diff options
Diffstat (limited to 'comm/chat/protocols/matrix/lib/matrix-sdk')
193 files changed, 58202 insertions, 0 deletions
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/IIdentityServerProvider.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/IIdentityServerProvider.js new file mode 100644 index 0000000000..430afc16cd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/IIdentityServerProvider.js @@ -0,0 +1,5 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/PushRules.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/PushRules.js new file mode 100644 index 0000000000..a3f9efa1ef --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/PushRules.js @@ -0,0 +1,101 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.TweakName = exports.RuleId = exports.PushRuleKind = exports.PushRuleActionName = exports.DMMemberCountCondition = exports.ConditionOperator = exports.ConditionKind = void 0; +exports.isDmMemberCountCondition = isDmMemberCountCondition; +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +// allow camelcase as these are things that go onto the wire +/* eslint-disable camelcase */ +let PushRuleActionName = /*#__PURE__*/function (PushRuleActionName) { + PushRuleActionName["DontNotify"] = "dont_notify"; + PushRuleActionName["Notify"] = "notify"; + PushRuleActionName["Coalesce"] = "coalesce"; + return PushRuleActionName; +}({}); +exports.PushRuleActionName = PushRuleActionName; +let TweakName = /*#__PURE__*/function (TweakName) { + TweakName["Highlight"] = "highlight"; + TweakName["Sound"] = "sound"; + return TweakName; +}({}); +exports.TweakName = TweakName; +let ConditionOperator = /*#__PURE__*/function (ConditionOperator) { + ConditionOperator["ExactEquals"] = "=="; + ConditionOperator["LessThan"] = "<"; + ConditionOperator["GreaterThan"] = ">"; + ConditionOperator["GreaterThanOrEqual"] = ">="; + ConditionOperator["LessThanOrEqual"] = "<="; + return ConditionOperator; +}({}); +exports.ConditionOperator = ConditionOperator; +const DMMemberCountCondition = "2"; +exports.DMMemberCountCondition = DMMemberCountCondition; +function isDmMemberCountCondition(condition) { + return condition === "==2" || condition === "2"; +} +let ConditionKind = /*#__PURE__*/function (ConditionKind) { + ConditionKind["EventMatch"] = "event_match"; + ConditionKind["EventPropertyIs"] = "event_property_is"; + ConditionKind["EventPropertyContains"] = "event_property_contains"; + ConditionKind["ContainsDisplayName"] = "contains_display_name"; + ConditionKind["RoomMemberCount"] = "room_member_count"; + ConditionKind["SenderNotificationPermission"] = "sender_notification_permission"; + ConditionKind["CallStarted"] = "call_started"; + ConditionKind["CallStartedPrefix"] = "org.matrix.msc3914.call_started"; + return ConditionKind; +}({}); // XXX: custom conditions are possible but always fail, and break the typescript discriminated union so ignore them here +// IPushRuleCondition<Exclude<string, ConditionKind>> unfortunately does not resolve this at the time of writing. +exports.ConditionKind = ConditionKind; +let PushRuleKind = /*#__PURE__*/function (PushRuleKind) { + PushRuleKind["Override"] = "override"; + PushRuleKind["ContentSpecific"] = "content"; + PushRuleKind["RoomSpecific"] = "room"; + PushRuleKind["SenderSpecific"] = "sender"; + PushRuleKind["Underride"] = "underride"; + return PushRuleKind; +}({}); +exports.PushRuleKind = PushRuleKind; +let RuleId = /*#__PURE__*/function (RuleId) { + RuleId["Master"] = ".m.rule.master"; + RuleId["IsUserMention"] = ".org.matrix.msc3952.is_user_mention"; + RuleId["IsRoomMention"] = ".org.matrix.msc3952.is_room_mention"; + RuleId["ContainsDisplayName"] = ".m.rule.contains_display_name"; + RuleId["ContainsUserName"] = ".m.rule.contains_user_name"; + RuleId["AtRoomNotification"] = ".m.rule.roomnotif"; + RuleId["DM"] = ".m.rule.room_one_to_one"; + RuleId["EncryptedDM"] = ".m.rule.encrypted_room_one_to_one"; + RuleId["Message"] = ".m.rule.message"; + RuleId["EncryptedMessage"] = ".m.rule.encrypted"; + RuleId["InviteToSelf"] = ".m.rule.invite_for_me"; + RuleId["MemberEvent"] = ".m.rule.member_event"; + RuleId["IncomingCall"] = ".m.rule.call"; + RuleId["SuppressNotices"] = ".m.rule.suppress_notices"; + RuleId["Tombstone"] = ".m.rule.tombstone"; + RuleId["PollStart"] = ".m.rule.poll_start"; + RuleId["PollStartUnstable"] = ".org.matrix.msc3930.rule.poll_start"; + RuleId["PollEnd"] = ".m.rule.poll_end"; + RuleId["PollEndUnstable"] = ".org.matrix.msc3930.rule.poll_end"; + RuleId["PollStartOneToOne"] = ".m.rule.poll_start_one_to_one"; + RuleId["PollStartOneToOneUnstable"] = ".org.matrix.msc3930.rule.poll_start_one_to_one"; + RuleId["PollEndOneToOne"] = ".m.rule.poll_end_one_to_one"; + RuleId["PollEndOneToOneUnstable"] = ".org.matrix.msc3930.rule.poll_end_one_to_one"; + return RuleId; +}({}); +/* eslint-enable camelcase */ +exports.RuleId = RuleId;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/another-json.d.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/another-json.d.js new file mode 100644 index 0000000000..9a390c31f7 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/another-json.d.js @@ -0,0 +1 @@ +"use strict";
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/auth.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/auth.js new file mode 100644 index 0000000000..360f679311 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/auth.js @@ -0,0 +1,68 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.SSOAction = exports.IdentityProviderBrand = exports.DELEGATED_OIDC_COMPATIBILITY = void 0; +var _NamespacedValue = require("../NamespacedValue"); +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// disable lint because these are wire responses +/* eslint-disable camelcase */ + +/** + * Represents a response to the CSAPI `/refresh` endpoint. + */ + +/* eslint-enable camelcase */ + +/** + * Response to GET login flows as per https://spec.matrix.org/v1.3/client-server-api/#get_matrixclientv3login + */ + +const DELEGATED_OIDC_COMPATIBILITY = new _NamespacedValue.UnstableValue("delegated_oidc_compatibility", "org.matrix.msc3824.delegated_oidc_compatibility"); + +/** + * Representation of SSO flow as per https://spec.matrix.org/v1.3/client-server-api/#client-login-via-sso + */ +exports.DELEGATED_OIDC_COMPATIBILITY = DELEGATED_OIDC_COMPATIBILITY; +let IdentityProviderBrand = /*#__PURE__*/function (IdentityProviderBrand) { + IdentityProviderBrand["Gitlab"] = "gitlab"; + IdentityProviderBrand["Github"] = "github"; + IdentityProviderBrand["Apple"] = "apple"; + IdentityProviderBrand["Google"] = "google"; + IdentityProviderBrand["Facebook"] = "facebook"; + IdentityProviderBrand["Twitter"] = "twitter"; + return IdentityProviderBrand; +}({}); +/** + * Parameters to login request as per https://spec.matrix.org/v1.3/client-server-api/#login + */ +/* eslint-disable camelcase */ +exports.IdentityProviderBrand = IdentityProviderBrand; +/* eslint-enable camelcase */ +let SSOAction = /*#__PURE__*/function (SSOAction) { + SSOAction["LOGIN"] = "login"; + SSOAction["REGISTER"] = "register"; + return SSOAction; +}({}); +/** + * The result of a successful [MSC3882](https://github.com/matrix-org/matrix-spec-proposals/pull/3882) + * `m.login.token` issuance request. + * Note that this is UNSTABLE and subject to breaking changes without notice. + */ +exports.SSOAction = SSOAction;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/beacon.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/beacon.js new file mode 100644 index 0000000000..b844583bcc --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/beacon.js @@ -0,0 +1,126 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.M_BEACON_INFO = exports.M_BEACON = void 0; +var _NamespacedValue = require("../NamespacedValue"); +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Beacon info and beacon event types as described in MSC3672 + * https://github.com/matrix-org/matrix-spec-proposals/pull/3672 + */ + +/** + * Beacon info events are state events. + * We have two requirements for these events: + * 1. they can only be written by their owner + * 2. a user can have an arbitrary number of beacon_info events + * + * 1. is achieved by setting the state_key to the owners mxid. + * Event keys in room state are a combination of `type` + `state_key`. + * To achieve an arbitrary number of only owner-writable state events + * we introduce a variable suffix to the event type + * + * @example + * ``` + * { + * "type": "m.beacon_info.@matthew:matrix.org.1", + * "state_key": "@matthew:matrix.org", + * "content": { + * "m.beacon_info": { + * "description": "The Matthew Tracker", + * "timeout": 86400000, + * }, + * // more content as described below + * } + * }, + * { + * "type": "m.beacon_info.@matthew:matrix.org.2", + * "state_key": "@matthew:matrix.org", + * "content": { + * "m.beacon_info": { + * "description": "Another different Matthew tracker", + * "timeout": 400000, + * }, + * // more content as described below + * } + * } + * ``` + */ + +/** + * Non-variable type for m.beacon_info event content + */ +const M_BEACON_INFO = new _NamespacedValue.UnstableValue("m.beacon_info", "org.matrix.msc3672.beacon_info"); +exports.M_BEACON_INFO = M_BEACON_INFO; +const M_BEACON = new _NamespacedValue.UnstableValue("m.beacon", "org.matrix.msc3672.beacon"); + +/** + * m.beacon_info Event example from the spec + * https://github.com/matrix-org/matrix-spec-proposals/pull/3672 + * @example + * ``` + * { + * "type": "m.beacon_info", + * "state_key": "@matthew:matrix.org", + * "content": { + * "m.beacon_info": { + * "description": "The Matthew Tracker", // same as an `m.location` description + * "timeout": 86400000, // how long from the last event until we consider the beacon inactive in milliseconds + * }, + * "m.ts": 1436829458432, // creation timestamp of the beacon on the client + * "m.asset": { + * "type": "m.self" // the type of asset being tracked as per MSC3488 + * } + * } + * } + * ``` + */ + +/** + * m.beacon_info.* event content + */ + +/** + * m.beacon event example + * https://github.com/matrix-org/matrix-spec-proposals/pull/3672 + * @example + * ``` + * { + * "type": "m.beacon", + * "sender": "@matthew:matrix.org", + * "content": { + * "m.relates_to": { // from MSC2674: https://github.com/matrix-org/matrix-doc/pull/2674 + * "rel_type": "m.reference", // from MSC3267: https://github.com/matrix-org/matrix-doc/pull/3267 + * "event_id": "$beacon_info" + * }, + * "m.location": { + * "uri": "geo:51.5008,0.1247;u=35", + * "description": "Arbitrary beacon information" + * }, + * "m.ts": 1636829458432, + * } + * } + * ``` + */ + +/** + * Content of an m.beacon event + */ +exports.M_BEACON = M_BEACON;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/crypto.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/crypto.js new file mode 100644 index 0000000000..430afc16cd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/crypto.js @@ -0,0 +1,5 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/event.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/event.js new file mode 100644 index 0000000000..1dbbbcaf5e --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/event.js @@ -0,0 +1,240 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.UNSTABLE_MSC3089_TREE_SUBTYPE = exports.UNSTABLE_MSC3089_LEAF = exports.UNSTABLE_MSC3089_BRANCH = exports.UNSTABLE_MSC3088_PURPOSE = exports.UNSTABLE_MSC3088_ENABLED = exports.UNSTABLE_MSC2716_MARKER = exports.UNSTABLE_ELEMENT_FUNCTIONAL_USERS = exports.UNSIGNED_THREAD_ID_FIELD = exports.ToDeviceMessageId = exports.RoomType = exports.RoomCreateTypeField = exports.RelationType = exports.PUSHER_ENABLED = exports.PUSHER_DEVICE_ID = exports.MsgType = exports.MSC3912_RELATION_BASED_REDACTIONS_PROP = exports.LOCAL_NOTIFICATION_SETTINGS_PREFIX = exports.EventType = exports.EVENT_VISIBILITY_CHANGE_TYPE = void 0; +var _NamespacedValue = require("../NamespacedValue"); +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +let EventType = /*#__PURE__*/function (EventType) { + EventType["RoomCanonicalAlias"] = "m.room.canonical_alias"; + EventType["RoomCreate"] = "m.room.create"; + EventType["RoomJoinRules"] = "m.room.join_rules"; + EventType["RoomMember"] = "m.room.member"; + EventType["RoomThirdPartyInvite"] = "m.room.third_party_invite"; + EventType["RoomPowerLevels"] = "m.room.power_levels"; + EventType["RoomName"] = "m.room.name"; + EventType["RoomTopic"] = "m.room.topic"; + EventType["RoomAvatar"] = "m.room.avatar"; + EventType["RoomPinnedEvents"] = "m.room.pinned_events"; + EventType["RoomEncryption"] = "m.room.encryption"; + EventType["RoomHistoryVisibility"] = "m.room.history_visibility"; + EventType["RoomGuestAccess"] = "m.room.guest_access"; + EventType["RoomServerAcl"] = "m.room.server_acl"; + EventType["RoomTombstone"] = "m.room.tombstone"; + EventType["RoomPredecessor"] = "org.matrix.msc3946.room_predecessor"; + EventType["SpaceChild"] = "m.space.child"; + EventType["SpaceParent"] = "m.space.parent"; + EventType["RoomRedaction"] = "m.room.redaction"; + EventType["RoomMessage"] = "m.room.message"; + EventType["RoomMessageEncrypted"] = "m.room.encrypted"; + EventType["Sticker"] = "m.sticker"; + EventType["CallInvite"] = "m.call.invite"; + EventType["CallCandidates"] = "m.call.candidates"; + EventType["CallAnswer"] = "m.call.answer"; + EventType["CallHangup"] = "m.call.hangup"; + EventType["CallReject"] = "m.call.reject"; + EventType["CallSelectAnswer"] = "m.call.select_answer"; + EventType["CallNegotiate"] = "m.call.negotiate"; + EventType["CallSDPStreamMetadataChanged"] = "m.call.sdp_stream_metadata_changed"; + EventType["CallSDPStreamMetadataChangedPrefix"] = "org.matrix.call.sdp_stream_metadata_changed"; + EventType["CallReplaces"] = "m.call.replaces"; + EventType["CallAssertedIdentity"] = "m.call.asserted_identity"; + EventType["CallAssertedIdentityPrefix"] = "org.matrix.call.asserted_identity"; + EventType["KeyVerificationRequest"] = "m.key.verification.request"; + EventType["KeyVerificationStart"] = "m.key.verification.start"; + EventType["KeyVerificationCancel"] = "m.key.verification.cancel"; + EventType["KeyVerificationMac"] = "m.key.verification.mac"; + EventType["KeyVerificationDone"] = "m.key.verification.done"; + EventType["KeyVerificationKey"] = "m.key.verification.key"; + EventType["KeyVerificationAccept"] = "m.key.verification.accept"; + EventType["KeyVerificationReady"] = "m.key.verification.ready"; + EventType["RoomMessageFeedback"] = "m.room.message.feedback"; + EventType["Reaction"] = "m.reaction"; + EventType["PollStart"] = "org.matrix.msc3381.poll.start"; + EventType["Typing"] = "m.typing"; + EventType["Receipt"] = "m.receipt"; + EventType["Presence"] = "m.presence"; + EventType["FullyRead"] = "m.fully_read"; + EventType["Tag"] = "m.tag"; + EventType["SpaceOrder"] = "org.matrix.msc3230.space_order"; + EventType["PushRules"] = "m.push_rules"; + EventType["Direct"] = "m.direct"; + EventType["IgnoredUserList"] = "m.ignored_user_list"; + EventType["RoomKey"] = "m.room_key"; + EventType["RoomKeyRequest"] = "m.room_key_request"; + EventType["ForwardedRoomKey"] = "m.forwarded_room_key"; + EventType["Dummy"] = "m.dummy"; + EventType["GroupCallPrefix"] = "org.matrix.msc3401.call"; + EventType["GroupCallMemberPrefix"] = "org.matrix.msc3401.call.member"; + return EventType; +}({}); +exports.EventType = EventType; +let RelationType = /*#__PURE__*/function (RelationType) { + RelationType["Annotation"] = "m.annotation"; + RelationType["Replace"] = "m.replace"; + RelationType["Reference"] = "m.reference"; + RelationType["Thread"] = "m.thread"; + return RelationType; +}({}); +exports.RelationType = RelationType; +let MsgType = /*#__PURE__*/function (MsgType) { + MsgType["Text"] = "m.text"; + MsgType["Emote"] = "m.emote"; + MsgType["Notice"] = "m.notice"; + MsgType["Image"] = "m.image"; + MsgType["File"] = "m.file"; + MsgType["Audio"] = "m.audio"; + MsgType["Location"] = "m.location"; + MsgType["Video"] = "m.video"; + MsgType["KeyVerificationRequest"] = "m.key.verification.request"; + return MsgType; +}({}); +exports.MsgType = MsgType; +const RoomCreateTypeField = "type"; +exports.RoomCreateTypeField = RoomCreateTypeField; +let RoomType = /*#__PURE__*/function (RoomType) { + RoomType["Space"] = "m.space"; + RoomType["UnstableCall"] = "org.matrix.msc3417.call"; + RoomType["ElementVideo"] = "io.element.video"; + return RoomType; +}({}); +exports.RoomType = RoomType; +const ToDeviceMessageId = "org.matrix.msgid"; + +/** + * Identifier for an [MSC3088](https://github.com/matrix-org/matrix-doc/pull/3088) + * room purpose. Note that this reference is UNSTABLE and subject to breaking changes, + * including its eventual removal. + */ +exports.ToDeviceMessageId = ToDeviceMessageId; +const UNSTABLE_MSC3088_PURPOSE = new _NamespacedValue.UnstableValue("m.room.purpose", "org.matrix.msc3088.purpose"); + +/** + * Enabled flag for an [MSC3088](https://github.com/matrix-org/matrix-doc/pull/3088) + * room purpose. Note that this reference is UNSTABLE and subject to breaking changes, + * including its eventual removal. + */ +exports.UNSTABLE_MSC3088_PURPOSE = UNSTABLE_MSC3088_PURPOSE; +const UNSTABLE_MSC3088_ENABLED = new _NamespacedValue.UnstableValue("m.enabled", "org.matrix.msc3088.enabled"); + +/** + * Subtype for an [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) space-room. + * Note that this reference is UNSTABLE and subject to breaking changes, including its + * eventual removal. + */ +exports.UNSTABLE_MSC3088_ENABLED = UNSTABLE_MSC3088_ENABLED; +const UNSTABLE_MSC3089_TREE_SUBTYPE = new _NamespacedValue.UnstableValue("m.data_tree", "org.matrix.msc3089.data_tree"); + +/** + * Leaf type for an event in a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) space-room. + * Note that this reference is UNSTABLE and subject to breaking changes, including its + * eventual removal. + */ +exports.UNSTABLE_MSC3089_TREE_SUBTYPE = UNSTABLE_MSC3089_TREE_SUBTYPE; +const UNSTABLE_MSC3089_LEAF = new _NamespacedValue.UnstableValue("m.leaf", "org.matrix.msc3089.leaf"); + +/** + * Branch (Leaf Reference) type for the index approach in a + * [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) space-room. Note that this reference is + * UNSTABLE and subject to breaking changes, including its eventual removal. + */ +exports.UNSTABLE_MSC3089_LEAF = UNSTABLE_MSC3089_LEAF; +const UNSTABLE_MSC3089_BRANCH = new _NamespacedValue.UnstableValue("m.branch", "org.matrix.msc3089.branch"); + +/** + * Marker event type to point back at imported historical content in a room. See + * [MSC2716](https://github.com/matrix-org/matrix-spec-proposals/pull/2716). + * Note that this reference is UNSTABLE and subject to breaking changes, + * including its eventual removal. + */ +exports.UNSTABLE_MSC3089_BRANCH = UNSTABLE_MSC3089_BRANCH; +const UNSTABLE_MSC2716_MARKER = new _NamespacedValue.UnstableValue("m.room.marker", "org.matrix.msc2716.marker"); + +/** + * Name of the "with_relations" request property for relation based redactions. + * {@link https://github.com/matrix-org/matrix-spec-proposals/pull/3912} + */ +exports.UNSTABLE_MSC2716_MARKER = UNSTABLE_MSC2716_MARKER; +const MSC3912_RELATION_BASED_REDACTIONS_PROP = new _NamespacedValue.UnstableValue("with_relations", "org.matrix.msc3912.with_relations"); + +/** + * Functional members type for declaring a purpose of room members (e.g. helpful bots). + * Note that this reference is UNSTABLE and subject to breaking changes, including its + * eventual removal. + * + * Schema (TypeScript): + * ``` + * { + * service_members?: string[] + * } + * ``` + * + * @example + * ``` + * { + * "service_members": [ + * "@helperbot:localhost", + * "@reminderbot:alice.tdl" + * ] + * } + * ``` + */ +exports.MSC3912_RELATION_BASED_REDACTIONS_PROP = MSC3912_RELATION_BASED_REDACTIONS_PROP; +const UNSTABLE_ELEMENT_FUNCTIONAL_USERS = new _NamespacedValue.UnstableValue("io.element.functional_members", "io.element.functional_members"); + +/** + * A type of message that affects visibility of a message, + * as per https://github.com/matrix-org/matrix-doc/pull/3531 + * + * @experimental + */ +exports.UNSTABLE_ELEMENT_FUNCTIONAL_USERS = UNSTABLE_ELEMENT_FUNCTIONAL_USERS; +const EVENT_VISIBILITY_CHANGE_TYPE = new _NamespacedValue.UnstableValue("m.visibility", "org.matrix.msc3531.visibility"); + +/** + * https://github.com/matrix-org/matrix-doc/pull/3881 + * + * @experimental + */ +exports.EVENT_VISIBILITY_CHANGE_TYPE = EVENT_VISIBILITY_CHANGE_TYPE; +const PUSHER_ENABLED = new _NamespacedValue.UnstableValue("enabled", "org.matrix.msc3881.enabled"); + +/** + * https://github.com/matrix-org/matrix-doc/pull/3881 + * + * @experimental + */ +exports.PUSHER_ENABLED = PUSHER_ENABLED; +const PUSHER_DEVICE_ID = new _NamespacedValue.UnstableValue("device_id", "org.matrix.msc3881.device_id"); + +/** + * https://github.com/matrix-org/matrix-doc/pull/3890 + * + * @experimental + */ +exports.PUSHER_DEVICE_ID = PUSHER_DEVICE_ID; +const LOCAL_NOTIFICATION_SETTINGS_PREFIX = new _NamespacedValue.UnstableValue("m.local_notification_settings", "org.matrix.msc3890.local_notification_settings"); + +/** + * https://github.com/matrix-org/matrix-doc/pull/4023 + * + * @experimental + */ +exports.LOCAL_NOTIFICATION_SETTINGS_PREFIX = LOCAL_NOTIFICATION_SETTINGS_PREFIX; +const UNSIGNED_THREAD_ID_FIELD = new _NamespacedValue.UnstableValue("thread_id", "org.matrix.msc4023.thread_id"); +exports.UNSIGNED_THREAD_ID_FIELD = UNSIGNED_THREAD_ID_FIELD;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/extensible_events.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/extensible_events.js new file mode 100644 index 0000000000..847909dd99 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/extensible_events.js @@ -0,0 +1,121 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.REFERENCE_RELATION = exports.M_TEXT = exports.M_MESSAGE = exports.M_HTML = void 0; +exports.isEventTypeSame = isEventTypeSame; +var _matrixEventsSdk = require("matrix-events-sdk"); +var _utilities = require("../extensible_events_v1/utilities"); +/* +Copyright 2021 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Types and utilities for MSC1767: Extensible events (version 1) in Matrix + +/** + * Represents the stable and unstable values of a given namespace. + */ + +/** + * Represents a namespaced value, if the value is a string. Used to extract provided types + * from a TSNamespace<N> (in cases where only stable *or* unstable is provided). + */ + +/** + * Creates a type which is V when T is `never`, otherwise T. + */ +// See https://github.com/microsoft/TypeScript/issues/23182#issuecomment-379091887 for details on the array syntax. + +/** + * The namespaced value for m.message + */ +const M_MESSAGE = new _matrixEventsSdk.UnstableValue("m.message", "org.matrix.msc1767.message"); + +/** + * An m.message event rendering + */ + +/** + * The content for an m.message event + */ +exports.M_MESSAGE = M_MESSAGE; +/** + * The namespaced value for m.text + */ +const M_TEXT = new _matrixEventsSdk.UnstableValue("m.text", "org.matrix.msc1767.text"); + +/** + * The content for an m.text event + */ +exports.M_TEXT = M_TEXT; +/** + * The namespaced value for m.html + */ +const M_HTML = new _matrixEventsSdk.UnstableValue("m.html", "org.matrix.msc1767.html"); + +/** + * The content for an m.html event + */ + +/** + * The content for an m.message, m.text, or m.html event + */ +exports.M_HTML = M_HTML; +/** + * The namespaced value for an m.reference relation + */ +const REFERENCE_RELATION = new _matrixEventsSdk.NamespacedValue("m.reference"); + +/** + * Represents any relation type + */ + +/** + * An m.relates_to relationship + */ + +/** + * Partial types for a Matrix Event. + */ + +/** + * Represents a potentially namespaced event type. + */ +exports.REFERENCE_RELATION = REFERENCE_RELATION; +/** + * Determines if two event types are the same, including namespaces. + * @param given - The given event type. This will be compared + * against the expected type. + * @param expected - The expected event type. + * @returns True if the given type matches the expected type. + */ +function isEventTypeSame(given, expected) { + if (typeof given === "string") { + if (typeof expected === "string") { + return expected === given; + } else { + return expected.matches(given); + } + } else { + if (typeof expected === "string") { + return given.matches(expected); + } else { + const expectedNs = expected; + const givenNs = given; + return expectedNs.matches(givenNs.name) || (0, _utilities.isProvided)(givenNs.altName) && expectedNs.matches(givenNs.altName); + } + } +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/global.d.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/global.d.js new file mode 100644 index 0000000000..9329ae092a --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/global.d.js @@ -0,0 +1,6 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +require("@matrix-org/olm");
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/local_notifications.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/local_notifications.js new file mode 100644 index 0000000000..430afc16cd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/local_notifications.js @@ -0,0 +1,5 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/location.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/location.js new file mode 100644 index 0000000000..0acaf952bf --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/location.js @@ -0,0 +1,72 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.M_TIMESTAMP = exports.M_LOCATION = exports.M_ASSET = exports.LocationAssetType = void 0; +var _NamespacedValue = require("../NamespacedValue"); +var _extensible_events = require("./extensible_events"); +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +// Types for MSC3488 - m.location: Extending events with location data +let LocationAssetType = /*#__PURE__*/function (LocationAssetType) { + LocationAssetType["Self"] = "m.self"; + LocationAssetType["Pin"] = "m.pin"; + return LocationAssetType; +}({}); +exports.LocationAssetType = LocationAssetType; +const M_ASSET = new _NamespacedValue.UnstableValue("m.asset", "org.matrix.msc3488.asset"); + +/** + * The event definition for an m.asset event (in content) + */ +exports.M_ASSET = M_ASSET; +const M_TIMESTAMP = new _NamespacedValue.UnstableValue("m.ts", "org.matrix.msc3488.ts"); +/** + * The event definition for an m.ts event (in content) + */ +exports.M_TIMESTAMP = M_TIMESTAMP; +const M_LOCATION = new _NamespacedValue.UnstableValue("m.location", "org.matrix.msc3488.location"); + +/* From the spec at: + * https://github.com/matrix-org/matrix-doc/blob/matthew/location/proposals/3488-location.md +{ + "type": "m.room.message", + "content": { + "body": "Matthew was at geo:51.5008,0.1247;u=35 as of Sat Nov 13 18:50:58 2021", + "msgtype": "m.location", + "geo_uri": "geo:51.5008,0.1247;u=35", + "m.location": { + "uri": "geo:51.5008,0.1247;u=35", + "description": "Matthew's whereabouts", + }, + "m.asset": { + "type": "m.self" + }, + "m.text": "Matthew was at geo:51.5008,0.1247;u=35 as of Sat Nov 13 18:50:58 2021", + "m.ts": 1636829458432, + } +} +*/ + +/** + * The content for an m.location event + */ + +/** + * Possible content for location events as sent over the wire + */ +exports.M_LOCATION = M_LOCATION;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/partials.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/partials.js new file mode 100644 index 0000000000..8d12c51c3d --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/partials.js @@ -0,0 +1,63 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.Visibility = exports.RestrictedAllowType = exports.Preset = exports.JoinRule = exports.HistoryVisibility = exports.GuestAccess = void 0; +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +let Visibility = /*#__PURE__*/function (Visibility) { + Visibility["Public"] = "public"; + Visibility["Private"] = "private"; + return Visibility; +}({}); +exports.Visibility = Visibility; +let Preset = /*#__PURE__*/function (Preset) { + Preset["PrivateChat"] = "private_chat"; + Preset["TrustedPrivateChat"] = "trusted_private_chat"; + Preset["PublicChat"] = "public_chat"; + return Preset; +}({}); +exports.Preset = Preset; +// Knock and private are reserved keywords which are not yet implemented. +let JoinRule = /*#__PURE__*/function (JoinRule) { + JoinRule["Public"] = "public"; + JoinRule["Invite"] = "invite"; + JoinRule["Private"] = "private"; + JoinRule["Knock"] = "knock"; + JoinRule["Restricted"] = "restricted"; + return JoinRule; +}({}); +exports.JoinRule = JoinRule; +let RestrictedAllowType = /*#__PURE__*/function (RestrictedAllowType) { + RestrictedAllowType["RoomMembership"] = "m.room_membership"; + return RestrictedAllowType; +}({}); +exports.RestrictedAllowType = RestrictedAllowType; +let GuestAccess = /*#__PURE__*/function (GuestAccess) { + GuestAccess["CanJoin"] = "can_join"; + GuestAccess["Forbidden"] = "forbidden"; + return GuestAccess; +}({}); +exports.GuestAccess = GuestAccess; +let HistoryVisibility = /*#__PURE__*/function (HistoryVisibility) { + HistoryVisibility["Invited"] = "invited"; + HistoryVisibility["Joined"] = "joined"; + HistoryVisibility["Shared"] = "shared"; + HistoryVisibility["WorldReadable"] = "world_readable"; + return HistoryVisibility; +}({}); +exports.HistoryVisibility = HistoryVisibility;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/polls.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/polls.js new file mode 100644 index 0000000000..6366b7ebfa --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/polls.js @@ -0,0 +1,93 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.M_POLL_START = exports.M_POLL_RESPONSE = exports.M_POLL_KIND_UNDISCLOSED = exports.M_POLL_KIND_DISCLOSED = exports.M_POLL_END = void 0; +var _matrixEventsSdk = require("matrix-events-sdk"); +/* +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Identifier for a disclosed poll. + */ +const M_POLL_KIND_DISCLOSED = new _matrixEventsSdk.UnstableValue("m.poll.disclosed", "org.matrix.msc3381.poll.disclosed"); + +/** + * Identifier for an undisclosed poll. + */ +exports.M_POLL_KIND_DISCLOSED = M_POLL_KIND_DISCLOSED; +const M_POLL_KIND_UNDISCLOSED = new _matrixEventsSdk.UnstableValue("m.poll.undisclosed", "org.matrix.msc3381.poll.undisclosed"); + +/** + * Any poll kind. + */ + +/** + * Known poll kind namespaces. + */ +exports.M_POLL_KIND_UNDISCLOSED = M_POLL_KIND_UNDISCLOSED; +/** + * The namespaced value for m.poll.start + */ +const M_POLL_START = new _matrixEventsSdk.UnstableValue("m.poll.start", "org.matrix.msc3381.poll.start"); + +/** + * The m.poll.start type within event content + */ + +/** + * A poll answer. + */ + +/** + * The event definition for an m.poll.start event (in content) + */ + +/** + * The content for an m.poll.start event + */ +exports.M_POLL_START = M_POLL_START; +/** + * The namespaced value for m.poll.response + */ +const M_POLL_RESPONSE = new _matrixEventsSdk.UnstableValue("m.poll.response", "org.matrix.msc3381.poll.response"); + +/** + * The m.poll.response type within event content + */ + +/** + * The event definition for an m.poll.response event (in content) + */ + +/** + * The content for an m.poll.response event + */ +exports.M_POLL_RESPONSE = M_POLL_RESPONSE; +/** + * The namespaced value for m.poll.end + */ +const M_POLL_END = new _matrixEventsSdk.UnstableValue("m.poll.end", "org.matrix.msc3381.poll.end"); + +/** + * The event definition for an m.poll.end event (in content) + */ + +/** + * The content for an m.poll.end event + */ +exports.M_POLL_END = M_POLL_END;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/read_receipts.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/read_receipts.js new file mode 100644 index 0000000000..70ad6132f5 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/read_receipts.js @@ -0,0 +1,33 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.ReceiptType = exports.MAIN_ROOM_TIMELINE = void 0; +/* +Copyright 2022 Šimon Brandner <simon.bra.ag@gmail.com> + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +let ReceiptType = /*#__PURE__*/function (ReceiptType) { + ReceiptType["Read"] = "m.read"; + ReceiptType["FullyRead"] = "m.fully_read"; + ReceiptType["ReadPrivate"] = "m.read.private"; + return ReceiptType; +}({}); +exports.ReceiptType = ReceiptType; +const MAIN_ROOM_TIMELINE = "main"; + +// We will only hold a synthetic receipt if we do not have a real receipt or the synthetic is newer. +// map: receipt type → user Id → receipt +exports.MAIN_ROOM_TIMELINE = MAIN_ROOM_TIMELINE;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/requests.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/requests.js new file mode 100644 index 0000000000..430afc16cd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/requests.js @@ -0,0 +1,5 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/search.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/search.js new file mode 100644 index 0000000000..52a63fd30b --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/search.js @@ -0,0 +1,35 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.SearchOrderBy = void 0; +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +// Types relating to the /search API +/* eslint-disable camelcase */ +var GroupKey = /*#__PURE__*/function (GroupKey) { + GroupKey["RoomId"] = "room_id"; + GroupKey["Sender"] = "sender"; + return GroupKey; +}(GroupKey || {}); +let SearchOrderBy = /*#__PURE__*/function (SearchOrderBy) { + SearchOrderBy["Recent"] = "recent"; + SearchOrderBy["Rank"] = "rank"; + return SearchOrderBy; +}({}); +/* eslint-enable camelcase */ +exports.SearchOrderBy = SearchOrderBy;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/signed.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/signed.js new file mode 100644 index 0000000000..430afc16cd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/signed.js @@ -0,0 +1,5 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/spaces.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/spaces.js new file mode 100644 index 0000000000..430afc16cd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/spaces.js @@ -0,0 +1,5 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/synapse.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/synapse.js new file mode 100644 index 0000000000..430afc16cd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/synapse.js @@ -0,0 +1,5 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/sync.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/sync.js new file mode 100644 index 0000000000..4e1da31379 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/sync.js @@ -0,0 +1,30 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.UNREAD_THREAD_NOTIFICATIONS = void 0; +var _NamespacedValue = require("../NamespacedValue"); +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * https://github.com/matrix-org/matrix-doc/pull/3773 + * + * @experimental + */ +const UNREAD_THREAD_NOTIFICATIONS = new _NamespacedValue.ServerControlledNamespacedValue("unread_thread_notifications", "org.matrix.msc3773.unread_thread_notifications"); +exports.UNREAD_THREAD_NOTIFICATIONS = UNREAD_THREAD_NOTIFICATIONS;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/threepids.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/threepids.js new file mode 100644 index 0000000000..5c0f76a731 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/threepids.js @@ -0,0 +1,27 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.ThreepidMedium = void 0; +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +let ThreepidMedium = /*#__PURE__*/function (ThreepidMedium) { + ThreepidMedium["Email"] = "email"; + ThreepidMedium["Phone"] = "msisdn"; + return ThreepidMedium; +}({}); // TODO: Are these types universal, or specific to just /account/3pid? +exports.ThreepidMedium = ThreepidMedium;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/topic.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/topic.js new file mode 100644 index 0000000000..97db425c21 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/topic.js @@ -0,0 +1,63 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.M_TOPIC = void 0; +var _NamespacedValue = require("../NamespacedValue"); +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Extensible topic event type based on MSC3765 + * https://github.com/matrix-org/matrix-spec-proposals/pull/3765 + * + * @example + * ``` + * { + * "type": "m.room.topic, + * "state_key": "", + * "content": { + * "topic": "All about **pizza**", + * "m.topic": [{ + * "body": "All about **pizza**", + * "mimetype": "text/plain", + * }, { + * "body": "All about <b>pizza</b>", + * "mimetype": "text/html", + * }], + * } + * } + * ``` + */ + +/** + * The event type for an m.topic event (in content) + */ +const M_TOPIC = new _NamespacedValue.UnstableValue("m.topic", "org.matrix.msc3765.topic"); + +/** + * The event content for an m.topic event (in content) + */ + +/** + * The event definition for an m.topic event (in content) + */ + +/** + * The event content for an m.room.topic event + */ +exports.M_TOPIC = M_TOPIC;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/uia.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/uia.js new file mode 100644 index 0000000000..430afc16cd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/uia.js @@ -0,0 +1,5 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/LICENSE b/comm/chat/protocols/matrix/lib/matrix-sdk/LICENSE new file mode 100644 index 0000000000..f433b1a53f --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/LICENSE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/NamespacedValue.js b/comm/chat/protocols/matrix/lib/matrix-sdk/NamespacedValue.js new file mode 100644 index 0000000000..ec911fe886 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/NamespacedValue.js @@ -0,0 +1,123 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.UnstableValue = exports.ServerControlledNamespacedValue = exports.NamespacedValue = void 0; +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } +/* +Copyright 2021 - 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Represents a simple Matrix namespaced value. This will assume that if a stable prefix + * is provided that the stable prefix should be used when representing the identifier. + */ +class NamespacedValue { + // Stable is optional, but one of the two parameters is required, hence the weird-looking types. + // Goal is to to have developers explicitly say there is no stable value (if applicable). + + constructor(stable, unstable) { + this.stable = stable; + this.unstable = unstable; + if (!this.unstable && !this.stable) { + throw new Error("One of stable or unstable values must be supplied"); + } + } + get name() { + if (this.stable) { + return this.stable; + } + return this.unstable; + } + get altName() { + if (!this.stable) { + return null; + } + return this.unstable; + } + get names() { + const names = [this.name]; + const altName = this.altName; + if (altName) names.push(altName); + return names; + } + matches(val) { + return this.name === val || this.altName === val; + } + + // this desperately wants https://github.com/microsoft/TypeScript/pull/26349 at the top level of the class + // so we can instantiate `NamespacedValue<string, _, _>` as a default type for that namespace. + findIn(obj) { + let val = undefined; + if (this.name) { + val = obj?.[this.name]; + } + if (!val && this.altName) { + val = obj?.[this.altName]; + } + return val; + } + includedIn(arr) { + let included = false; + if (this.name) { + included = arr.includes(this.name); + } + if (!included && this.altName) { + included = arr.includes(this.altName); + } + return included; + } +} +exports.NamespacedValue = NamespacedValue; +class ServerControlledNamespacedValue extends NamespacedValue { + constructor(...args) { + super(...args); + _defineProperty(this, "preferUnstable", false); + } + setPreferUnstable(preferUnstable) { + this.preferUnstable = preferUnstable; + } + get name() { + if (this.stable && !this.preferUnstable) { + return this.stable; + } + return this.unstable; + } +} + +/** + * Represents a namespaced value which prioritizes the unstable value over the stable + * value. + */ +exports.ServerControlledNamespacedValue = ServerControlledNamespacedValue; +class UnstableValue extends NamespacedValue { + // Note: Constructor difference is that `unstable` is *required*. + constructor(stable, unstable) { + super(stable, unstable); + if (!this.unstable) { + throw new Error("Unstable value must be supplied"); + } + } + get name() { + return this.unstable; + } + get altName() { + return this.stable; + } +} +exports.UnstableValue = UnstableValue;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/ReEmitter.js b/comm/chat/protocols/matrix/lib/matrix-sdk/ReEmitter.js new file mode 100644 index 0000000000..17a2e986f3 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/ReEmitter.js @@ -0,0 +1,89 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.TypedReEmitter = exports.ReEmitter = void 0; +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd +Copyright 2017 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// eslint-disable-next-line no-restricted-imports + +class ReEmitter { + constructor(target) { + this.target = target; + // Map from emitter to event name to re-emitter + _defineProperty(this, "reEmitters", new WeakMap()); + } + reEmit(source, eventNames) { + let reEmittersByEvent = this.reEmitters.get(source); + if (!reEmittersByEvent) { + reEmittersByEvent = new Map(); + this.reEmitters.set(source, reEmittersByEvent); + } + for (const eventName of eventNames) { + if (reEmittersByEvent.has(eventName)) continue; + + // We include the source as the last argument for event handlers which may need it, + // such as read receipt listeners on the client class which won't have the context + // of the room. + const forSource = (...args) => { + // EventEmitter special cases 'error' to make the emit function throw if no + // handler is attached, which sort of makes sense for making sure that something + // handles an error, but for re-emitting, there could be a listener on the original + // source object so the test doesn't really work. We *could* try to replicate the + // same logic and throw if there is no listener on either the source or the target, + // but this behaviour is fairly undesireable for us anyway: the main place we throw + // 'error' events is for calls, where error events are usually emitted some time + // later by a different part of the code where 'emit' throwing because the app hasn't + // added an error handler isn't terribly helpful. (A better fix in retrospect may + // have been to just avoid using the event name 'error', but backwards compat...) + if (eventName === "error" && this.target.listenerCount("error") === 0) return; + this.target.emit(eventName, ...args, source); + }; + source.on(eventName, forSource); + reEmittersByEvent.set(eventName, forSource); + } + } + stopReEmitting(source, eventNames) { + const reEmittersByEvent = this.reEmitters.get(source); + if (!reEmittersByEvent) return; // We were never re-emitting these events in the first place + + for (const eventName of eventNames) { + source.off(eventName, reEmittersByEvent.get(eventName)); + reEmittersByEvent.delete(eventName); + } + if (reEmittersByEvent.size === 0) this.reEmitters.delete(source); + } +} +exports.ReEmitter = ReEmitter; +class TypedReEmitter extends ReEmitter { + constructor(target) { + super(target); + } + reEmit(source, eventNames) { + super.reEmit(source, eventNames); + } + stopReEmitting(source, eventNames) { + super.stopReEmitting(source, eventNames); + } +} +exports.TypedReEmitter = TypedReEmitter;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/ToDeviceMessageQueue.js b/comm/chat/protocols/matrix/lib/matrix-sdk/ToDeviceMessageQueue.js new file mode 100644 index 0000000000..e3edb80009 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/ToDeviceMessageQueue.js @@ -0,0 +1,133 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.ToDeviceMessageQueue = void 0; +var _event = require("./@types/event"); +var _logger = require("./logger"); +var _client = require("./client"); +var _scheduler = require("./scheduler"); +var _sync = require("./sync"); +var _utils = require("./utils"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2022 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +const MAX_BATCH_SIZE = 20; + +/** + * Maintains a queue of outgoing to-device messages, sending them + * as soon as the homeserver is reachable. + */ +class ToDeviceMessageQueue { + constructor(client) { + this.client = client; + _defineProperty(this, "sending", false); + _defineProperty(this, "running", true); + _defineProperty(this, "retryTimeout", null); + _defineProperty(this, "retryAttempts", 0); + _defineProperty(this, "sendQueue", async () => { + if (this.retryTimeout !== null) clearTimeout(this.retryTimeout); + this.retryTimeout = null; + if (this.sending || !this.running) return; + _logger.logger.debug("Attempting to send queued to-device messages"); + this.sending = true; + let headBatch; + try { + while (this.running) { + headBatch = await this.client.store.getOldestToDeviceBatch(); + if (headBatch === null) break; + await this.sendBatch(headBatch); + await this.client.store.removeToDeviceBatch(headBatch.id); + this.retryAttempts = 0; + } + + // Make sure we're still running after the async tasks: if not, stop. + if (!this.running) return; + _logger.logger.debug("All queued to-device messages sent"); + } catch (e) { + ++this.retryAttempts; + // eslint-disable-next-line @typescript-eslint/naming-convention + // eslint-disable-next-line new-cap + const retryDelay = _scheduler.MatrixScheduler.RETRY_BACKOFF_RATELIMIT(null, this.retryAttempts, e); + if (retryDelay === -1) { + // the scheduler function doesn't differentiate between fatal errors and just getting + // bored and giving up for now + if (Math.floor(e.httpStatus / 100) === 4) { + _logger.logger.error("Fatal error when sending to-device message - dropping to-device batch!", e); + await this.client.store.removeToDeviceBatch(headBatch.id); + } else { + _logger.logger.info("Automatic retry limit reached for to-device messages."); + } + return; + } + _logger.logger.info(`Failed to send batch of to-device messages. Will retry in ${retryDelay}ms`, e); + this.retryTimeout = setTimeout(this.sendQueue, retryDelay); + } finally { + this.sending = false; + } + }); + /** + * Listen to sync state changes and automatically resend any pending events + * once syncing is resumed + */ + _defineProperty(this, "onResumedSync", (state, oldState) => { + if (state === _sync.SyncState.Syncing && oldState !== _sync.SyncState.Syncing) { + _logger.logger.info(`Resuming queue after resumed sync`); + this.sendQueue(); + } + }); + } + start() { + this.running = true; + this.sendQueue(); + this.client.on(_client.ClientEvent.Sync, this.onResumedSync); + } + stop() { + this.running = false; + if (this.retryTimeout !== null) clearTimeout(this.retryTimeout); + this.retryTimeout = null; + this.client.removeListener(_client.ClientEvent.Sync, this.onResumedSync); + } + async queueBatch(batch) { + const batches = []; + for (let i = 0; i < batch.batch.length; i += MAX_BATCH_SIZE) { + const batchWithTxnId = { + eventType: batch.eventType, + batch: batch.batch.slice(i, i + MAX_BATCH_SIZE), + txnId: this.client.makeTxnId() + }; + batches.push(batchWithTxnId); + const msgmap = batchWithTxnId.batch.map(msg => `${msg.userId}/${msg.deviceId} (msgid ${msg.payload[_event.ToDeviceMessageId]})`); + _logger.logger.info(`Enqueuing batch of to-device messages. type=${batch.eventType} txnid=${batchWithTxnId.txnId}`, msgmap); + } + await this.client.store.saveToDeviceBatches(batches); + this.sendQueue(); + } + /** + * Attempts to send a batch of to-device messages. + */ + async sendBatch(batch) { + const contentMap = new _utils.MapWithDefault(() => new Map()); + for (const item of batch.batch) { + contentMap.getOrCreate(item.userId).set(item.deviceId, item.payload); + } + _logger.logger.info(`Sending batch of ${batch.batch.length} to-device messages with ID ${batch.id} and txnId ${batch.txnId}`); + await this.client.sendToDevice(batch.eventType, contentMap, batch.txnId); + } +} +exports.ToDeviceMessageQueue = ToDeviceMessageQueue;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/autodiscovery.js b/comm/chat/protocols/matrix/lib/matrix-sdk/autodiscovery.js new file mode 100644 index 0000000000..60d1ed5fd4 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/autodiscovery.js @@ -0,0 +1,429 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.AutoDiscoveryAction = exports.AutoDiscovery = void 0; +var _logger = require("./logger"); +var _httpApi = require("./http-api"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2018 New Vector Ltd + Copyright 2019 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +// Dev note: Auto discovery is part of the spec. +// See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery +let AutoDiscoveryAction = /*#__PURE__*/function (AutoDiscoveryAction) { + AutoDiscoveryAction["SUCCESS"] = "SUCCESS"; + AutoDiscoveryAction["IGNORE"] = "IGNORE"; + AutoDiscoveryAction["PROMPT"] = "PROMPT"; + AutoDiscoveryAction["FAIL_PROMPT"] = "FAIL_PROMPT"; + AutoDiscoveryAction["FAIL_ERROR"] = "FAIL_ERROR"; + return AutoDiscoveryAction; +}({}); +exports.AutoDiscoveryAction = AutoDiscoveryAction; +var AutoDiscoveryError = /*#__PURE__*/function (AutoDiscoveryError) { + AutoDiscoveryError["Invalid"] = "Invalid homeserver discovery response"; + AutoDiscoveryError["GenericFailure"] = "Failed to get autodiscovery configuration from server"; + AutoDiscoveryError["InvalidHsBaseUrl"] = "Invalid base_url for m.homeserver"; + AutoDiscoveryError["InvalidHomeserver"] = "Homeserver URL does not appear to be a valid Matrix homeserver"; + AutoDiscoveryError["InvalidIsBaseUrl"] = "Invalid base_url for m.identity_server"; + AutoDiscoveryError["InvalidIdentityServer"] = "Identity server URL does not appear to be a valid identity server"; + AutoDiscoveryError["InvalidIs"] = "Invalid identity server discovery response"; + AutoDiscoveryError["MissingWellknown"] = "No .well-known JSON file found"; + AutoDiscoveryError["InvalidJson"] = "Invalid JSON"; + return AutoDiscoveryError; +}(AutoDiscoveryError || {}); +/** + * Utilities for automatically discovery resources, such as homeservers + * for users to log in to. + */ +class AutoDiscovery { + /** + * Validates and verifies client configuration information for purposes + * of logging in. Such information includes the homeserver URL + * and identity server URL the client would want. Additional details + * may also be included, and will be transparently brought into the + * response object unaltered. + * @param wellknown - The configuration object itself, as returned + * by the .well-known auto-discovery endpoint. + * @returns Promise which resolves to the verified + * configuration, which may include error states. Rejects on unexpected + * failure, not when verification fails. + */ + static async fromDiscoveryConfig(wellknown) { + // Step 1 is to get the config, which is provided to us here. + + // We default to an error state to make the first few checks easier to + // write. We'll update the properties of this object over the duration + // of this function. + const clientConfig = { + "m.homeserver": { + state: AutoDiscovery.FAIL_ERROR, + error: AutoDiscovery.ERROR_INVALID, + base_url: null + }, + "m.identity_server": { + // Technically, we don't have a problem with the identity server + // config at this point. + state: AutoDiscovery.PROMPT, + error: null, + base_url: null + } + }; + if (!wellknown?.["m.homeserver"]) { + _logger.logger.error("No m.homeserver key in config"); + clientConfig["m.homeserver"].state = AutoDiscovery.FAIL_PROMPT; + clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID; + return Promise.resolve(clientConfig); + } + if (!wellknown["m.homeserver"]["base_url"]) { + _logger.logger.error("No m.homeserver base_url in config"); + clientConfig["m.homeserver"].state = AutoDiscovery.FAIL_PROMPT; + clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HS_BASE_URL; + return Promise.resolve(clientConfig); + } + + // Step 2: Make sure the homeserver URL is valid *looking*. We'll make + // sure it points to a homeserver in Step 3. + const hsUrl = this.sanitizeWellKnownUrl(wellknown["m.homeserver"]["base_url"]); + if (!hsUrl) { + _logger.logger.error("Invalid base_url for m.homeserver"); + clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HS_BASE_URL; + return Promise.resolve(clientConfig); + } + + // Step 3: Make sure the homeserver URL points to a homeserver. + const hsVersions = await this.fetchWellKnownObject(`${hsUrl}/_matrix/client/versions`); + if (!hsVersions?.raw?.["versions"]) { + _logger.logger.error("Invalid /versions response"); + clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HOMESERVER; + + // Supply the base_url to the caller because they may be ignoring liveliness + // errors, like this one. + clientConfig["m.homeserver"].base_url = hsUrl; + return Promise.resolve(clientConfig); + } + + // Step 4: Now that the homeserver looks valid, update our client config. + clientConfig["m.homeserver"] = { + state: AutoDiscovery.SUCCESS, + error: null, + base_url: hsUrl + }; + + // Step 5: Try to pull out the identity server configuration + let isUrl = ""; + if (wellknown["m.identity_server"]) { + // We prepare a failing identity server response to save lines later + // in this branch. + const failingClientConfig = { + "m.homeserver": clientConfig["m.homeserver"], + "m.identity_server": { + state: AutoDiscovery.FAIL_PROMPT, + error: AutoDiscovery.ERROR_INVALID_IS, + base_url: null + } + }; + + // Step 5a: Make sure the URL is valid *looking*. We'll make sure it + // points to an identity server in Step 5b. + isUrl = this.sanitizeWellKnownUrl(wellknown["m.identity_server"]["base_url"]); + if (!isUrl) { + _logger.logger.error("Invalid base_url for m.identity_server"); + failingClientConfig["m.identity_server"].error = AutoDiscovery.ERROR_INVALID_IS_BASE_URL; + return Promise.resolve(failingClientConfig); + } + + // Step 5b: Verify there is an identity server listening on the provided + // URL. + const isResponse = await this.fetchWellKnownObject(`${isUrl}/_matrix/identity/v2`); + if (!isResponse?.raw || isResponse.action !== AutoDiscoveryAction.SUCCESS) { + _logger.logger.error("Invalid /v2 response"); + failingClientConfig["m.identity_server"].error = AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER; + + // Supply the base_url to the caller because they may be ignoring + // liveliness errors, like this one. + failingClientConfig["m.identity_server"].base_url = isUrl; + return Promise.resolve(failingClientConfig); + } + } + + // Step 6: Now that the identity server is valid, or never existed, + // populate the IS section. + if (isUrl && isUrl.toString().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).forEach(k => { + if (k === "m.homeserver" || k === "m.identity_server") { + // Only copy selected parts of the config to avoid overwriting + // properties computed by the validation logic above. + const notProps = ["error", "state", "base_url"]; + for (const prop of Object.keys(wellknown[k])) { + if (notProps.includes(prop)) continue; + // @ts-ignore - ts gets unhappy as we're mixing types here + clientConfig[k][prop] = wellknown[k][prop]; + } + } else { + // Just copy the whole thing over otherwise + clientConfig[k] = wellknown[k]; + } + }); + + // Step 8: Give the config to the caller (finally) + return Promise.resolve(clientConfig); + } + + /** + * Attempts to automatically discover client configuration information + * prior to logging in. Such information includes the homeserver URL + * and identity server URL the client would want. Additional details + * may also be discovered, and will be transparently included in the + * response object unaltered. + * @param domain - The homeserver domain to perform discovery + * on. For example, "matrix.org". + * @returns Promise which resolves to the discovered + * configuration, which may include error states. Rejects on unexpected + * failure, not when discovery fails. + */ + static async findClientConfig(domain) { + if (!domain || typeof domain !== "string" || domain.length === 0) { + throw new Error("'domain' must be a string of non-zero length"); + } + + // We use a .well-known lookup for all cases. According to the spec, we + // can do other discovery mechanisms if we want such as custom lookups + // however we won't bother with that here (mostly because the spec only + // supports .well-known right now). + // + // By using .well-known, we need to ensure we at least pull out a URL + // for the homeserver. We don't really need an identity server configuration + // but will return one anyways (with state PROMPT) to make development + // easier for clients. If we can't get a homeserver URL, all bets are + // off on the rest of the config and we'll assume it is invalid too. + + // We default to an error state to make the first few checks easier to + // write. We'll update the properties of this object over the duration + // of this function. + const clientConfig = { + "m.homeserver": { + state: AutoDiscovery.FAIL_ERROR, + error: AutoDiscovery.ERROR_INVALID, + base_url: null + }, + "m.identity_server": { + // Technically, we don't have a problem with the identity server + // config at this point. + state: AutoDiscovery.PROMPT, + error: null, + base_url: null + } + }; + + // Step 1: Actually request the .well-known JSON file and make sure it + // at least has a homeserver definition. + const wellknown = await this.fetchWellKnownObject(`https://${domain}/.well-known/matrix/client`); + if (!wellknown || wellknown.action !== AutoDiscoveryAction.SUCCESS) { + _logger.logger.error("No response or error when parsing .well-known"); + if (wellknown.reason) _logger.logger.error(wellknown.reason); + if (wellknown.action === AutoDiscoveryAction.IGNORE) { + clientConfig["m.homeserver"] = { + state: AutoDiscovery.PROMPT, + error: null, + base_url: null + }; + } else { + // this can only ever be FAIL_PROMPT at this point. + clientConfig["m.homeserver"].state = AutoDiscovery.FAIL_PROMPT; + clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID; + } + return Promise.resolve(clientConfig); + } + + // Step 2: Validate and parse the config + return AutoDiscovery.fromDiscoveryConfig(wellknown.raw); + } + + /** + * Gets the raw discovery client configuration for the given domain name. + * Should only be used if there's no validation to be done on the resulting + * object, otherwise use findClientConfig(). + * @param domain - The domain to get the client config for. + * @returns Promise which resolves to the domain's client config. Can + * be an empty object. + */ + static async getRawClientConfig(domain) { + if (!domain || typeof domain !== "string" || domain.length === 0) { + throw new Error("'domain' must be a string of non-zero length"); + } + const response = await this.fetchWellKnownObject(`https://${domain}/.well-known/matrix/client`); + if (!response) return {}; + return response.raw ?? {}; + } + + /** + * Sanitizes a given URL to ensure it is either an HTTP or HTTP URL and + * is suitable for the requirements laid out by .well-known auto discovery. + * If valid, the URL will also be stripped of any trailing slashes. + * @param url - The potentially invalid URL to sanitize. + * @returns The sanitized URL or a falsey value if the URL is invalid. + * @internal + */ + static sanitizeWellKnownUrl(url) { + if (!url) return false; + try { + let parsed; + try { + parsed = new URL(url); + } catch (e) { + _logger.logger.error("Could not parse url", e); + } + if (!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.logger.error(e); + return false; + } + } + static fetch(resource, options) { + if (this.fetchFn) { + return this.fetchFn(resource, options); + } + return global.fetch(resource, options); + } + static setFetchFn(fetchFn) { + AutoDiscovery.fetchFn = fetchFn; + } + + /** + * 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 url - The URL to fetch a JSON object from. + * @returns Promise which resolves to the returned state. + * @internal + */ + static async fetchWellKnownObject(url) { + let response; + try { + response = await AutoDiscovery.fetch(url, { + method: _httpApi.Method.Get, + signal: (0, _httpApi.timeoutSignal)(5000) + }); + if (response.status === 404) { + return { + raw: {}, + action: AutoDiscoveryAction.IGNORE, + reason: AutoDiscovery.ERROR_MISSING_WELLKNOWN + }; + } + if (!response.ok) { + return { + raw: {}, + action: AutoDiscoveryAction.FAIL_PROMPT, + reason: "General failure" + }; + } + } catch (err) { + const error = err; + let reason = ""; + if (typeof error === "object") { + reason = error?.message; + } + return { + error, + raw: {}, + action: AutoDiscoveryAction.FAIL_PROMPT, + reason: reason || "General failure" + }; + } + try { + return { + raw: await response.json(), + action: AutoDiscoveryAction.SUCCESS + }; + } catch (err) { + const error = err; + return { + error, + raw: {}, + action: AutoDiscoveryAction.FAIL_PROMPT, + reason: error?.name === "SyntaxError" ? AutoDiscovery.ERROR_INVALID_JSON : AutoDiscovery.ERROR_INVALID + }; + } + } +} +exports.AutoDiscovery = 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. +_defineProperty(AutoDiscovery, "ERROR_INVALID", AutoDiscoveryError.Invalid); +_defineProperty(AutoDiscovery, "ERROR_GENERIC_FAILURE", AutoDiscoveryError.GenericFailure); +_defineProperty(AutoDiscovery, "ERROR_INVALID_HS_BASE_URL", AutoDiscoveryError.InvalidHsBaseUrl); +_defineProperty(AutoDiscovery, "ERROR_INVALID_HOMESERVER", AutoDiscoveryError.InvalidHomeserver); +_defineProperty(AutoDiscovery, "ERROR_INVALID_IS_BASE_URL", AutoDiscoveryError.InvalidIsBaseUrl); +_defineProperty(AutoDiscovery, "ERROR_INVALID_IDENTITY_SERVER", AutoDiscoveryError.InvalidIdentityServer); +_defineProperty(AutoDiscovery, "ERROR_INVALID_IS", AutoDiscoveryError.InvalidIs); +_defineProperty(AutoDiscovery, "ERROR_MISSING_WELLKNOWN", AutoDiscoveryError.MissingWellknown); +_defineProperty(AutoDiscovery, "ERROR_INVALID_JSON", AutoDiscoveryError.InvalidJson); +_defineProperty(AutoDiscovery, "ALL_ERRORS", Object.keys(AutoDiscoveryError)); +/** + * The auto discovery failed. The client is expected to communicate + * the error to the user and refuse logging in. + */ +_defineProperty(AutoDiscovery, "FAIL_ERROR", AutoDiscoveryAction.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. + */ +_defineProperty(AutoDiscovery, "FAIL_PROMPT", AutoDiscoveryAction.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. + */ +_defineProperty(AutoDiscovery, "PROMPT", AutoDiscoveryAction.PROMPT); +/** + * The auto discovery was successful. + */ +_defineProperty(AutoDiscovery, "SUCCESS", AutoDiscoveryAction.SUCCESS); +_defineProperty(AutoDiscovery, "fetchFn", void 0);
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/browser-index.js b/comm/chat/protocols/matrix/lib/matrix-sdk/browser-index.js new file mode 100644 index 0000000000..4d6259825c --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/browser-index.js @@ -0,0 +1,58 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +var _exportNames = {}; +exports.default = void 0; +var matrixcs = _interopRequireWildcard(require("./matrix")); +Object.keys(matrixcs).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === matrixcs[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return matrixcs[key]; + } + }); +}); +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +if (global.__js_sdk_entrypoint) { + throw new Error("Multiple matrix-js-sdk entrypoints detected!"); +} +global.__js_sdk_entrypoint = true; + +// just *accessing* indexedDB throws an exception in firefox with indexeddb disabled. +let indexedDB; +try { + indexedDB = global.indexedDB; +} catch (e) {} + +// if our browser (appears to) support indexeddb, use an indexeddb crypto store. +if (indexedDB) { + matrixcs.setCryptoStoreFactory(() => new matrixcs.IndexedDBCryptoStore(indexedDB, "matrix-js-sdk:crypto")); +} + +// We export 3 things to make browserify happy as well as downstream projects. +// It's awkward, but required. +var _default = matrixcs; // keep export for browserify package deps +exports.default = _default; +global.matrixcs = matrixcs;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/client.js b/comm/chat/protocols/matrix/lib/matrix-sdk/client.js new file mode 100644 index 0000000000..76d6f1dac9 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/client.js @@ -0,0 +1,7660 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.UNSTABLE_MSC3882_CAPABILITY = exports.UNSTABLE_MSC3852_LAST_SEEN_UA = exports.RoomVersionStability = exports.PendingEventOrdering = exports.MatrixClient = exports.M_AUTHENTICATION = exports.ClientEvent = exports.CRYPTO_ENABLED = void 0; +exports.fixNotificationCountOnDecryption = fixNotificationCountOnDecryption; +var _sync = require("./sync"); +var _event = require("./models/event"); +var _stub = require("./store/stub"); +var _call = require("./webrtc/call"); +var _filter = require("./filter"); +var _callEventHandler = require("./webrtc/callEventHandler"); +var _groupCallEventHandler = require("./webrtc/groupCallEventHandler"); +var utils = _interopRequireWildcard(require("./utils")); +var _eventTimeline = require("./models/event-timeline"); +var _pushprocessor = require("./pushprocessor"); +var _autodiscovery = require("./autodiscovery"); +var olmlib = _interopRequireWildcard(require("./crypto/olmlib")); +var _ReEmitter = require("./ReEmitter"); +var _RoomList = require("./crypto/RoomList"); +var _logger = require("./logger"); +var _serviceTypes = require("./service-types"); +var _httpApi = require("./http-api"); +var _crypto = require("./crypto"); +var _recoverykey = require("./crypto/recoverykey"); +var _key_passphrase = require("./crypto/key_passphrase"); +var _user = require("./models/user"); +var _contentRepo = require("./content-repo"); +var _searchResult = require("./models/search-result"); +var _dehydration = require("./crypto/dehydration"); +var _api = require("./crypto/api"); +var ContentHelpers = _interopRequireWildcard(require("./content-helpers")); +var _room = require("./models/room"); +var _roomMember = require("./models/room-member"); +var _event2 = require("./@types/event"); +var _partials = require("./@types/partials"); +var _eventMapper = require("./event-mapper"); +var _randomstring = require("./randomstring"); +var _backup = require("./crypto/backup"); +var _MSC3089TreeSpace = require("./models/MSC3089TreeSpace"); +var _search = require("./@types/search"); +var _PushRules = require("./@types/PushRules"); +var _groupCall = require("./webrtc/groupCall"); +var _mediaHandler = require("./webrtc/mediaHandler"); +var _typedEventEmitter = require("./models/typed-event-emitter"); +var _read_receipts = require("./@types/read_receipts"); +var _slidingSyncSdk = require("./sliding-sync-sdk"); +var _thread = require("./models/thread"); +var _beacon = require("./@types/beacon"); +var _NamespacedValue = require("./NamespacedValue"); +var _ToDeviceMessageQueue = require("./ToDeviceMessageQueue"); +var _invitesIgnorer = require("./models/invites-ignorer"); +var _feature = require("./feature"); +var _constants = require("./rust-crypto/constants"); +var _secretStorage = require("./secret-storage"); +const _excluded = ["server", "limit", "since"]; +function _objectWithoutProperties(source, excluded) { if (source == null) return {}; var target = _objectWithoutPropertiesLoose(source, excluded); var key, i; if (Object.getOwnPropertySymbols) { var sourceSymbolKeys = Object.getOwnPropertySymbols(source); for (i = 0; i < sourceSymbolKeys.length; i++) { key = sourceSymbolKeys[i]; if (excluded.indexOf(key) >= 0) continue; if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue; target[key] = source[key]; } } return target; } +function _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; } +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2015-2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ /** + * This is an internal module. See {@link MatrixClient} for the public class. + */ +const SCROLLBACK_DELAY_MS = 3000; +const CRYPTO_ENABLED = (0, _crypto.isCryptoAvailable)(); +exports.CRYPTO_ENABLED = CRYPTO_ENABLED; +const CAPABILITIES_CACHE_MS = 21600000; // 6 hours - an arbitrary value +const TURN_CHECK_INTERVAL = 10 * 60 * 1000; // poll for turn credentials every 10 minutes + +const UNSTABLE_MSC3852_LAST_SEEN_UA = new _NamespacedValue.UnstableValue("last_seen_user_agent", "org.matrix.msc3852.last_seen_user_agent"); +exports.UNSTABLE_MSC3852_LAST_SEEN_UA = UNSTABLE_MSC3852_LAST_SEEN_UA; +let PendingEventOrdering = /*#__PURE__*/function (PendingEventOrdering) { + PendingEventOrdering["Chronological"] = "chronological"; + PendingEventOrdering["Detached"] = "detached"; + return PendingEventOrdering; +}({}); +exports.PendingEventOrdering = PendingEventOrdering; +let RoomVersionStability = /*#__PURE__*/function (RoomVersionStability) { + RoomVersionStability["Stable"] = "stable"; + RoomVersionStability["Unstable"] = "unstable"; + return RoomVersionStability; +}({}); +exports.RoomVersionStability = RoomVersionStability; +const UNSTABLE_MSC3882_CAPABILITY = new _NamespacedValue.UnstableValue("m.get_login_token", "org.matrix.msc3882.get_login_token"); + +/** + * A representation of the capabilities advertised by a homeserver as defined by + * [Capabilities negotiation](https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv3capabilities). + */ + +/* eslint-disable camelcase */ +exports.UNSTABLE_MSC3882_CAPABILITY = UNSTABLE_MSC3882_CAPABILITY; +var CrossSigningKeyType = /*#__PURE__*/function (CrossSigningKeyType) { + CrossSigningKeyType["MasterKey"] = "master_key"; + CrossSigningKeyType["SelfSigningKey"] = "self_signing_key"; + CrossSigningKeyType["UserSigningKey"] = "user_signing_key"; + return CrossSigningKeyType; +}(CrossSigningKeyType || {}); +const M_AUTHENTICATION = new _NamespacedValue.UnstableValue("m.authentication", "org.matrix.msc2965.authentication"); +exports.M_AUTHENTICATION = M_AUTHENTICATION; +/* eslint-enable camelcase */ + +// We're using this constant for methods overloading and inspect whether a variable +// contains an eventId or not. This was required to ensure backwards compatibility +// of methods for threads +// Probably not the most graceful solution but does a good enough job for now +const EVENT_ID_PREFIX = "$"; +let ClientEvent = /*#__PURE__*/function (ClientEvent) { + ClientEvent["Sync"] = "sync"; + ClientEvent["Event"] = "event"; + ClientEvent["ToDeviceEvent"] = "toDeviceEvent"; + ClientEvent["AccountData"] = "accountData"; + ClientEvent["Room"] = "Room"; + ClientEvent["DeleteRoom"] = "deleteRoom"; + ClientEvent["SyncUnexpectedError"] = "sync.unexpectedError"; + ClientEvent["ClientWellKnown"] = "WellKnown.client"; + ClientEvent["ReceivedVoipEvent"] = "received_voip_event"; + ClientEvent["UndecryptableToDeviceEvent"] = "toDeviceEvent.undecryptable"; + ClientEvent["TurnServers"] = "turnServers"; + ClientEvent["TurnServersError"] = "turnServers.error"; + return ClientEvent; +}({}); +exports.ClientEvent = ClientEvent; +const SSO_ACTION_PARAM = new _NamespacedValue.UnstableValue("action", "org.matrix.msc3824.action"); + +/** + * Represents a Matrix Client. Only directly construct this if you want to use + * custom modules. Normally, {@link createClient} should be used + * as it specifies 'sensible' defaults for these modules. + */ +class MatrixClient extends _typedEventEmitter.TypedEventEmitter { + constructor(opts) { + super(); + _defineProperty(this, "reEmitter", new _ReEmitter.TypedReEmitter(this)); + _defineProperty(this, "olmVersion", null); + // populated after initCrypto + _defineProperty(this, "usingExternalCrypto", false); + _defineProperty(this, "store", void 0); + _defineProperty(this, "deviceId", void 0); + _defineProperty(this, "credentials", void 0); + _defineProperty(this, "pickleKey", void 0); + _defineProperty(this, "scheduler", void 0); + _defineProperty(this, "clientRunning", false); + _defineProperty(this, "timelineSupport", false); + _defineProperty(this, "urlPreviewCache", {}); + _defineProperty(this, "identityServer", void 0); + _defineProperty(this, "http", void 0); + // XXX: Intended private, used in code. + /** + * The libolm crypto implementation, if it is in use. + * + * @deprecated This should not be used. Instead, use the methods exposed directly on this class or + * (where they are available) via {@link getCrypto}. + */ + _defineProperty(this, "crypto", void 0); + // XXX: Intended private, used in code. Being replaced by cryptoBackend + _defineProperty(this, "cryptoBackend", void 0); + // one of crypto or rustCrypto + _defineProperty(this, "cryptoCallbacks", void 0); + // XXX: Intended private, used in code. + _defineProperty(this, "callEventHandler", void 0); + // XXX: Intended private, used in code. + _defineProperty(this, "groupCallEventHandler", void 0); + _defineProperty(this, "supportsCallTransfer", false); + // XXX: Intended private, used in code. + _defineProperty(this, "forceTURN", false); + // XXX: Intended private, used in code. + _defineProperty(this, "iceCandidatePoolSize", 0); + // XXX: Intended private, used in code. + _defineProperty(this, "idBaseUrl", void 0); + _defineProperty(this, "baseUrl", void 0); + _defineProperty(this, "isVoipWithNoMediaAllowed", void 0); + // Note: these are all `protected` to let downstream consumers make mistakes if they want to. + // We don't technically support this usage, but have reasons to do this. + _defineProperty(this, "canSupportVoip", false); + _defineProperty(this, "peekSync", null); + _defineProperty(this, "isGuestAccount", false); + _defineProperty(this, "ongoingScrollbacks", {}); + _defineProperty(this, "notifTimelineSet", null); + _defineProperty(this, "cryptoStore", void 0); + _defineProperty(this, "verificationMethods", void 0); + _defineProperty(this, "fallbackICEServerAllowed", false); + _defineProperty(this, "roomList", void 0); + _defineProperty(this, "syncApi", void 0); + _defineProperty(this, "roomNameGenerator", void 0); + _defineProperty(this, "pushRules", void 0); + _defineProperty(this, "syncLeftRoomsPromise", void 0); + _defineProperty(this, "syncedLeftRooms", false); + _defineProperty(this, "clientOpts", void 0); + _defineProperty(this, "clientWellKnownIntervalID", void 0); + _defineProperty(this, "canResetTimelineCallback", void 0); + _defineProperty(this, "canSupport", new Map()); + // The pushprocessor caches useful things, so keep one and re-use it + _defineProperty(this, "pushProcessor", new _pushprocessor.PushProcessor(this)); + // Promise to a response of the server's /versions response + // TODO: This should expire: https://github.com/matrix-org/matrix-js-sdk/issues/1020 + _defineProperty(this, "serverVersionsPromise", void 0); + _defineProperty(this, "cachedCapabilities", void 0); + _defineProperty(this, "clientWellKnown", void 0); + _defineProperty(this, "clientWellKnownPromise", void 0); + _defineProperty(this, "turnServers", []); + _defineProperty(this, "turnServersExpiry", 0); + _defineProperty(this, "checkTurnServersIntervalID", void 0); + _defineProperty(this, "exportedOlmDeviceToImport", void 0); + _defineProperty(this, "txnCtr", 0); + _defineProperty(this, "mediaHandler", new _mediaHandler.MediaHandler(this)); + _defineProperty(this, "sessionId", void 0); + _defineProperty(this, "pendingEventEncryption", new Map()); + _defineProperty(this, "useE2eForGroupCall", true); + _defineProperty(this, "toDeviceMessageQueue", void 0); + _defineProperty(this, "_secretStorage", void 0); + // A manager for determining which invites should be ignored. + _defineProperty(this, "ignoredInvites", void 0); + _defineProperty(this, "startCallEventHandler", () => { + if (this.isInitialSyncComplete()) { + this.callEventHandler.start(); + this.groupCallEventHandler.start(); + this.off(ClientEvent.Sync, this.startCallEventHandler); + } + }); + /** + * Once the client has been initialised, we want to clear notifications we + * know for a fact should be here. + * This issue should also be addressed on synapse's side and is tracked as part + * of https://github.com/matrix-org/synapse/issues/14837 + * + * We consider a room or a thread as fully read if the current user has sent + * the last event in the live timeline of that context and if the read receipt + * we have on record matches. + */ + _defineProperty(this, "fixupRoomNotifications", () => { + if (this.isInitialSyncComplete()) { + const unreadRooms = (this.getRooms() ?? []).filter(room => { + return room.getUnreadNotificationCount(_room.NotificationCountType.Total) > 0; + }); + for (const room of unreadRooms) { + const currentUserId = this.getSafeUserId(); + room.fixupNotifications(currentUserId); + } + this.off(ClientEvent.Sync, this.fixupRoomNotifications); + } + }); + opts.baseUrl = utils.ensureNoTrailingSlash(opts.baseUrl); + opts.idBaseUrl = utils.ensureNoTrailingSlash(opts.idBaseUrl); + this.baseUrl = opts.baseUrl; + this.idBaseUrl = opts.idBaseUrl; + this.identityServer = opts.identityServer; + this.usingExternalCrypto = opts.usingExternalCrypto ?? false; + this.store = opts.store || new _stub.StubStore(); + this.deviceId = opts.deviceId || null; + this.sessionId = (0, _randomstring.randomString)(10); + const userId = opts.userId || null; + this.credentials = { + userId + }; + this.http = new _httpApi.MatrixHttpApi(this, { + fetchFn: opts.fetchFn, + baseUrl: opts.baseUrl, + idBaseUrl: opts.idBaseUrl, + accessToken: opts.accessToken, + prefix: _httpApi.ClientPrefix.R0, + onlyData: true, + extraParams: opts.queryParams, + localTimeoutMs: opts.localTimeoutMs, + useAuthorizationHeader: opts.useAuthorizationHeader + }); + if (opts.deviceToImport) { + if (this.deviceId) { + _logger.logger.warn("not importing device because device ID is provided to " + "constructor independently of exported data"); + } else if (this.credentials.userId) { + _logger.logger.warn("not importing device because user ID is provided to " + "constructor independently of exported data"); + } else if (!opts.deviceToImport.deviceId) { + _logger.logger.warn("not importing device because no device ID in exported data"); + } else { + this.deviceId = opts.deviceToImport.deviceId; + this.credentials.userId = opts.deviceToImport.userId; + // will be used during async initialization of the crypto + this.exportedOlmDeviceToImport = opts.deviceToImport.olmDevice; + } + } else if (opts.pickleKey) { + this.pickleKey = opts.pickleKey; + } + this.scheduler = opts.scheduler; + if (this.scheduler) { + this.scheduler.setProcessFunction(async eventToSend => { + const room = this.getRoom(eventToSend.getRoomId()); + if (eventToSend.status !== _event.EventStatus.SENDING) { + this.updatePendingEventStatus(room, eventToSend, _event.EventStatus.SENDING); + } + const res = await this.sendEventHttpRequest(eventToSend); + if (room) { + // ensure we update pending event before the next scheduler run so that any listeners to event id + // updates on the synchronous event emitter get a chance to run first. + room.updatePendingEvent(eventToSend, _event.EventStatus.SENT, res.event_id); + } + return res; + }); + } + if ((0, _call.supportsMatrixCall)()) { + this.callEventHandler = new _callEventHandler.CallEventHandler(this); + this.groupCallEventHandler = new _groupCallEventHandler.GroupCallEventHandler(this); + this.canSupportVoip = true; + // Start listening for calls after the initial sync is done + // We do not need to backfill the call event buffer + // with encrypted events that might never get decrypted + this.on(ClientEvent.Sync, this.startCallEventHandler); + } + this.on(ClientEvent.Sync, this.fixupRoomNotifications); + this.timelineSupport = Boolean(opts.timelineSupport); + this.cryptoStore = opts.cryptoStore; + this.verificationMethods = opts.verificationMethods; + this.cryptoCallbacks = opts.cryptoCallbacks || {}; + this.forceTURN = opts.forceTURN || false; + this.iceCandidatePoolSize = opts.iceCandidatePoolSize === undefined ? 0 : opts.iceCandidatePoolSize; + this.supportsCallTransfer = opts.supportsCallTransfer || false; + this.fallbackICEServerAllowed = opts.fallbackICEServerAllowed || false; + this.isVoipWithNoMediaAllowed = opts.isVoipWithNoMediaAllowed || false; + if (opts.useE2eForGroupCall !== undefined) this.useE2eForGroupCall = opts.useE2eForGroupCall; + + // List of which rooms have encryption enabled: separate from crypto because + // we still want to know which rooms are encrypted even if crypto is disabled: + // we don't want to start sending unencrypted events to them. + this.roomList = new _RoomList.RoomList(this.cryptoStore); + this.roomNameGenerator = opts.roomNameGenerator; + this.toDeviceMessageQueue = new _ToDeviceMessageQueue.ToDeviceMessageQueue(this); + + // The SDK doesn't really provide a clean way for events to recalculate the push + // actions for themselves, so we have to kinda help them out when they are encrypted. + // We do this so that push rules are correctly executed on events in their decrypted + // state, such as highlights when the user's name is mentioned. + this.on(_event.MatrixEventEvent.Decrypted, event => { + fixNotificationCountOnDecryption(this, event); + }); + + // Like above, we have to listen for read receipts from ourselves in order to + // correctly handle notification counts on encrypted rooms. + // This fixes https://github.com/vector-im/element-web/issues/9421 + this.on(_room.RoomEvent.Receipt, (event, room) => { + if (room && this.isRoomEncrypted(room.roomId)) { + // Figure out if we've read something or if it's just informational + const content = event.getContent(); + const isSelf = Object.keys(content).filter(eid => { + for (const [key, value] of Object.entries(content[eid])) { + if (!utils.isSupportedReceiptType(key)) continue; + if (!value) continue; + if (Object.keys(value).includes(this.getUserId())) return true; + } + return false; + }).length > 0; + if (!isSelf) return; + + // Work backwards to determine how many events are unread. We also set + // a limit for how back we'll look to avoid spinning CPU for too long. + // If we hit the limit, we assume the count is unchanged. + const maxHistory = 20; + const events = room.getLiveTimeline().getEvents(); + let highlightCount = 0; + for (let i = events.length - 1; i >= 0; i--) { + if (i === events.length - maxHistory) return; // limit reached + + const event = events[i]; + if (room.hasUserReadEvent(this.getUserId(), event.getId())) { + // If the user has read the event, then the counting is done. + break; + } + const pushActions = this.getPushActionsForEvent(event); + highlightCount += pushActions?.tweaks?.highlight ? 1 : 0; + } + + // Note: we don't need to handle 'total' notifications because the counts + // will come from the server. + room.setUnreadNotificationCount(_room.NotificationCountType.Highlight, highlightCount); + } + }); + this.ignoredInvites = new _invitesIgnorer.IgnoredInvites(this); + this._secretStorage = new _secretStorage.ServerSideSecretStorageImpl(this, opts.cryptoCallbacks ?? {}); + } + + /** + * High level helper method to begin syncing and poll for new events. To listen for these + * events, add a listener for {@link ClientEvent.Event} + * via {@link MatrixClient#on}. Alternatively, listen for specific + * state change events. + * @param opts - Options to apply when syncing. + */ + async startClient(opts) { + if (this.clientRunning) { + // client is already running. + return; + } + this.clientRunning = true; + // backwards compat for when 'opts' was 'historyLen'. + if (typeof opts === "number") { + opts = { + initialSyncLimit: opts + }; + } + + // Create our own user object artificially (instead of waiting for sync) + // so it's always available, even if the user is not in any rooms etc. + const userId = this.getUserId(); + if (userId) { + this.store.storeUser(new _user.User(userId)); + } + + // periodically poll for turn servers if we support voip + if (this.canSupportVoip) { + this.checkTurnServersIntervalID = setInterval(() => { + this.checkTurnServers(); + }, TURN_CHECK_INTERVAL); + // noinspection ES6MissingAwait + this.checkTurnServers(); + } + if (this.syncApi) { + // This shouldn't happen since we thought the client was not running + _logger.logger.error("Still have sync object whilst not running: stopping old one"); + this.syncApi.stop(); + } + try { + await this.getVersions(); + + // This should be done with `canSupport` + // TODO: https://github.com/vector-im/element-web/issues/23643 + const { + threads, + list, + fwdPagination + } = await this.doesServerSupportThread(); + _thread.Thread.setServerSideSupport(threads); + _thread.Thread.setServerSideListSupport(list); + _thread.Thread.setServerSideFwdPaginationSupport(fwdPagination); + } catch (e) { + _logger.logger.error("Can't fetch server versions, continuing to initialise sync, this will be retried later", e); + } + this.clientOpts = opts ?? {}; + if (this.clientOpts.slidingSync) { + this.syncApi = new _slidingSyncSdk.SlidingSyncSdk(this.clientOpts.slidingSync, this, this.clientOpts, this.buildSyncApiOptions()); + } else { + this.syncApi = new _sync.SyncApi(this, this.clientOpts, this.buildSyncApiOptions()); + } + if (this.clientOpts.hasOwnProperty("experimentalThreadSupport")) { + _logger.logger.warn("`experimentalThreadSupport` has been deprecated, use `threadSupport` instead"); + } + + // If `threadSupport` is omitted and the deprecated `experimentalThreadSupport` has been passed + // We should fallback to that value for backwards compatibility purposes + if (!this.clientOpts.hasOwnProperty("threadSupport") && this.clientOpts.hasOwnProperty("experimentalThreadSupport")) { + this.clientOpts.threadSupport = this.clientOpts.experimentalThreadSupport; + } + this.syncApi.sync(); + if (this.clientOpts.clientWellKnownPollPeriod !== undefined) { + this.clientWellKnownIntervalID = setInterval(() => { + this.fetchClientWellKnown(); + }, 1000 * this.clientOpts.clientWellKnownPollPeriod); + this.fetchClientWellKnown(); + } + this.toDeviceMessageQueue.start(); + } + + /** + * Construct a SyncApiOptions for this client, suitable for passing into the SyncApi constructor + */ + buildSyncApiOptions() { + return { + crypto: this.crypto, + cryptoCallbacks: this.cryptoBackend, + canResetEntireTimeline: roomId => { + if (!this.canResetTimelineCallback) { + return false; + } + return this.canResetTimelineCallback(roomId); + } + }; + } + + /** + * High level helper method to stop the client from polling and allow a + * clean shutdown. + */ + stopClient() { + this.cryptoBackend?.stop(); // crypto might have been initialised even if the client wasn't fully started + + if (!this.clientRunning) return; // already stopped + + _logger.logger.log("stopping MatrixClient"); + this.clientRunning = false; + this.syncApi?.stop(); + this.syncApi = undefined; + this.peekSync?.stopPeeking(); + this.callEventHandler?.stop(); + this.groupCallEventHandler?.stop(); + this.callEventHandler = undefined; + this.groupCallEventHandler = undefined; + global.clearInterval(this.checkTurnServersIntervalID); + this.checkTurnServersIntervalID = undefined; + if (this.clientWellKnownIntervalID !== undefined) { + global.clearInterval(this.clientWellKnownIntervalID); + } + this.toDeviceMessageQueue.stop(); + } + + /** + * Try to rehydrate a device if available. The client must have been + * initialized with a `cryptoCallback.getDehydrationKey` option, and this + * function must be called before initCrypto and startClient are called. + * + * @returns Promise which resolves to undefined if a device could not be dehydrated, or + * to the new device ID if the dehydration was successful. + * @returns Rejects: with an error response. + */ + async rehydrateDevice() { + if (this.crypto) { + throw new Error("Cannot rehydrate device after crypto is initialized"); + } + if (!this.cryptoCallbacks.getDehydrationKey) { + return; + } + const getDeviceResult = await this.getDehydratedDevice(); + if (!getDeviceResult) { + return; + } + if (!getDeviceResult.device_data || !getDeviceResult.device_id) { + _logger.logger.info("no dehydrated device found"); + return; + } + const account = new global.Olm.Account(); + try { + const deviceData = getDeviceResult.device_data; + if (deviceData.algorithm !== _dehydration.DEHYDRATION_ALGORITHM) { + _logger.logger.warn("Wrong algorithm for dehydrated device"); + return; + } + _logger.logger.log("unpickling dehydrated device"); + const key = await this.cryptoCallbacks.getDehydrationKey(deviceData, k => { + // copy the key so that it doesn't get clobbered + account.unpickle(new Uint8Array(k), deviceData.account); + }); + account.unpickle(key, deviceData.account); + _logger.logger.log("unpickled device"); + const rehydrateResult = await this.http.authedRequest(_httpApi.Method.Post, "/dehydrated_device/claim", undefined, { + device_id: getDeviceResult.device_id + }, { + prefix: "/_matrix/client/unstable/org.matrix.msc2697.v2" + }); + if (rehydrateResult.success) { + this.deviceId = getDeviceResult.device_id; + _logger.logger.info("using dehydrated device"); + const pickleKey = this.pickleKey || "DEFAULT_KEY"; + this.exportedOlmDeviceToImport = { + pickledAccount: account.pickle(pickleKey), + sessions: [], + pickleKey: pickleKey + }; + account.free(); + return this.deviceId; + } else { + account.free(); + _logger.logger.info("not using dehydrated device"); + return; + } + } catch (e) { + account.free(); + _logger.logger.warn("could not unpickle", e); + } + } + + /** + * Get the current dehydrated device, if any + * @returns A promise of an object containing the dehydrated device + */ + async getDehydratedDevice() { + try { + return await this.http.authedRequest(_httpApi.Method.Get, "/dehydrated_device", undefined, undefined, { + prefix: "/_matrix/client/unstable/org.matrix.msc2697.v2" + }); + } catch (e) { + _logger.logger.info("could not get dehydrated device", e); + return; + } + } + + /** + * Set the dehydration key. This will also periodically dehydrate devices to + * the server. + * + * @param key - the dehydration key + * @param keyInfo - Information about the key. Primarily for + * information about how to generate the key from a passphrase. + * @param deviceDisplayName - The device display name for the + * dehydrated device. + * @returns A promise that resolves when the dehydrated device is stored. + */ + async setDehydrationKey(key, keyInfo, deviceDisplayName) { + if (!this.crypto) { + _logger.logger.warn("not dehydrating device if crypto is not enabled"); + return; + } + return this.crypto.dehydrationManager.setKeyAndQueueDehydration(key, keyInfo, deviceDisplayName); + } + + /** + * Creates a new dehydrated device (without queuing periodic dehydration) + * @param key - the dehydration key + * @param keyInfo - Information about the key. Primarily for + * information about how to generate the key from a passphrase. + * @param deviceDisplayName - The device display name for the + * dehydrated device. + * @returns the device id of the newly created dehydrated device + */ + async createDehydratedDevice(key, keyInfo, deviceDisplayName) { + if (!this.crypto) { + _logger.logger.warn("not dehydrating device if crypto is not enabled"); + return; + } + await this.crypto.dehydrationManager.setKey(key, keyInfo, deviceDisplayName); + return this.crypto.dehydrationManager.dehydrateDevice(); + } + async exportDevice() { + if (!this.crypto) { + _logger.logger.warn("not exporting device if crypto is not enabled"); + return; + } + return { + userId: this.credentials.userId, + deviceId: this.deviceId, + // XXX: Private member access. + olmDevice: await this.crypto.olmDevice.export() + }; + } + + /** + * Clear any data out of the persistent stores used by the client. + * + * @returns Promise which resolves when the stores have been cleared. + */ + clearStores() { + if (this.clientRunning) { + throw new Error("Cannot clear stores while client is running"); + } + const promises = []; + promises.push(this.store.deleteAllData()); + if (this.cryptoStore) { + promises.push(this.cryptoStore.deleteAllData()); + } + + // delete the stores used by the rust matrix-sdk-crypto, in case they were used + const deleteRustSdkStore = async () => { + let indexedDB; + try { + indexedDB = global.indexedDB; + } catch (e) { + // No indexeddb support + return; + } + for (const dbname of [`${_constants.RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto`, `${_constants.RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto-meta`]) { + const prom = new Promise((resolve, reject) => { + _logger.logger.info(`Removing IndexedDB instance ${dbname}`); + const req = indexedDB.deleteDatabase(dbname); + req.onsuccess = _ => { + _logger.logger.info(`Removed IndexedDB instance ${dbname}`); + resolve(0); + }; + req.onerror = e => { + // In private browsing, Firefox has a global.indexedDB, but attempts to delete an indexeddb + // (even a non-existent one) fail with "DOMException: A mutation operation was attempted on a + // database that did not allow mutations." + // + // it seems like the only thing we can really do is ignore the error. + _logger.logger.warn(`Failed to remove IndexedDB instance ${dbname}:`, e); + resolve(0); + }; + req.onblocked = e => { + _logger.logger.info(`cannot yet remove IndexedDB instance ${dbname}`); + }; + }); + await prom; + } + }; + promises.push(deleteRustSdkStore()); + return Promise.all(promises).then(); // .then to fix types + } + + /** + * Get the user-id of the logged-in user + * + * @returns MXID for the logged-in user, or null if not logged in + */ + getUserId() { + if (this.credentials && this.credentials.userId) { + return this.credentials.userId; + } + return null; + } + + /** + * Get the user-id of the logged-in user + * + * @returns MXID for the logged-in user + * @throws Error if not logged in + */ + getSafeUserId() { + const userId = this.getUserId(); + if (!userId) { + throw new Error("Expected logged in user but found none."); + } + return userId; + } + + /** + * Get the domain for this client's MXID + * @returns Domain of this MXID + */ + getDomain() { + if (this.credentials && this.credentials.userId) { + return this.credentials.userId.replace(/^.*?:/, ""); + } + return null; + } + + /** + * Get the local part of the current user ID e.g. "foo" in "\@foo:bar". + * @returns The user ID localpart or null. + */ + getUserIdLocalpart() { + if (this.credentials && this.credentials.userId) { + return this.credentials.userId.split(":")[0].substring(1); + } + return null; + } + + /** + * Get the device ID of this client + * @returns device ID + */ + getDeviceId() { + return this.deviceId; + } + + /** + * Get the session ID of this client + * @returns session ID + */ + getSessionId() { + return this.sessionId; + } + + /** + * Check if the runtime environment supports VoIP calling. + * @returns True if VoIP is supported. + */ + supportsVoip() { + return this.canSupportVoip; + } + + /** + * @returns + */ + getMediaHandler() { + return this.mediaHandler; + } + + /** + * Set whether VoIP calls are forced to use only TURN + * candidates. This is the same as the forceTURN option + * when creating the client. + * @param force - True to force use of TURN servers + */ + setForceTURN(force) { + this.forceTURN = force; + } + + /** + * Set whether to advertise transfer support to other parties on Matrix calls. + * @param support - True to advertise the 'm.call.transferee' capability + */ + setSupportsCallTransfer(support) { + this.supportsCallTransfer = support; + } + + /** + * Returns true if to-device signalling for group calls will be encrypted with Olm. + * If false, it will be sent unencrypted. + * @returns boolean Whether group call signalling will be encrypted + */ + getUseE2eForGroupCall() { + return this.useE2eForGroupCall; + } + + /** + * Creates a new call. + * The place*Call methods on the returned call can be used to actually place a call + * + * @param roomId - The room the call is to be placed in. + * @returns the call or null if the browser doesn't support calling. + */ + createCall(roomId) { + return (0, _call.createNewMatrixCall)(this, roomId); + } + + /** + * Creates a new group call and sends the associated state event + * to alert other members that the room now has a group call. + * + * @param roomId - The room the call is to be placed in. + */ + async createGroupCall(roomId, type, isPtt, intent, dataChannelsEnabled, dataChannelOptions) { + if (this.getGroupCallForRoom(roomId)) { + throw new Error(`${roomId} already has an existing group call`); + } + const room = this.getRoom(roomId); + if (!room) { + throw new Error(`Cannot find room ${roomId}`); + } + + // Because without Media section a WebRTC connection is not possible, so need a RTCDataChannel to set up a + // no media WebRTC connection anyway. + return new _groupCall.GroupCall(this, room, type, isPtt, intent, undefined, dataChannelsEnabled || this.isVoipWithNoMediaAllowed, dataChannelOptions, this.isVoipWithNoMediaAllowed).create(); + } + + /** + * Wait until an initial state for the given room has been processed by the + * client and the client is aware of any ongoing group calls. Awaiting on + * the promise returned by this method before calling getGroupCallForRoom() + * avoids races where getGroupCallForRoom is called before the state for that + * room has been processed. It does not, however, fix other races, eg. two + * clients both creating a group call at the same time. + * @param roomId - The room ID to wait for + * @returns A promise that resolves once existing group calls in the room + * have been processed. + */ + waitUntilRoomReadyForGroupCalls(roomId) { + return this.groupCallEventHandler.waitUntilRoomReadyForGroupCalls(roomId); + } + + /** + * Get an existing group call for the provided room. + * @returns The group call or null if it doesn't already exist. + */ + getGroupCallForRoom(roomId) { + return this.groupCallEventHandler.groupCalls.get(roomId) || null; + } + + /** + * Get the current sync state. + * @returns the sync state, which may be null. + * @see MatrixClient#event:"sync" + */ + getSyncState() { + return this.syncApi?.getSyncState() ?? null; + } + + /** + * 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. + */ + getSyncStateData() { + if (!this.syncApi) { + return null; + } + return this.syncApi.getSyncStateData(); + } + + /** + * Whether the initial sync has completed. + * @returns True if at least one sync has happened. + */ + isInitialSyncComplete() { + const state = this.getSyncState(); + if (!state) { + return false; + } + return state === _sync.SyncState.Prepared || state === _sync.SyncState.Syncing; + } + + /** + * Return whether the client is configured for a guest account. + * @returns True if this is a guest access_token (or no token is supplied). + */ + isGuest() { + return this.isGuestAccount; + } + + /** + * Set whether this client is a guest account. <b>This method is experimental + * and may change without warning.</b> + * @param guest - True if this is a guest account. + */ + setGuest(guest) { + // EXPERIMENTAL: + // If the token is a macaroon, it should be encoded in it that it is a 'guest' + // access token, which means that the SDK can determine this entirely without + // the dev manually flipping this flag. + this.isGuestAccount = guest; + } + + /** + * Return the provided scheduler, if any. + * @returns The scheduler or undefined + */ + getScheduler() { + return this.scheduler; + } + + /** + * Retry a backed off syncing request immediately. This should only be used when + * the user <b>explicitly</b> attempts to retry their lost connection. + * Will also retry any outbound to-device messages currently in the queue to be sent + * (retries of regular outgoing events are handled separately, per-event). + * @returns True if this resulted in a request being retried. + */ + retryImmediately() { + // don't await for this promise: we just want to kick it off + this.toDeviceMessageQueue.sendQueue(); + return this.syncApi?.retryImmediately() ?? false; + } + + /** + * Return the global notification EventTimelineSet, if any + * + * @returns the globl notification EventTimelineSet + */ + getNotifTimelineSet() { + return this.notifTimelineSet; + } + + /** + * Set the global notification EventTimelineSet + * + */ + setNotifTimelineSet(set) { + this.notifTimelineSet = set; + } + + /** + * Gets the capabilities of the homeserver. Always returns an object of + * capability keys and their options, which may be empty. + * @param fresh - True to ignore any cached values. + * @returns Promise which resolves to the capabilities of the homeserver + * @returns Rejects: with an error response. + */ + getCapabilities(fresh = false) { + const now = new Date().getTime(); + if (this.cachedCapabilities && !fresh) { + if (now < this.cachedCapabilities.expiration) { + _logger.logger.log("Returning cached capabilities"); + return Promise.resolve(this.cachedCapabilities.capabilities); + } + } + return this.http.authedRequest(_httpApi.Method.Get, "/capabilities").catch(e => { + // We swallow errors because we need a default object anyhow + _logger.logger.error(e); + return {}; + }).then((r = {}) => { + const capabilities = r["capabilities"] || {}; + + // If the capabilities missed the cache, cache it for a shorter amount + // of time to try and refresh them later. + const cacheMs = Object.keys(capabilities).length ? CAPABILITIES_CACHE_MS : 60000 + Math.random() * 5000; + this.cachedCapabilities = { + capabilities, + expiration: now + cacheMs + }; + _logger.logger.log("Caching capabilities: ", capabilities); + return capabilities; + }); + } + + /** + * Initialise support for end-to-end encryption in this client, using libolm. + * + * You should call this method after creating the matrixclient, but *before* + * calling `startClient`, if you want to support end-to-end encryption. + * + * It will return a Promise which will resolve when the crypto layer has been + * successfully initialised. + */ + async initCrypto() { + if (!(0, _crypto.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.cryptoBackend) { + _logger.logger.warn("Attempt to re-initialise e2e encryption on MatrixClient"); + return; + } + if (!this.cryptoStore) { + // the cryptostore is provided by sdk.createClient, so this shouldn't happen + throw new Error(`Cannot enable encryption: no cryptoStore provided`); + } + _logger.logger.log("Crypto: Starting up crypto store..."); + await this.cryptoStore.startup(); + + // initialise the list of encrypted rooms (whether or not crypto is enabled) + _logger.logger.log("Crypto: initialising roomlist..."); + await this.roomList.init(); + const userId = this.getUserId(); + if (userId === null) { + throw new Error(`Cannot enable encryption on MatrixClient with unknown userId: ` + `ensure userId is passed in createClient().`); + } + if (this.deviceId === null) { + throw new Error(`Cannot enable encryption on MatrixClient with unknown deviceId: ` + `ensure deviceId is passed in createClient().`); + } + const crypto = new _crypto.Crypto(this, userId, this.deviceId, this.store, this.cryptoStore, this.roomList, this.verificationMethods); + this.reEmitter.reEmit(crypto, [_crypto.CryptoEvent.KeyBackupFailed, _crypto.CryptoEvent.KeyBackupSessionsRemaining, _crypto.CryptoEvent.RoomKeyRequest, _crypto.CryptoEvent.RoomKeyRequestCancellation, _crypto.CryptoEvent.Warning, _crypto.CryptoEvent.DevicesUpdated, _crypto.CryptoEvent.WillUpdateDevices, _crypto.CryptoEvent.DeviceVerificationChanged, _crypto.CryptoEvent.UserTrustStatusChanged, _crypto.CryptoEvent.KeysChanged]); + _logger.logger.log("Crypto: initialising crypto object..."); + await crypto.init({ + exportedOlmDevice: this.exportedOlmDeviceToImport, + pickleKey: this.pickleKey + }); + delete this.exportedOlmDeviceToImport; + this.olmVersion = _crypto.Crypto.getOlmVersion(); + + // if crypto initialisation was successful, tell it to attach its event handlers. + crypto.registerEventHandlers(this); + this.cryptoBackend = this.crypto = crypto; + + // upload our keys in the background + this.crypto.uploadDeviceKeys().catch(e => { + // TODO: throwing away this error is a really bad idea. + _logger.logger.error("Error uploading device keys", e); + }); + } + + /** + * Initialise support for end-to-end encryption in this client, using the rust matrix-sdk-crypto. + * + * An alternative to {@link initCrypto}. + * + * *WARNING*: this API is very experimental, should not be used in production, and may change without notice! + * Eventually it will be deprecated and `initCrypto` will do the same thing. + * + * @experimental + * + * @returns a Promise which will resolve when the crypto layer has been + * successfully initialised. + */ + async initRustCrypto() { + if (this.cryptoBackend) { + _logger.logger.warn("Attempt to re-initialise e2e encryption on MatrixClient"); + return; + } + const userId = this.getUserId(); + if (userId === null) { + throw new Error(`Cannot enable encryption on MatrixClient with unknown userId: ` + `ensure userId is passed in createClient().`); + } + const deviceId = this.getDeviceId(); + if (deviceId === null) { + throw new Error(`Cannot enable encryption on MatrixClient with unknown deviceId: ` + `ensure deviceId is passed in createClient().`); + } + + // importing rust-crypto will download the webassembly, so we delay it until we know it will be + // needed. + const RustCrypto = await Promise.resolve().then(() => _interopRequireWildcard(require("./rust-crypto"))); + const rustCrypto = await RustCrypto.initRustCrypto(this.http, userId, deviceId, this.secretStorage); + this.cryptoBackend = rustCrypto; + + // attach the event listeners needed by RustCrypto + this.on(_roomMember.RoomMemberEvent.Membership, rustCrypto.onRoomMembership.bind(rustCrypto)); + } + + /** + * Access the server-side secret storage API for this client. + */ + get secretStorage() { + return this._secretStorage; + } + + /** + * Access the crypto API for this client. + * + * If end-to-end encryption has been enabled for this client (via {@link initCrypto} or {@link initRustCrypto}), + * returns an object giving access to the crypto API. Otherwise, returns `undefined`. + */ + getCrypto() { + return this.cryptoBackend; + } + + /** + * Is end-to-end crypto enabled for this client. + * @returns True if end-to-end is enabled. + * @deprecated prefer {@link getCrypto} + */ + isCryptoEnabled() { + return !!this.cryptoBackend; + } + + /** + * Get the Ed25519 key for this device + * + * @returns base64-encoded ed25519 key. Null if crypto is + * disabled. + */ + getDeviceEd25519Key() { + return this.crypto?.getDeviceEd25519Key() ?? null; + } + + /** + * Get the Curve25519 key for this device + * + * @returns base64-encoded curve25519 key. Null if crypto is + * disabled. + */ + getDeviceCurve25519Key() { + return this.crypto?.getDeviceCurve25519Key() ?? null; + } + + /** + * @deprecated Does nothing. + */ + async uploadKeys() { + _logger.logger.warn("MatrixClient.uploadKeys is deprecated"); + } + + /** + * Download the keys for a list of users and stores the keys in the session + * store. + * @param userIds - The users to fetch. + * @param forceDownload - Always download the keys even if cached. + * + * @returns A promise which resolves to a map userId-\>deviceId-\>`DeviceInfo` + * + * @deprecated Prefer {@link CryptoApi.getUserDeviceInfo} + */ + downloadKeys(userIds, forceDownload) { + if (!this.crypto) { + return Promise.reject(new Error("End-to-end encryption disabled")); + } + return this.crypto.downloadKeys(userIds, forceDownload); + } + + /** + * Get the stored device keys for a user id + * + * @param userId - the user to list keys for. + * + * @returns list of devices + * @deprecated Prefer {@link CryptoApi.getUserDeviceInfo} + */ + getStoredDevicesForUser(userId) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this.crypto.getStoredDevicesForUser(userId) || []; + } + + /** + * Get the stored device key for a user id and device id + * + * @param userId - the user to list keys for. + * @param deviceId - unique identifier for the device + * + * @returns device or null + * @deprecated Prefer {@link CryptoApi.getUserDeviceInfo} + */ + getStoredDevice(userId, deviceId) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this.crypto.getStoredDevice(userId, deviceId) || null; + } + + /** + * Mark the given device as verified + * + * @param userId - owner of the device + * @param deviceId - unique identifier for the device or user's + * cross-signing public key ID. + * + * @param verified - whether to mark the device as verified. defaults + * to 'true'. + * + * @returns + * + * @remarks + * Fires {@link CryptoEvent#DeviceVerificationChanged} + */ + setDeviceVerified(userId, deviceId, verified = true) { + const prom = this.setDeviceVerification(userId, deviceId, verified, null, 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.checkKeyBackup(); + } + return prom; + } + + /** + * Mark the given device as blocked/unblocked + * + * @param userId - owner of the device + * @param deviceId - unique identifier for the device or user's + * cross-signing public key ID. + * + * @param blocked - whether to mark the device as blocked. defaults + * to 'true'. + * + * @returns + * + * @remarks + * Fires {@link CryptoEvent.DeviceVerificationChanged} + */ + setDeviceBlocked(userId, deviceId, blocked = true) { + return this.setDeviceVerification(userId, deviceId, null, blocked, null); + } + + /** + * Mark the given device as known/unknown + * + * @param userId - owner of the device + * @param deviceId - unique identifier for the device or user's + * cross-signing public key ID. + * + * @param known - whether to mark the device as known. defaults + * to 'true'. + * + * @returns + * + * @remarks + * Fires {@link CryptoEvent#DeviceVerificationChanged} + */ + setDeviceKnown(userId, deviceId, known = true) { + return this.setDeviceVerification(userId, deviceId, null, null, known); + } + async setDeviceVerification(userId, deviceId, verified, blocked, known) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + await this.crypto.setDeviceVerification(userId, deviceId, verified, blocked, known); + } + + /** + * Request a key verification from another user, using a DM. + * + * @param userId - the user to request verification with + * @param roomId - the room to use for verification + * + * @returns resolves to a VerificationRequest + * when the request has been sent to the other party. + */ + requestVerificationDM(userId, roomId) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this.crypto.requestVerificationDM(userId, roomId); + } + + /** + * Finds a DM verification request that is already in progress for the given room id + * + * @param roomId - the room to use for verification + * + * @returns the VerificationRequest that is in progress, if any + */ + findVerificationRequestDMInProgress(roomId) { + if (!this.cryptoBackend) { + throw new Error("End-to-end encryption disabled"); + } + return this.cryptoBackend.findVerificationRequestDMInProgress(roomId); + } + + /** + * Returns all to-device verification requests that are already in progress for the given user id + * + * @param userId - the ID of the user to query + * + * @returns the VerificationRequests that are in progress + */ + getVerificationRequestsToDeviceInProgress(userId) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this.crypto.getVerificationRequestsToDeviceInProgress(userId); + } + + /** + * Request a key verification from another user. + * + * @param userId - the user to request verification with + * @param devices - array of device IDs to send requests to. Defaults to + * all devices owned by the user + * + * @returns resolves to a VerificationRequest + * when the request has been sent to the other party. + */ + requestVerification(userId, devices) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this.crypto.requestVerification(userId, devices); + } + + /** + * Begin a key verification. + * + * @param method - the verification method to use + * @param userId - the user to verify keys with + * @param deviceId - the device to verify + * + * @returns a verification object + * @deprecated Use `requestVerification` instead. + */ + beginKeyVerification(method, userId, deviceId) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this.crypto.beginKeyVerification(method, userId, deviceId); + } + + /** + * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#checkKey}. + */ + checkSecretStorageKey(key, info) { + return this.secretStorage.checkKey(key, info); + } + + /** + * Set the global override for whether the client should ever send encrypted + * messages to unverified devices. This provides the default for rooms which + * do not specify a value. + * + * @param value - whether to blacklist all unverified devices by default + * + * @deprecated Prefer direct access to {@link CryptoApi.globalBlacklistUnverifiedDevices}: + * + * ```javascript + * client.getCrypto().globalBlacklistUnverifiedDevices = value; + * ``` + */ + setGlobalBlacklistUnverifiedDevices(value) { + if (!this.cryptoBackend) { + throw new Error("End-to-end encryption disabled"); + } + this.cryptoBackend.globalBlacklistUnverifiedDevices = value; + return value; + } + + /** + * @returns whether to blacklist all unverified devices by default + * + * @deprecated Prefer direct access to {@link CryptoApi.globalBlacklistUnverifiedDevices}: + * + * ```javascript + * value = client.getCrypto().globalBlacklistUnverifiedDevices; + * ``` + */ + getGlobalBlacklistUnverifiedDevices() { + if (!this.cryptoBackend) { + throw new Error("End-to-end encryption disabled"); + } + return this.cryptoBackend.globalBlacklistUnverifiedDevices; + } + + /** + * Set whether sendMessage in a room with unknown and unverified devices + * should throw an error and not send them message. This has 'Global' for + * symmetry with setGlobalBlacklistUnverifiedDevices but there is currently + * no room-level equivalent for this setting. + * + * This API is currently UNSTABLE and may change or be removed without notice. + * + * @param value - whether error on unknown devices + * + * @deprecated Prefer direct access to {@link CryptoApi.globalBlacklistUnverifiedDevices}: + * + * ```ts + * client.getCrypto().globalBlacklistUnverifiedDevices = value; + * ``` + */ + setGlobalErrorOnUnknownDevices(value) { + if (!this.cryptoBackend) { + throw new Error("End-to-end encryption disabled"); + } + this.cryptoBackend.globalErrorOnUnknownDevices = value; + } + + /** + * @returns whether to error on unknown devices + * + * This API is currently UNSTABLE and may change or be removed without notice. + */ + getGlobalErrorOnUnknownDevices() { + if (!this.cryptoBackend) { + throw new Error("End-to-end encryption disabled"); + } + return this.cryptoBackend.globalErrorOnUnknownDevices; + } + + /** + * Get the ID of one of the user's cross-signing keys + * + * @param type - The type of key to get the ID of. One of + * "master", "self_signing", or "user_signing". Defaults to "master". + * + * @returns the key ID + * @deprecated prefer {@link Crypto.CryptoApi#getCrossSigningKeyId} + */ + getCrossSigningId(type = _api.CrossSigningKey.Master) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this.crypto.getCrossSigningId(type); + } + + /** + * Get the cross signing information for a given user. + * + * The cross-signing API is currently UNSTABLE and may change without notice. + * + * @param userId - the user ID to get the cross-signing info for. + * + * @returns the cross signing information for the user. + */ + getStoredCrossSigningForUser(userId) { + if (!this.cryptoBackend) { + throw new Error("End-to-end encryption disabled"); + } + return this.cryptoBackend.getStoredCrossSigningForUser(userId); + } + + /** + * Check whether a given user is trusted. + * + * The cross-signing API is currently UNSTABLE and may change without notice. + * + * @param userId - The ID of the user to check. + */ + checkUserTrust(userId) { + if (!this.cryptoBackend) { + throw new Error("End-to-end encryption disabled"); + } + return this.cryptoBackend.checkUserTrust(userId); + } + + /** + * Check whether a given device is trusted. + * + * The cross-signing API is currently UNSTABLE and may change without notice. + * + * @param userId - The ID of the user whose devices is to be checked. + * @param deviceId - The ID of the device to check + * + * @deprecated Use {@link Crypto.CryptoApi.getDeviceVerificationStatus | `CryptoApi.getDeviceVerificationStatus`} + */ + checkDeviceTrust(userId, deviceId) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this.crypto.checkDeviceTrust(userId, deviceId); + } + + /** + * Check whether one of our own devices is cross-signed by our + * user's stored keys, regardless of whether we trust those keys yet. + * + * @param deviceId - The ID of the device to check + * + * @returns true if the device is cross-signed + */ + checkIfOwnDeviceCrossSigned(deviceId) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this.crypto.checkIfOwnDeviceCrossSigned(deviceId); + } + + /** + * Check the copy of our cross-signing key that we have in the device list and + * see if we can get the private key. If so, mark it as trusted. + * @param opts - ICheckOwnCrossSigningTrustOpts object + */ + checkOwnCrossSigningTrust(opts) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this.crypto.checkOwnCrossSigningTrust(opts); + } + + /** + * Checks that a given cross-signing private key matches a given public key. + * This can be used by the getCrossSigningKey callback to verify that the + * private key it is about to supply is the one that was requested. + * @param privateKey - The private key + * @param expectedPublicKey - The public key + * @returns true if the key matches, otherwise false + */ + checkCrossSigningPrivateKey(privateKey, expectedPublicKey) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this.crypto.checkCrossSigningPrivateKey(privateKey, expectedPublicKey); + } + + // deprecated: use requestVerification instead + legacyDeviceVerification(userId, deviceId, method) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this.crypto.legacyDeviceVerification(userId, deviceId, method); + } + + /** + * Perform any background tasks that can be done before a message is ready to + * send, in order to speed up sending of the message. + * @param room - the room the event is in + * + * @deprecated Prefer {@link CryptoApi.prepareToEncrypt | `CryptoApi.prepareToEncrypt`}: + * + * ```javascript + * client.getCrypto().prepareToEncrypt(room); + * ``` + */ + prepareToEncrypt(room) { + if (!this.cryptoBackend) { + throw new Error("End-to-end encryption disabled"); + } + this.cryptoBackend.prepareToEncrypt(room); + } + + /** + * Checks if the user has previously published cross-signing keys + * + * This means downloading the devicelist for the user and checking if the list includes + * the cross-signing pseudo-device. + * + * @deprecated Prefer {@link CryptoApi.userHasCrossSigningKeys | `CryptoApi.userHasCrossSigningKeys`}: + * + * ```javascript + * result = client.getCrypto().userHasCrossSigningKeys(); + * ``` + */ + userHasCrossSigningKeys() { + if (!this.cryptoBackend) { + throw new Error("End-to-end encryption disabled"); + } + return this.cryptoBackend.userHasCrossSigningKeys(); + } + + /** + * Checks whether cross signing: + * - is enabled on this account and trusted by this device + * - has private keys either cached locally or stored in secret storage + * + * If this function returns false, bootstrapCrossSigning() can be used + * to fix things such that it returns true. That is to say, after + * bootstrapCrossSigning() completes successfully, this function should + * return true. + * @returns True if cross-signing is ready to be used on this device + * @deprecated Prefer {@link CryptoApi.isCrossSigningReady | `CryptoApi.isCrossSigningReady`}: + */ + isCrossSigningReady() { + if (!this.cryptoBackend) { + throw new Error("End-to-end encryption disabled"); + } + return this.cryptoBackend.isCrossSigningReady(); + } + + /** + * Bootstrap cross-signing by creating keys if needed. If everything is already + * set up, then no changes are made, so this is safe to run to ensure + * cross-signing is ready for use. + * + * This function: + * - creates new cross-signing keys if they are not found locally cached nor in + * secret storage (if it has been set up) + * + * @deprecated Prefer {@link CryptoApi.bootstrapCrossSigning | `CryptoApi.bootstrapCrossSigning`}. + */ + bootstrapCrossSigning(opts) { + if (!this.cryptoBackend) { + throw new Error("End-to-end encryption disabled"); + } + return this.cryptoBackend.bootstrapCrossSigning(opts); + } + + /** + * Whether to trust a others users signatures of their devices. + * If false, devices will only be considered 'verified' if we have + * verified that device individually (effectively disabling cross-signing). + * + * Default: true + * + * @returns True if trusting cross-signed devices + * + * @deprecated Prefer {@link CryptoApi.getTrustCrossSignedDevices | `CryptoApi.getTrustCrossSignedDevices`}. + */ + getCryptoTrustCrossSignedDevices() { + if (!this.cryptoBackend) { + throw new Error("End-to-end encryption disabled"); + } + return this.cryptoBackend.getTrustCrossSignedDevices(); + } + + /** + * See getCryptoTrustCrossSignedDevices + * + * @param val - True to trust cross-signed devices + * + * @deprecated Prefer {@link CryptoApi.setTrustCrossSignedDevices | `CryptoApi.setTrustCrossSignedDevices`}. + */ + setCryptoTrustCrossSignedDevices(val) { + if (!this.cryptoBackend) { + throw new Error("End-to-end encryption disabled"); + } + this.cryptoBackend.setTrustCrossSignedDevices(val); + } + + /** + * Counts the number of end to end session keys that are waiting to be backed up + * @returns Promise which resolves to the number of sessions requiring backup + */ + countSessionsNeedingBackup() { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this.crypto.countSessionsNeedingBackup(); + } + + /** + * Get information about the encryption of an event + * + * @param event - event to be checked + * @returns The event information. + */ + getEventEncryptionInfo(event) { + if (!this.cryptoBackend) { + throw new Error("End-to-end encryption disabled"); + } + return this.cryptoBackend.getEventEncryptionInfo(event); + } + + /** + * Create a recovery key from a user-supplied passphrase. + * + * The Secure Secret Storage API is currently UNSTABLE and may change without notice. + * + * @param password - Passphrase string that can be entered by the user + * when restoring the backup as an alternative to entering the recovery key. + * Optional. + * @returns Object with public key metadata, encoded private + * recovery key which should be disposed of after displaying to the user, + * and raw private key to avoid round tripping if needed. + */ + createRecoveryKeyFromPassphrase(password) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this.crypto.createRecoveryKeyFromPassphrase(password); + } + + /** + * Checks whether secret storage: + * - is enabled on this account + * - is storing cross-signing private keys + * - is storing session backup key (if enabled) + * + * If this function returns false, bootstrapSecretStorage() can be used + * to fix things such that it returns true. That is to say, after + * bootstrapSecretStorage() completes successfully, this function should + * return true. + * + * @returns True if secret storage is ready to be used on this device + * @deprecated Prefer {@link CryptoApi.isSecretStorageReady | `CryptoApi.isSecretStorageReady`}: + */ + isSecretStorageReady() { + if (!this.cryptoBackend) { + throw new Error("End-to-end encryption disabled"); + } + return this.cryptoBackend.isSecretStorageReady(); + } + + /** + * Bootstrap Secure Secret Storage if needed by creating a default key. If everything is + * already set up, then no changes are made, so this is safe to run to ensure secret + * storage is ready for use. + * + * This function + * - creates a new Secure Secret Storage key if no default key exists + * - if a key backup exists, it is migrated to store the key in the Secret + * Storage + * - creates a backup if none exists, and one is requested + * - migrates Secure Secret Storage to use the latest algorithm, if an outdated + * algorithm is found + * + */ + bootstrapSecretStorage(opts) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this.crypto.bootstrapSecretStorage(opts); + } + + /** + * Add a key for encrypting secrets. + * + * The Secure Secret Storage API is currently UNSTABLE and may change without notice. + * + * @param algorithm - the algorithm used by the key + * @param opts - the options for the algorithm. The properties used + * depend on the algorithm given. + * @param keyName - the name of the key. If not given, a random name will be generated. + * + * @returns An object with: + * keyId: the ID of the key + * keyInfo: details about the key (iv, mac, passphrase) + * + * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#addKey}. + */ + addSecretStorageKey(algorithm, opts, keyName) { + return this.secretStorage.addKey(algorithm, opts, keyName); + } + + /** + * Check whether we have a key with a given ID. + * + * The Secure Secret Storage API is currently UNSTABLE and may change without notice. + * + * @param keyId - The ID of the key to check + * for. Defaults to the default key ID if not provided. + * @returns Whether we have the key. + * + * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#hasKey}. + */ + hasSecretStorageKey(keyId) { + return this.secretStorage.hasKey(keyId); + } + + /** + * Store an encrypted secret on the server. + * + * The Secure Secret Storage API is currently UNSTABLE and may change without notice. + * + * @param name - The name of the secret + * @param secret - The secret contents. + * @param keys - The IDs of the keys to use to encrypt the secret or null/undefined + * to use the default (will throw if no default key is set). + * + * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#store}. + */ + storeSecret(name, secret, keys) { + return this.secretStorage.store(name, secret, keys); + } + + /** + * Get a secret from storage. + * + * The Secure Secret Storage API is currently UNSTABLE and may change without notice. + * + * @param name - the name of the secret + * + * @returns the contents of the secret + * + * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#get}. + */ + getSecret(name) { + return this.secretStorage.get(name); + } + + /** + * Check if a secret is stored on the server. + * + * The Secure Secret Storage API is currently UNSTABLE and may change without notice. + * + * @param name - the name of the secret + * @returns map of key name to key info the secret is encrypted + * with, or null if it is not present or not encrypted with a trusted + * key + * + * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#isStored}. + */ + isSecretStored(name) { + return this.secretStorage.isStored(name); + } + + /** + * Request a secret from another device. + * + * The Secure Secret Storage API is currently UNSTABLE and may change without notice. + * + * @param name - the name of the secret to request + * @param devices - the devices to request the secret from + * + * @returns the secret request object + */ + requestSecret(name, devices) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this.crypto.requestSecret(name, devices); + } + + /** + * Get the current default key ID for encrypting secrets. + * + * The Secure Secret Storage API is currently UNSTABLE and may change without notice. + * + * @returns The default key ID or null if no default key ID is set + * + * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#getDefaultKeyId}. + */ + getDefaultSecretStorageKeyId() { + return this.secretStorage.getDefaultKeyId(); + } + + /** + * Set the current default key ID for encrypting secrets. + * + * The Secure Secret Storage API is currently UNSTABLE and may change without notice. + * + * @param keyId - The new default key ID + * + * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#setDefaultKeyId}. + */ + setDefaultSecretStorageKeyId(keyId) { + return this.secretStorage.setDefaultKeyId(keyId); + } + + /** + * Checks that a given secret storage private key matches a given public key. + * This can be used by the getSecretStorageKey callback to verify that the + * private key it is about to supply is the one that was requested. + * + * The Secure Secret Storage API is currently UNSTABLE and may change without notice. + * + * @param privateKey - The private key + * @param expectedPublicKey - The public key + * @returns true if the key matches, otherwise false + * + * @deprecated The use of asymmetric keys for SSSS is deprecated. + * Use {@link SecretStorage.ServerSideSecretStorage#checkKey} for symmetric keys. + */ + checkSecretStoragePrivateKey(privateKey, expectedPublicKey) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this.crypto.checkSecretStoragePrivateKey(privateKey, expectedPublicKey); + } + + /** + * Get e2e information on the device that sent an event + * + * @param event - event to be checked + */ + async getEventSenderDeviceInfo(event) { + if (!this.crypto) { + return null; + } + return this.crypto.getEventSenderDeviceInfo(event); + } + + /** + * Check if the sender of an event is verified + * + * @param event - event to be checked + * + * @returns true if the sender of this event has been verified using + * {@link MatrixClient#setDeviceVerified}. + */ + async isEventSenderVerified(event) { + const device = await this.getEventSenderDeviceInfo(event); + if (!device) { + return false; + } + return device.isVerified(); + } + + /** + * Get outgoing room key request for this event if there is one. + * @param event - The event to check for + * + * @returns A room key request, or null if there is none + */ + getOutgoingRoomKeyRequest(event) { + if (!this.crypto) { + throw new Error("End-to-End encryption disabled"); + } + const wireContent = event.getWireContent(); + const requestBody = { + session_id: wireContent.session_id, + sender_key: wireContent.sender_key, + algorithm: wireContent.algorithm, + room_id: event.getRoomId() + }; + if (!requestBody.session_id || !requestBody.sender_key || !requestBody.algorithm || !requestBody.room_id) { + return Promise.resolve(null); + } + return this.crypto.cryptoStore.getOutgoingRoomKeyRequest(requestBody); + } + + /** + * Cancel a room key request for this event if one is ongoing and resend the + * request. + * @param event - event of which to cancel and resend the room + * key request. + * @returns A promise that will resolve when the key request is queued + */ + cancelAndResendEventRoomKeyRequest(event) { + if (!this.crypto) { + throw new Error("End-to-End encryption disabled"); + } + return event.cancelAndResendKeyRequest(this.crypto, this.getUserId()); + } + + /** + * Enable end-to-end encryption for a room. This does not modify room state. + * Any messages sent before the returned promise resolves will be sent unencrypted. + * @param roomId - The room ID to enable encryption in. + * @param config - The encryption config for the room. + * @returns A promise that will resolve when encryption is set up. + */ + setRoomEncryption(roomId, config) { + if (!this.crypto) { + throw new Error("End-to-End encryption disabled"); + } + return this.crypto.setRoomEncryption(roomId, config); + } + + /** + * Whether encryption is enabled for a room. + * @param roomId - the room id to query. + * @returns whether encryption is enabled. + */ + isRoomEncrypted(roomId) { + const room = this.getRoom(roomId); + if (!room) { + // we don't know about this room, so can't determine if it should be + // encrypted. Let's assume not. + return false; + } + + // if there is an 'm.room.encryption' event in this room, it should be + // encrypted (independently of whether we actually support encryption) + const ev = room.currentState.getStateEvents(_event2.EventType.RoomEncryption, ""); + if (ev) { + return true; + } + + // we don't have an m.room.encrypted event, but that might be because + // the server is hiding it from us. Check the store to see if it was + // previously encrypted. + return this.roomList.isRoomEncrypted(roomId); + } + + /** + * Encrypts and sends a given object via Olm to-device messages to a given + * set of devices. + * + * @param userDeviceMap - mapping from userId to deviceInfo + * + * @param payload - fields to include in the encrypted payload + * + * @returns Promise which + * resolves once the message has been encrypted and sent to the given + * userDeviceMap, and returns the `{ contentMap, deviceInfoByDeviceId }` + * of the successfully sent messages. + */ + encryptAndSendToDevices(userDeviceInfoArr, payload) { + if (!this.crypto) { + throw new Error("End-to-End encryption disabled"); + } + return this.crypto.encryptAndSendToDevices(userDeviceInfoArr, payload); + } + + /** + * Forces the current outbound group session to be discarded such + * that another one will be created next time an event is sent. + * + * @param roomId - The ID of the room to discard the session for + * + * @deprecated Prefer {@link CryptoApi.forceDiscardSession | `CryptoApi.forceDiscardSession`}: + * + */ + forceDiscardSession(roomId) { + if (!this.cryptoBackend) { + throw new Error("End-to-End encryption disabled"); + } + this.cryptoBackend.forceDiscardSession(roomId); + } + + /** + * Get a list containing all of the room keys + * + * This should be encrypted before returning it to the user. + * + * @returns a promise which resolves to a list of session export objects + * + * @deprecated Prefer {@link CryptoApi.exportRoomKeys | `CryptoApi.exportRoomKeys`}: + * + * ```javascript + * sessionData = await client.getCrypto().exportRoomKeys(); + * ``` + */ + exportRoomKeys() { + if (!this.cryptoBackend) { + return Promise.reject(new Error("End-to-end encryption disabled")); + } + return this.cryptoBackend.exportRoomKeys(); + } + + /** + * Import a list of room keys previously exported by exportRoomKeys + * + * @param keys - a list of session export objects + * + * @returns a promise which resolves when the keys have been imported + */ + importRoomKeys(keys, opts) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this.crypto.importRoomKeys(keys, opts); + } + + /** + * Force a re-check of the local key backup status against + * what's on the server. + * + * @returns Object with backup info (as returned by + * getKeyBackupVersion) in backupInfo and + * trust information (as returned by isKeyBackupTrusted) + * in trustInfo. + */ + checkKeyBackup() { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this.crypto.backupManager.checkKeyBackup(); + } + + /** + * Get information about the current key backup. + * @returns Information object from API or null + */ + async getKeyBackupVersion() { + let res; + try { + res = await this.http.authedRequest(_httpApi.Method.Get, "/room_keys/version", undefined, undefined, { + prefix: _httpApi.ClientPrefix.V3 + }); + } catch (e) { + if (e.errcode === "M_NOT_FOUND") { + return null; + } else { + throw e; + } + } + _backup.BackupManager.checkBackupVersion(res); + return res; + } + + /** + * @param info - key backup info dict from getKeyBackupVersion() + */ + isKeyBackupTrusted(info) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this.crypto.backupManager.isKeyBackupTrusted(info); + } + + /** + * @returns true if the client is configured to back up keys to + * the server, otherwise false. If we haven't completed a successful check + * of key backup status yet, returns null. + */ + getKeyBackupEnabled() { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this.crypto.backupManager.getKeyBackupEnabled(); + } + + /** + * Enable backing up of keys, using data previously returned from + * getKeyBackupVersion. + * + * @param info - Backup information object as returned by getKeyBackupVersion + * @returns Promise which resolves when complete. + */ + enableKeyBackup(info) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this.crypto.backupManager.enableKeyBackup(info); + } + + /** + * Disable backing up of keys. + */ + disableKeyBackup() { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + this.crypto.backupManager.disableKeyBackup(); + } + + /** + * 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 password - Passphrase string that can be entered by the user + * when restoring the backup as an alternative to entering the recovery key. + * Optional. + * + * @returns Object that can be passed to createKeyBackupVersion and + * additionally has a 'recovery_key' member with the user-facing recovery key string. + */ + async prepareKeyBackupVersion(password, opts = { + secureSecretStorage: false + }) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + + // eslint-disable-next-line camelcase + const { + algorithm, + auth_data, + recovery_key, + privateKey + } = await this.crypto.backupManager.prepareKeyBackupVersion(password); + if (opts.secureSecretStorage) { + await this.secretStorage.store("m.megolm_backup.v1", (0, olmlib.encodeBase64)(privateKey)); + _logger.logger.info("Key backup private key stored in secret storage"); + } + return { + algorithm, + /* eslint-disable camelcase */ + auth_data, + recovery_key + /* eslint-enable camelcase */ + }; + } + + /** + * Check whether the key backup private key is stored in secret storage. + * @returns map of key name to key info the secret is + * encrypted with, or null if it is not present or not encrypted with a + * trusted key + */ + isKeyBackupKeyStored() { + return Promise.resolve(this.secretStorage.isStored("m.megolm_backup.v1")); + } + + /** + * Create a new key backup version and enable it, using the information return + * from prepareKeyBackupVersion. + * + * @param info - Info object from prepareKeyBackupVersion + * @returns Object with 'version' param indicating the version created + */ + async createKeyBackupVersion(info) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + await this.crypto.backupManager.createKeyBackupVersion(info); + const data = { + algorithm: info.algorithm, + auth_data: info.auth_data + }; + + // Sign the backup auth data with the device key for backwards compat with + // older devices with cross-signing. This can probably go away very soon in + // favour of just signing with the cross-singing master key. + // XXX: Private member access + await this.crypto.signObject(data.auth_data); + if (this.cryptoCallbacks.getCrossSigningKey && + // XXX: Private member access + this.crypto.crossSigningInfo.getId()) { + // now also sign the auth data with the cross-signing master key + // we check for the callback explicitly here because we still want to be able + // to create an un-cross-signed key backup if there is a cross-signing key but + // no callback supplied. + // XXX: Private member access + await this.crypto.crossSigningInfo.signObject(data.auth_data, "master"); + } + const res = await this.http.authedRequest(_httpApi.Method.Post, "/room_keys/version", undefined, data, { + prefix: _httpApi.ClientPrefix.V3 + }); + + // We could assume everything's okay and enable directly, but this ensures + // we run the same signature verification that will be used for future + // sessions. + await this.checkKeyBackup(); + if (!this.getKeyBackupEnabled()) { + _logger.logger.error("Key backup not usable even though we just created it"); + } + return res; + } + async deleteKeyBackupVersion(version) { + if (!this.crypto) { + 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.backupManager.version) { + this.crypto.backupManager.disableKeyBackup(); + } + const path = utils.encodeUri("/room_keys/version/$version", { + $version: version + }); + await this.http.authedRequest(_httpApi.Method.Delete, path, undefined, undefined, { + prefix: _httpApi.ClientPrefix.V3 + }); + } + makeKeyBackupPath(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 + }; + return { + path, + queryData + }; + } + + /** + * Back up session keys to the homeserver. + * @param roomId - ID of the room that the keys are for Optional. + * @param sessionId - ID of the session that the keys are for Optional. + * @param version - backup version Optional. + * @param data - Object keys to send + * @returns a promise that will resolve when the keys + * are uploaded + */ + + async sendKeyBackup(roomId, sessionId, version, data) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + const path = this.makeKeyBackupPath(roomId, sessionId, version); + await this.http.authedRequest(_httpApi.Method.Put, path.path, path.queryData, data, { + prefix: _httpApi.ClientPrefix.V3 + }); + } + + /** + * Marks all group sessions as needing to be backed up and schedules them to + * upload in the background as soon as possible. + */ + async scheduleAllGroupSessionsForBackup() { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + await this.crypto.backupManager.scheduleAllGroupSessionsForBackup(); + } + + /** + * Marks all group sessions as needing to be backed up without scheduling + * them to upload in the background. + * @returns Promise which resolves to the number of sessions requiring a backup. + */ + flagAllGroupSessionsForBackup() { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this.crypto.backupManager.flagAllGroupSessionsForBackup(); + } + isValidRecoveryKey(recoveryKey) { + try { + (0, _recoverykey.decodeRecoveryKey)(recoveryKey); + return true; + } catch (e) { + return false; + } + } + + /** + * Get the raw key for a key backup from the password + * Used when migrating key backups into SSSS + * + * The cross-signing API is currently UNSTABLE and may change without notice. + * + * @param password - Passphrase + * @param backupInfo - Backup metadata from `checkKeyBackup` + * @returns key backup key + */ + keyBackupKeyFromPassword(password, backupInfo) { + return (0, _key_passphrase.keyFromAuthData)(backupInfo.auth_data, password); + } + + /** + * Get the raw key for a key backup from the recovery key + * Used when migrating key backups into SSSS + * + * The cross-signing API is currently UNSTABLE and may change without notice. + * + * @param recoveryKey - The recovery key + * @returns key backup key + */ + keyBackupKeyFromRecoveryKey(recoveryKey) { + return (0, _recoverykey.decodeRecoveryKey)(recoveryKey); + } + + /** + * Restore from an existing key backup via a passphrase. + * + * @param password - Passphrase + * @param targetRoomId - Room ID to target a specific room. + * Restores all rooms if omitted. + * @param targetSessionId - Session ID to target a specific session. + * Restores all sessions if omitted. + * @param backupInfo - Backup metadata from `checkKeyBackup` + * @param opts - Optional params such as callbacks + * @returns Status of restoration with `total` and `imported` + * key counts. + */ + + async restoreKeyBackupWithPassword(password, targetRoomId, targetSessionId, backupInfo, opts) { + const privKey = await (0, _key_passphrase.keyFromAuthData)(backupInfo.auth_data, password); + return this.restoreKeyBackup(privKey, targetRoomId, targetSessionId, backupInfo, opts); + } + + /** + * Restore from an existing key backup via a private key stored in secret + * storage. + * + * @param backupInfo - Backup metadata from `checkKeyBackup` + * @param targetRoomId - Room ID to target a specific room. + * Restores all rooms if omitted. + * @param targetSessionId - Session ID to target a specific session. + * Restores all sessions if omitted. + * @param opts - Optional params such as callbacks + * @returns Status of restoration with `total` and `imported` + * key counts. + */ + async restoreKeyBackupWithSecretStorage(backupInfo, targetRoomId, targetSessionId, opts) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + const storedKey = await this.secretStorage.get("m.megolm_backup.v1"); + + // ensure that the key is in the right format. If not, fix the key and + // store the fixed version + const fixedKey = (0, _crypto.fixBackupKey)(storedKey); + if (fixedKey) { + const keys = await this.secretStorage.getKey(); + await this.secretStorage.store("m.megolm_backup.v1", fixedKey, [keys[0]]); + } + const privKey = (0, olmlib.decodeBase64)(fixedKey || storedKey); + return this.restoreKeyBackup(privKey, targetRoomId, targetSessionId, backupInfo, opts); + } + + /** + * Restore from an existing key backup via an encoded recovery key. + * + * @param recoveryKey - Encoded recovery key + * @param targetRoomId - Room ID to target a specific room. + * Restores all rooms if omitted. + * @param targetSessionId - Session ID to target a specific session. + * Restores all sessions if omitted. + * @param backupInfo - Backup metadata from `checkKeyBackup` + * @param opts - Optional params such as callbacks + * @returns Status of restoration with `total` and `imported` + * key counts. + */ + + restoreKeyBackupWithRecoveryKey(recoveryKey, targetRoomId, targetSessionId, backupInfo, opts) { + const privKey = (0, _recoverykey.decodeRecoveryKey)(recoveryKey); + return this.restoreKeyBackup(privKey, targetRoomId, targetSessionId, backupInfo, opts); + } + async restoreKeyBackupWithCache(targetRoomId, targetSessionId, backupInfo, opts) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + const privKey = await this.crypto.getSessionBackupPrivateKey(); + if (!privKey) { + throw new Error("Couldn't get key"); + } + return this.restoreKeyBackup(privKey, targetRoomId, targetSessionId, backupInfo, opts); + } + async restoreKeyBackup(privKey, targetRoomId, targetSessionId, backupInfo, opts) { + const cacheCompleteCallback = opts?.cacheCompleteCallback; + const progressCallback = opts?.progressCallback; + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + let totalKeyCount = 0; + let keys = []; + const path = this.makeKeyBackupPath(targetRoomId, targetSessionId, backupInfo.version); + const algorithm = await _backup.BackupManager.makeAlgorithm(backupInfo, async () => { + return privKey; + }); + const untrusted = algorithm.untrusted; + try { + // If the pubkey computed from the private data we've been given + // doesn't match the one in the auth_data, the user has entered + // a different recovery key / the wrong passphrase. + if (!(await algorithm.keyMatches(privKey))) { + return Promise.reject(new _httpApi.MatrixError({ + errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY + })); + } + + // Cache the key, if possible. + // This is async. + this.crypto.storeSessionBackupPrivateKey(privKey).catch(e => { + _logger.logger.warn("Error caching session backup key:", e); + }).then(cacheCompleteCallback); + if (progressCallback) { + progressCallback({ + stage: "fetch" + }); + } + const res = await this.http.authedRequest(_httpApi.Method.Get, path.path, path.queryData, undefined, { + prefix: _httpApi.ClientPrefix.V3 + }); + if (res.rooms) { + const rooms = res.rooms; + for (const [roomId, roomData] of Object.entries(rooms)) { + if (!roomData.sessions) continue; + totalKeyCount += Object.keys(roomData.sessions).length; + const roomKeys = await algorithm.decryptSessions(roomData.sessions); + for (const k of roomKeys) { + k.room_id = roomId; + keys.push(k); + } + } + } else if (res.sessions) { + const sessions = res.sessions; + totalKeyCount = Object.keys(sessions).length; + keys = await algorithm.decryptSessions(sessions); + for (const k of keys) { + k.room_id = targetRoomId; + } + } else { + totalKeyCount = 1; + try { + const [key] = await algorithm.decryptSessions({ + [targetSessionId]: res + }); + key.room_id = targetRoomId; + key.session_id = targetSessionId; + keys.push(key); + } catch (e) { + _logger.logger.log("Failed to decrypt megolm session from backup", e); + } + } + } finally { + algorithm.free(); + } + await this.importRoomKeys(keys, { + progressCallback, + untrusted, + source: "backup" + }); + await this.checkKeyBackup(); + return { + total: totalKeyCount, + imported: keys.length + }; + } + async deleteKeysFromBackup(roomId, sessionId, version) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + const path = this.makeKeyBackupPath(roomId, sessionId, version); + await this.http.authedRequest(_httpApi.Method.Delete, path.path, path.queryData, undefined, { + prefix: _httpApi.ClientPrefix.V3 + }); + } + + /** + * Share shared-history decryption keys with the given users. + * + * @param roomId - the room for which keys should be shared. + * @param userIds - a list of users to share with. The keys will be sent to + * all of the user's current devices. + */ + async sendSharedHistoryKeys(roomId, userIds) { + if (!this.crypto) { + throw new Error("End-to-end encryption disabled"); + } + const roomEncryption = this.roomList.getRoomEncryption(roomId); + if (!roomEncryption) { + // unknown room, or unencrypted room + _logger.logger.error("Unknown room. Not sharing decryption keys"); + return; + } + const deviceInfos = await this.crypto.downloadKeys(userIds); + const devicesByUser = new Map(); + for (const [userId, devices] of deviceInfos) { + devicesByUser.set(userId, Array.from(devices.values())); + } + + // XXX: Private member access + const alg = this.crypto.getRoomDecryptor(roomId, roomEncryption.algorithm); + if (alg.sendSharedHistoryInboundSessions) { + await alg.sendSharedHistoryInboundSessions(devicesByUser); + } else { + _logger.logger.warn("Algorithm does not support sharing previous keys", roomEncryption.algorithm); + } + } + + /** + * Get the config for the media repository. + * @returns Promise which resolves with an object containing the config. + */ + getMediaConfig() { + return this.http.authedRequest(_httpApi.Method.Get, "/config", undefined, undefined, { + prefix: _httpApi.MediaPrefix.R0 + }); + } + + /** + * Get the room for the given room ID. + * This function will return a valid room for any room for which a Room event + * has been emitted. Note in particular that other events, eg. RoomState.members + * will be emitted for a room before this function will return the given room. + * @param roomId - The room ID + * @returns The Room or null if it doesn't exist or there is no data store. + */ + getRoom(roomId) { + if (!roomId) { + return null; + } + return this.store.getRoom(roomId); + } + + /** + * Retrieve all known rooms. + * @returns A list of rooms, or an empty list if there is no data store. + */ + getRooms() { + return this.store.getRooms(); + } + + /** + * Retrieve all rooms that should be displayed to the user + * This is essentially getRooms() with some rooms filtered out, eg. old versions + * of rooms that have been replaced or (in future) other rooms that have been + * marked at the protocol level as not to be displayed to the user. + * + * @param msc3946ProcessDynamicPredecessor - if true, look for an + * m.room.predecessor state event and + * use it if found (MSC3946). + * @returns A list of rooms, or an empty list if there is no data store. + */ + getVisibleRooms(msc3946ProcessDynamicPredecessor = false) { + const allRooms = this.store.getRooms(); + const replacedRooms = new Set(); + for (const r of allRooms) { + const predecessor = r.findPredecessor(msc3946ProcessDynamicPredecessor)?.roomId; + if (predecessor) { + replacedRooms.add(predecessor); + } + } + return allRooms.filter(r => { + const tombstone = r.currentState.getStateEvents(_event2.EventType.RoomTombstone, ""); + if (tombstone && replacedRooms.has(r.roomId)) { + return false; + } + return true; + }); + } + + /** + * Retrieve a user. + * @param userId - The user ID to retrieve. + * @returns A user or null if there is no data store or the user does + * not exist. + */ + getUser(userId) { + return this.store.getUser(userId); + } + + /** + * Retrieve all known users. + * @returns A list of users, or an empty list if there is no data store. + */ + getUsers() { + return this.store.getUsers(); + } + + /** + * Set account data event for the current user. + * It will retry the request up to 5 times. + * @param eventType - The event type + * @param content - the contents object for the event + * @returns Promise which resolves: an empty object + * @returns Rejects: with an error response. + */ + setAccountData(eventType, content) { + const path = utils.encodeUri("/user/$userId/account_data/$type", { + $userId: this.credentials.userId, + $type: eventType + }); + return (0, _httpApi.retryNetworkOperation)(5, () => { + return this.http.authedRequest(_httpApi.Method.Put, path, undefined, content); + }); + } + + /** + * Get account data event of given type for the current user. + * @param eventType - The event type + * @returns The contents of the given account data event + */ + getAccountData(eventType) { + return this.store.getAccountData(eventType); + } + + /** + * Get account data event of given type for the current user. This variant + * gets account data directly from the homeserver if the local store is not + * ready, which can be useful very early in startup before the initial sync. + * @param eventType - The event type + * @returns Promise which resolves: The contents of the given account data event. + * @returns Rejects: with an error response. + */ + async getAccountDataFromServer(eventType) { + if (this.isInitialSyncComplete()) { + const event = this.store.getAccountData(eventType); + if (!event) { + return null; + } + // The network version below returns just the content, so this branch + // does the same to match. + return event.getContent(); + } + const path = utils.encodeUri("/user/$userId/account_data/$type", { + $userId: this.credentials.userId, + $type: eventType + }); + try { + return await this.http.authedRequest(_httpApi.Method.Get, path); + } catch (e) { + if (e.data?.errcode === "M_NOT_FOUND") { + return null; + } + throw e; + } + } + async deleteAccountData(eventType) { + const msc3391DeleteAccountDataServerSupport = this.canSupport.get(_feature.Feature.AccountDataDeletion); + // if deletion is not supported overwrite with empty content + if (msc3391DeleteAccountDataServerSupport === _feature.ServerSupport.Unsupported) { + await this.setAccountData(eventType, {}); + return; + } + const path = utils.encodeUri("/user/$userId/account_data/$type", { + $userId: this.getSafeUserId(), + $type: eventType + }); + const options = msc3391DeleteAccountDataServerSupport === _feature.ServerSupport.Unstable ? { + prefix: "/_matrix/client/unstable/org.matrix.msc3391" + } : undefined; + return await this.http.authedRequest(_httpApi.Method.Delete, path, undefined, undefined, options); + } + + /** + * Gets the users that are ignored by this client + * @returns The array of users that are ignored (empty if none) + */ + getIgnoredUsers() { + const event = this.getAccountData("m.ignored_user_list"); + if (!event || !event.getContent() || !event.getContent()["ignored_users"]) return []; + return Object.keys(event.getContent()["ignored_users"]); + } + + /** + * Sets the users that the current user should ignore. + * @param userIds - the user IDs to ignore + * @returns Promise which resolves: an empty object + * @returns Rejects: with an error response. + */ + setIgnoredUsers(userIds) { + const content = { + ignored_users: {} + }; + userIds.forEach(u => { + content.ignored_users[u] = {}; + }); + return this.setAccountData("m.ignored_user_list", content); + } + + /** + * Gets whether or not a specific user is being ignored by this client. + * @param userId - the user ID to check + * @returns true if the user is ignored, false otherwise + */ + isUserIgnored(userId) { + return this.getIgnoredUsers().includes(userId); + } + + /** + * Join a room. If you have already joined the room, this will no-op. + * @param roomIdOrAlias - The room ID or room alias to join. + * @param opts - Options when joining the room. + * @returns Promise which resolves: Room object. + * @returns Rejects: with an error response. + */ + async joinRoom(roomIdOrAlias, opts = {}) { + if (opts.syncRoom === undefined) { + opts.syncRoom = true; + } + const room = this.getRoom(roomIdOrAlias); + if (room?.hasMembershipState(this.credentials.userId, "join")) { + return Promise.resolve(room); + } + let signPromise = Promise.resolve(); + if (opts.inviteSignUrl) { + const url = new URL(opts.inviteSignUrl); + url.searchParams.set("mxid", this.credentials.userId); + signPromise = this.http.requestOtherUrl(_httpApi.Method.Post, url); + } + const queryString = {}; + if (opts.viaServers) { + queryString["server_name"] = opts.viaServers; + } + const data = {}; + const signedInviteObj = await signPromise; + if (signedInviteObj) { + data.third_party_signed = signedInviteObj; + } + const path = utils.encodeUri("/join/$roomid", { + $roomid: roomIdOrAlias + }); + const res = await this.http.authedRequest(_httpApi.Method.Post, path, queryString, data); + const roomId = res.room_id; + const syncApi = new _sync.SyncApi(this, this.clientOpts, this.buildSyncApiOptions()); + const syncRoom = syncApi.createRoom(roomId); + if (opts.syncRoom) { + // v2 will do this for us + // return syncApi.syncRoom(room); + } + return syncRoom; + } + + /** + * Resend an event. Will also retry any to-device messages waiting to be sent. + * @param event - The event to resend. + * @param room - Optional. The room the event is in. Will update the + * timeline entry if provided. + * @returns Promise which resolves: to an ISendEventResponse object + * @returns Rejects: with an error response. + */ + resendEvent(event, room) { + // also kick the to-device queue to retry + this.toDeviceMessageQueue.sendQueue(); + this.updatePendingEventStatus(room, event, _event.EventStatus.SENDING); + return this.encryptAndSendEvent(room, event); + } + + /** + * Cancel a queued or unsent event. + * + * @param event - Event to cancel + * @throws Error if the event is not in QUEUED, NOT_SENT or ENCRYPTING state + */ + cancelPendingEvent(event) { + if (![_event.EventStatus.QUEUED, _event.EventStatus.NOT_SENT, _event.EventStatus.ENCRYPTING].includes(event.status)) { + throw new Error("cannot cancel an event with status " + event.status); + } + + // if the event is currently being encrypted then + if (event.status === _event.EventStatus.ENCRYPTING) { + this.pendingEventEncryption.delete(event.getId()); + } else if (this.scheduler && event.status === _event.EventStatus.QUEUED) { + // tell the scheduler to forget about it, if it's queued + this.scheduler.removeEventFromQueue(event); + } + + // then tell the room about the change of state, which will remove it + // from the room's list of pending events. + const room = this.getRoom(event.getRoomId()); + this.updatePendingEventStatus(room, event, _event.EventStatus.CANCELLED); + } + + /** + * @returns Promise which resolves: TODO + * @returns Rejects: with an error response. + */ + setRoomName(roomId, name) { + return this.sendStateEvent(roomId, _event2.EventType.RoomName, { + name: name + }); + } + + /** + * @param htmlTopic - Optional. + * @returns Promise which resolves: TODO + * @returns Rejects: with an error response. + */ + setRoomTopic(roomId, topic, htmlTopic) { + const content = ContentHelpers.makeTopicContent(topic, htmlTopic); + return this.sendStateEvent(roomId, _event2.EventType.RoomTopic, content); + } + + /** + * @returns Promise which resolves: to an object keyed by tagId with objects containing a numeric order field. + * @returns Rejects: with an error response. + */ + getRoomTags(roomId) { + const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags", { + $userId: this.credentials.userId, + $roomId: roomId + }); + return this.http.authedRequest(_httpApi.Method.Get, path); + } + + /** + * @param tagName - name of room tag to be set + * @param metadata - associated with that tag to be stored + * @returns Promise which resolves: to an empty object + * @returns Rejects: with an error response. + */ + setRoomTag(roomId, tagName, metadata = {}) { + const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", { + $userId: this.credentials.userId, + $roomId: roomId, + $tag: tagName + }); + return this.http.authedRequest(_httpApi.Method.Put, path, undefined, metadata); + } + + /** + * @param tagName - name of room tag to be removed + * @returns Promise which resolves: to an empty object + * @returns Rejects: with an error response. + */ + deleteRoomTag(roomId, tagName) { + const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", { + $userId: this.credentials.userId, + $roomId: roomId, + $tag: tagName + }); + return this.http.authedRequest(_httpApi.Method.Delete, path); + } + + /** + * @param eventType - event type to be set + * @param content - event content + * @returns Promise which resolves: to an empty object `{}` + * @returns Rejects: with an error response. + */ + setRoomAccountData(roomId, eventType, content) { + const path = utils.encodeUri("/user/$userId/rooms/$roomId/account_data/$type", { + $userId: this.credentials.userId, + $roomId: roomId, + $type: eventType + }); + return this.http.authedRequest(_httpApi.Method.Put, path, undefined, content); + } + + /** + * Set a power level to one or multiple users. + * @returns Promise which resolves: to an ISendEventResponse object + * @returns Rejects: with an error response. + */ + setPowerLevel(roomId, userId, powerLevel, event) { + let content = { + users: {} + }; + if (event?.getType() === _event2.EventType.RoomPowerLevels) { + // take a copy of the content to ensure we don't corrupt + // existing client state with a failed power level change + content = utils.deepCopy(event.getContent()); + } + const users = Array.isArray(userId) ? userId : [userId]; + for (const user of users) { + if (powerLevel == null) { + delete content.users[user]; + } else { + content.users[user] = powerLevel; + } + } + const path = utils.encodeUri("/rooms/$roomId/state/m.room.power_levels", { + $roomId: roomId + }); + return this.http.authedRequest(_httpApi.Method.Put, path, undefined, content); + } + + /** + * Create an m.beacon_info event + * @returns + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + async unstable_createLiveBeacon(roomId, beaconInfoContent) { + return this.unstable_setLiveBeacon(roomId, beaconInfoContent); + } + + /** + * Upsert a live beacon event + * using a specific m.beacon_info.* event variable type + * @param roomId - string + * @returns + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + async unstable_setLiveBeacon(roomId, beaconInfoContent) { + return this.sendStateEvent(roomId, _beacon.M_BEACON_INFO.name, beaconInfoContent, this.getUserId()); + } + sendEvent(roomId, threadIdOrEventType, eventTypeOrContent, contentOrTxnId, txnIdOrVoid) { + let threadId; + let eventType; + let content; + let txnId; + if (!threadIdOrEventType?.startsWith(EVENT_ID_PREFIX) && threadIdOrEventType !== null) { + txnId = contentOrTxnId; + content = eventTypeOrContent; + eventType = threadIdOrEventType; + threadId = null; + } else { + txnId = txnIdOrVoid; + content = contentOrTxnId; + eventType = eventTypeOrContent; + threadId = threadIdOrEventType; + } + + // If we expect that an event is part of a thread but is missing the relation + // we need to add it manually, as well as the reply fallback + if (threadId && !content["m.relates_to"]?.rel_type) { + const isReply = !!content["m.relates_to"]?.["m.in_reply_to"]; + content["m.relates_to"] = _objectSpread(_objectSpread({}, content["m.relates_to"]), {}, { + rel_type: _thread.THREAD_RELATION_TYPE.name, + event_id: threadId, + // Set is_falling_back to true unless this is actually intended to be a reply + is_falling_back: !isReply + }); + const thread = this.getRoom(roomId)?.getThread(threadId); + if (thread && !isReply) { + content["m.relates_to"]["m.in_reply_to"] = { + event_id: thread.lastReply(ev => { + return ev.isRelation(_thread.THREAD_RELATION_TYPE.name) && !ev.status; + })?.getId() ?? threadId + }; + } + } + return this.sendCompleteEvent(roomId, threadId, { + type: eventType, + content + }, txnId); + } + + /** + * @param eventObject - An object with the partial structure of an event, to which event_id, user_id, room_id and origin_server_ts will be added. + * @param txnId - Optional. + * @returns Promise which resolves: to an empty object `{}` + * @returns Rejects: with an error response. + */ + sendCompleteEvent(roomId, threadId, eventObject, txnId) { + if (!txnId) { + txnId = this.makeTxnId(); + } + + // We always construct a MatrixEvent when sending because the store and scheduler use them. + // We'll extract the params back out if it turns out the client has no scheduler or store. + const localEvent = new _event.MatrixEvent(Object.assign(eventObject, { + event_id: "~" + roomId + ":" + txnId, + user_id: this.credentials.userId, + sender: this.credentials.userId, + room_id: roomId, + origin_server_ts: new Date().getTime() + })); + const room = this.getRoom(roomId); + const thread = threadId ? room?.getThread(threadId) : undefined; + if (thread) { + localEvent.setThread(thread); + } + + // set up re-emitter for this new event - this is normally the job of EventMapper but we don't use it here + this.reEmitter.reEmit(localEvent, [_event.MatrixEventEvent.Replaced, _event.MatrixEventEvent.VisibilityChange]); + room?.reEmitter.reEmit(localEvent, [_event.MatrixEventEvent.BeforeRedaction]); + + // if this is a relation or redaction of an event + // that hasn't been sent yet (e.g. with a local id starting with a ~) + // then listen for the remote echo of that event so that by the time + // this event does get sent, we have the correct event_id + const targetId = localEvent.getAssociatedId(); + if (targetId?.startsWith("~")) { + const target = room?.getPendingEvents().find(e => e.getId() === targetId); + target?.once(_event.MatrixEventEvent.LocalEventIdReplaced, () => { + localEvent.updateAssociatedId(target.getId()); + }); + } + const type = localEvent.getType(); + _logger.logger.log(`sendEvent of type ${type} in ${roomId} with txnId ${txnId}`); + localEvent.setTxnId(txnId); + localEvent.setStatus(_event.EventStatus.SENDING); + + // add this event immediately to the local store as 'sending'. + 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 === _event.EventStatus.NOT_SENT) { + return Promise.reject(new Error("Event blocked by other events not yet sent")); + } + return this.encryptAndSendEvent(room, localEvent); + } + + /** + * encrypts the event if necessary; adds the event to the queue, or sends it; marks the event as sent/unsent + * @returns returns a promise which resolves with the result of the send request + */ + encryptAndSendEvent(room, event) { + let cancelled = false; + // Add an extra Promise.resolve() to turn synchronous exceptions into promise rejections, + // so that we can handle synchronous and asynchronous exceptions with the + // same code path. + return Promise.resolve().then(() => { + const encryptionPromise = this.encryptEventIfNeeded(event, room ?? undefined); + if (!encryptionPromise) return null; // doesn't need encryption + + this.pendingEventEncryption.set(event.getId(), encryptionPromise); + this.updatePendingEventStatus(room, event, _event.EventStatus.ENCRYPTING); + return encryptionPromise.then(() => { + if (!this.pendingEventEncryption.has(event.getId())) { + // cancelled via MatrixClient::cancelPendingEvent + cancelled = true; + return; + } + this.updatePendingEventStatus(room, event, _event.EventStatus.SENDING); + }); + }).then(() => { + if (cancelled) return {}; + let promise = null; + if (this.scheduler) { + // if this returns a promise then the scheduler has control now and will + // resolve/reject when it is done. Internally, the scheduler will invoke + // processFn which is set to this._sendEventHttpRequest so the same code + // path is executed regardless. + promise = this.scheduler.queueEvent(event); + if (promise && this.scheduler.getQueueForEvent(event).length > 1) { + // event is processed FIFO so if the length is 2 or more we know + // this event is stuck behind an earlier event. + this.updatePendingEventStatus(room, event, _event.EventStatus.QUEUED); + } + } + if (!promise) { + promise = this.sendEventHttpRequest(event); + if (room) { + promise = promise.then(res => { + room.updatePendingEvent(event, _event.EventStatus.SENT, res["event_id"]); + return res; + }); + } + } + return promise; + }).catch(err => { + _logger.logger.error("Error sending event", err.stack || err); + try { + // set the error on the event before we update the status: + // updating the status emits the event, so the state should be + // consistent at that point. + event.error = err; + this.updatePendingEventStatus(room, event, _event.EventStatus.NOT_SENT); + } catch (e) { + _logger.logger.error("Exception in error handler!", e.stack || err); + } + if (err instanceof _httpApi.MatrixError) { + err.event = event; + } + throw err; + }); + } + encryptEventIfNeeded(event, room) { + if (event.isEncrypted()) { + // this event has already been encrypted; this happens if the + // encryption step succeeded, but the send step failed on the first + // attempt. + return null; + } + if (event.isRedaction()) { + // Redactions do not support encryption in the spec at this time, + // whilst it mostly worked in some clients, it wasn't compliant. + return null; + } + if (!room || !this.isRoomEncrypted(event.getRoomId())) { + return null; + } + if (!this.cryptoBackend && this.usingExternalCrypto) { + // The client has opted to allow sending messages to encrypted + // rooms even if the room is encrypted, and we haven't setup + // crypto. This is useful for users of matrix-org/pantalaimon + return null; + } + if (event.getType() === _event2.EventType.Reaction) { + // For reactions, there is a very little gained by encrypting the entire + // event, as relation data is already kept in the clear. Event + // encryption for a reaction effectively only obscures the event type, + // but the purpose is still obvious from the relation data, so nothing + // is really gained. It also causes quite a few problems, such as: + // * triggers notifications via default push rules + // * prevents server-side bundling for reactions + // The reaction key / content / emoji value does warrant encrypting, but + // this will be handled separately by encrypting just this value. + // See https://github.com/matrix-org/matrix-doc/pull/1849#pullrequestreview-248763642 + return null; + } + if (!this.cryptoBackend) { + throw new Error("This room is configured to use encryption, but your client does not support encryption."); + } + return this.cryptoBackend.encryptEvent(event, room); + } + + /** + * Returns the eventType that should be used taking encryption into account + * for a given eventType. + * @param roomId - the room for the events `eventType` relates to + * @param eventType - the event type + * @returns the event type taking encryption into account + */ + getEncryptedIfNeededEventType(roomId, eventType) { + if (eventType === _event2.EventType.Reaction) return eventType; + return this.isRoomEncrypted(roomId) ? _event2.EventType.RoomMessageEncrypted : eventType; + } + updatePendingEventStatus(room, event, newStatus) { + if (room) { + room.updatePendingEvent(event, newStatus); + } else { + event.setStatus(newStatus); + } + } + sendEventHttpRequest(event) { + let txnId = event.getTxnId(); + if (!txnId) { + txnId = this.makeTxnId(); + event.setTxnId(txnId); + } + const pathParams = { + $roomId: event.getRoomId(), + $eventType: event.getWireType(), + $stateKey: event.getStateKey(), + $txnId: txnId + }; + let path; + if (event.isState()) { + let pathTemplate = "/rooms/$roomId/state/$eventType"; + if (event.getStateKey() && event.getStateKey().length > 0) { + pathTemplate = "/rooms/$roomId/state/$eventType/$stateKey"; + } + path = utils.encodeUri(pathTemplate, pathParams); + } else if (event.isRedaction()) { + const pathTemplate = `/rooms/$roomId/redact/$redactsEventId/$txnId`; + path = utils.encodeUri(pathTemplate, _objectSpread({ + $redactsEventId: event.event.redacts + }, pathParams)); + } else { + path = utils.encodeUri("/rooms/$roomId/send/$eventType/$txnId", pathParams); + } + return this.http.authedRequest(_httpApi.Method.Put, path, undefined, event.getWireContent()).then(res => { + _logger.logger.log(`Event sent to ${event.getRoomId()} with event id ${res.event_id}`); + return res; + }); + } + + /** + * @param txnId - transaction id. One will be made up if not supplied. + * @param opts - Options to pass on, may contain `reason` and `with_relations` (MSC3912) + * @returns Promise which resolves: TODO + * @returns Rejects: with an error response. + * @throws Error if called with `with_relations` (MSC3912) but the server does not support it. + * Callers should check whether the server supports MSC3912 via `MatrixClient.canSupport`. + */ + + redactEvent(roomId, threadId, eventId, txnId, opts) { + if (!eventId?.startsWith(EVENT_ID_PREFIX)) { + opts = txnId; + txnId = eventId; + eventId = threadId; + threadId = null; + } + const reason = opts?.reason; + if (opts?.with_relations && this.canSupport.get(_feature.Feature.RelationBasedRedactions) === _feature.ServerSupport.Unsupported) { + throw new Error("Server does not support relation based redactions " + `roomId ${roomId} eventId ${eventId} txnId: ${txnId} threadId ${threadId}`); + } + const withRelations = opts?.with_relations ? { + [this.canSupport.get(_feature.Feature.RelationBasedRedactions) === _feature.ServerSupport.Stable ? _event2.MSC3912_RELATION_BASED_REDACTIONS_PROP.stable : _event2.MSC3912_RELATION_BASED_REDACTIONS_PROP.unstable]: opts?.with_relations + } : {}; + return this.sendCompleteEvent(roomId, threadId, { + type: _event2.EventType.RoomRedaction, + content: _objectSpread(_objectSpread({}, withRelations), {}, { + reason + }), + redacts: eventId + }, txnId); + } + + /** + * @param txnId - Optional. + * @returns Promise which resolves: to an ISendEventResponse object + * @returns Rejects: with an error response. + */ + + sendMessage(roomId, threadId, content, txnId) { + if (typeof threadId !== "string" && threadId !== null) { + txnId = content; + content = threadId; + threadId = null; + } + const eventType = _event2.EventType.RoomMessage; + const sendContent = content; + return this.sendEvent(roomId, threadId, eventType, sendContent, txnId); + } + + /** + * @param txnId - Optional. + * @returns + * @returns Rejects: with an error response. + */ + + sendTextMessage(roomId, threadId, body, txnId) { + if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { + txnId = body; + body = threadId; + threadId = null; + } + const content = ContentHelpers.makeTextMessage(body); + return this.sendMessage(roomId, threadId, content, txnId); + } + + /** + * @param txnId - Optional. + * @returns Promise which resolves: to a ISendEventResponse object + * @returns Rejects: with an error response. + */ + + sendNotice(roomId, threadId, body, txnId) { + if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { + txnId = body; + body = threadId; + threadId = null; + } + const content = ContentHelpers.makeNotice(body); + return this.sendMessage(roomId, threadId, content, txnId); + } + + /** + * @param txnId - Optional. + * @returns Promise which resolves: to a ISendEventResponse object + * @returns Rejects: with an error response. + */ + + sendEmoteMessage(roomId, threadId, body, txnId) { + if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { + txnId = body; + body = threadId; + threadId = null; + } + const content = ContentHelpers.makeEmoteMessage(body); + return this.sendMessage(roomId, threadId, content, txnId); + } + + /** + * @returns Promise which resolves: to a ISendEventResponse object + * @returns Rejects: with an error response. + */ + + sendImageMessage(roomId, threadId, url, info, text = "Image") { + if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { + text = info || "Image"; + info = url; + url = threadId; + threadId = null; + } + const content = { + msgtype: _event2.MsgType.Image, + url: url, + info: info, + body: text + }; + return this.sendMessage(roomId, threadId, content); + } + + /** + * @returns Promise which resolves: to a ISendEventResponse object + * @returns Rejects: with an error response. + */ + + sendStickerMessage(roomId, threadId, url, info, text = "Sticker") { + if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { + text = info || "Sticker"; + info = url; + url = threadId; + threadId = null; + } + const content = { + url: url, + info: info, + body: text + }; + return this.sendEvent(roomId, threadId, _event2.EventType.Sticker, content); + } + + /** + * @returns Promise which resolves: to a ISendEventResponse object + * @returns Rejects: with an error response. + */ + + sendHtmlMessage(roomId, threadId, body, htmlBody) { + if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { + htmlBody = body; + body = threadId; + threadId = null; + } + const content = ContentHelpers.makeHtmlMessage(body, htmlBody); + return this.sendMessage(roomId, threadId, content); + } + + /** + * @returns Promise which resolves: to a ISendEventResponse object + * @returns Rejects: with an error response. + */ + + sendHtmlNotice(roomId, threadId, body, htmlBody) { + if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { + htmlBody = body; + body = threadId; + threadId = null; + } + const content = ContentHelpers.makeHtmlNotice(body, htmlBody); + return this.sendMessage(roomId, threadId, content); + } + + /** + * @returns Promise which resolves: to a ISendEventResponse object + * @returns Rejects: with an error response. + */ + + sendHtmlEmote(roomId, threadId, body, htmlBody) { + if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { + htmlBody = body; + body = threadId; + threadId = null; + } + const content = ContentHelpers.makeHtmlEmote(body, htmlBody); + return this.sendMessage(roomId, threadId, content); + } + + /** + * Send a receipt. + * @param event - The event being acknowledged + * @param receiptType - The kind of receipt e.g. "m.read". Other than + * ReceiptType.Read are experimental! + * @param body - Additional content to send alongside the receipt. + * @param unthreaded - An unthreaded receipt will clear room+thread notifications + * @returns Promise which resolves: to an empty object `{}` + * @returns Rejects: with an error response. + */ + async sendReceipt(event, receiptType, body, unthreaded = false) { + if (this.isGuest()) { + return Promise.resolve({}); // guests cannot send receipts so don't bother. + } + + const path = utils.encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", { + $roomId: event.getRoomId(), + $receiptType: receiptType, + $eventId: event.getId() + }); + if (!unthreaded) { + const isThread = !!event.threadRootId; + body = _objectSpread(_objectSpread({}, body), {}, { + thread_id: isThread ? event.threadRootId : _read_receipts.MAIN_ROOM_TIMELINE + }); + } + const promise = this.http.authedRequest(_httpApi.Method.Post, path, undefined, body || {}); + const room = this.getRoom(event.getRoomId()); + if (room && this.credentials.userId) { + room.addLocalEchoReceipt(this.credentials.userId, event, receiptType); + } + return promise; + } + + /** + * Send a read receipt. + * @param event - The event that has been read. + * @param receiptType - other than ReceiptType.Read are experimental! Optional. + * @returns Promise which resolves: to an empty object `{}` + * @returns Rejects: with an error response. + */ + async sendReadReceipt(event, receiptType = _read_receipts.ReceiptType.Read, unthreaded = false) { + if (!event) return; + const eventId = event.getId(); + const room = this.getRoom(event.getRoomId()); + if (room?.hasPendingEvent(eventId)) { + throw new Error(`Cannot set read receipt to a pending event (${eventId})`); + } + return this.sendReceipt(event, receiptType, {}, unthreaded); + } + + /** + * Set a marker to indicate the point in a room before which the user has read every + * event. This can be retrieved from room account data (the event type is `m.fully_read`) + * and displayed as a horizontal line in the timeline that is visually distinct to the + * position of the user's own read receipt. + * @param roomId - ID of the room that has been read + * @param rmEventId - ID of the event that has been read + * @param rrEvent - the event tracked by the read receipt. This is here for + * convenience because the RR and the RM are commonly updated at the same time as each + * other. The local echo of this receipt will be done if set. Optional. + * @param rpEvent - the m.read.private read receipt event for when we don't + * want other users to see the read receipts. This is experimental. Optional. + * @returns Promise which resolves: the empty object, `{}`. + */ + async setRoomReadMarkers(roomId, rmEventId, rrEvent, rpEvent) { + const room = this.getRoom(roomId); + if (room?.hasPendingEvent(rmEventId)) { + throw new Error(`Cannot set read marker to a pending event (${rmEventId})`); + } + + // Add the optional RR update, do local echo like `sendReceipt` + let rrEventId; + if (rrEvent) { + rrEventId = rrEvent.getId(); + if (room?.hasPendingEvent(rrEventId)) { + throw new Error(`Cannot set read receipt to a pending event (${rrEventId})`); + } + room?.addLocalEchoReceipt(this.credentials.userId, rrEvent, _read_receipts.ReceiptType.Read); + } + + // Add the optional private RR update, do local echo like `sendReceipt` + let rpEventId; + if (rpEvent) { + rpEventId = rpEvent.getId(); + if (room?.hasPendingEvent(rpEventId)) { + throw new Error(`Cannot set read receipt to a pending event (${rpEventId})`); + } + room?.addLocalEchoReceipt(this.credentials.userId, rpEvent, _read_receipts.ReceiptType.ReadPrivate); + } + return await this.setRoomReadMarkersHttpRequest(roomId, rmEventId, rrEventId, rpEventId); + } + + /** + * Get a preview of the given URL as of (roughly) the given point in time, + * described as an object with OpenGraph keys and associated values. + * Attributes may be synthesized where actual OG metadata is lacking. + * Caches results to prevent hammering the server. + * @param url - The URL to get preview data for + * @param ts - The preferred point in time that the preview should + * describe (ms since epoch). The preview returned will either be the most + * recent one preceding this timestamp if available, or failing that the next + * most recent available preview. + * @returns Promise which resolves: Object of OG metadata. + * @returns Rejects: with an error response. + * May return synthesized attributes if the URL lacked OG meta. + */ + getUrlPreview(url, ts) { + // bucket the timestamp to the nearest minute to prevent excessive spam to the server + // Surely 60-second accuracy is enough for anyone. + ts = Math.floor(ts / 60000) * 60000; + const parsed = new URL(url); + parsed.hash = ""; // strip the hash as it won't affect the preview + url = parsed.toString(); + const key = ts + "_" + url; + + // If there's already a request in flight (or we've handled it), return that instead. + if (key in this.urlPreviewCache) { + return this.urlPreviewCache[key]; + } + const resp = this.http.authedRequest(_httpApi.Method.Get, "/preview_url", { + url, + ts: ts.toString() + }, undefined, { + prefix: _httpApi.MediaPrefix.R0 + }); + // TODO: Expire the URL preview cache sometimes + this.urlPreviewCache[key] = resp; + return resp; + } + + /** + * @returns Promise which resolves: to an empty object `{}` + * @returns Rejects: with an error response. + */ + sendTyping(roomId, isTyping, timeoutMs) { + if (this.isGuest()) { + return Promise.resolve({}); // guests cannot send typing notifications so don't bother. + } + + const path = utils.encodeUri("/rooms/$roomId/typing/$userId", { + $roomId: roomId, + $userId: this.getUserId() + }); + const data = { + typing: isTyping + }; + if (isTyping) { + data.timeout = timeoutMs ? timeoutMs : 20000; + } + return this.http.authedRequest(_httpApi.Method.Put, path, undefined, data); + } + + /** + * Determines the history of room upgrades for a given room, as far as the + * client can see. Returns an array of Rooms where the first entry is the + * oldest and the last entry is the newest (likely current) room. If the + * provided room is not found, this returns an empty list. This works in + * both directions, looking for older and newer rooms of the given room. + * @param roomId - The room ID to search from + * @param verifyLinks - If true, the function will only return rooms + * which can be proven to be linked. For example, rooms which have a create + * event pointing to an old room which the client is not aware of or doesn't + * have a matching tombstone would not be returned. + * @param msc3946ProcessDynamicPredecessor - if true, look for + * m.room.predecessor state events as well as create events, and prefer + * predecessor events where they exist (MSC3946). + * @returns An array of rooms representing the upgrade + * history. + */ + getRoomUpgradeHistory(roomId, verifyLinks = false, msc3946ProcessDynamicPredecessor = false) { + const currentRoom = this.getRoom(roomId); + if (!currentRoom) return []; + const before = this.findPredecessorRooms(currentRoom, verifyLinks, msc3946ProcessDynamicPredecessor); + const after = this.findSuccessorRooms(currentRoom, verifyLinks, msc3946ProcessDynamicPredecessor); + return [...before, currentRoom, ...after]; + } + findPredecessorRooms(room, verifyLinks, msc3946ProcessDynamicPredecessor) { + const ret = []; + + // Work backwards from newer to older rooms + let predecessorRoomId = room.findPredecessor(msc3946ProcessDynamicPredecessor)?.roomId; + while (predecessorRoomId !== null) { + const predecessorRoom = this.getRoom(predecessorRoomId); + if (predecessorRoom === null) { + break; + } + if (verifyLinks) { + const tombstone = predecessorRoom.currentState.getStateEvents(_event2.EventType.RoomTombstone, ""); + if (!tombstone || tombstone.getContent()["replacement_room"] !== room.roomId) { + break; + } + } + + // Insert at the front because we're working backwards from the currentRoom + ret.splice(0, 0, predecessorRoom); + room = predecessorRoom; + predecessorRoomId = room.findPredecessor(msc3946ProcessDynamicPredecessor)?.roomId; + } + return ret; + } + findSuccessorRooms(room, verifyLinks, msc3946ProcessDynamicPredecessor) { + const ret = []; + + // Work forwards, looking at tombstone events + let tombstoneEvent = room.currentState.getStateEvents(_event2.EventType.RoomTombstone, ""); + while (tombstoneEvent) { + const successorRoom = this.getRoom(tombstoneEvent.getContent()["replacement_room"]); + if (!successorRoom) break; // end of the chain + if (successorRoom.roomId === room.roomId) break; // Tombstone is referencing its own room + + if (verifyLinks) { + const predecessorRoomId = successorRoom.findPredecessor(msc3946ProcessDynamicPredecessor)?.roomId; + if (!predecessorRoomId || predecessorRoomId !== room.roomId) { + break; + } + } + + // Push to the end because we're looking forwards + ret.push(successorRoom); + const roomIds = new Set(ret.map(ref => ref.roomId)); + if (roomIds.size < ret.length) { + // The last room added to the list introduced a previous roomId + // To avoid recursion, return the last rooms - 1 + return ret.slice(0, ret.length - 1); + } + + // Set the current room to the reference room so we know where we're at + room = successorRoom; + tombstoneEvent = room.currentState.getStateEvents(_event2.EventType.RoomTombstone, ""); + } + return ret; + } + + /** + * @param reason - Optional. + * @returns Promise which resolves: `{}` an empty object. + * @returns Rejects: with an error response. + */ + invite(roomId, userId, reason) { + return this.membershipChange(roomId, userId, "invite", reason); + } + + /** + * Invite a user to a room based on their email address. + * @param roomId - The room to invite the user to. + * @param email - The email address to invite. + * @returns Promise which resolves: `{}` an empty object. + * @returns Rejects: with an error response. + */ + inviteByEmail(roomId, email) { + return this.inviteByThreePid(roomId, "email", email); + } + + /** + * Invite a user to a room based on a third-party identifier. + * @param roomId - The room to invite the user to. + * @param medium - The medium to invite the user e.g. "email". + * @param address - The address for the specified medium. + * @returns Promise which resolves: `{}` an empty object. + * @returns Rejects: with an error response. + */ + async inviteByThreePid(roomId, medium, address) { + const path = utils.encodeUri("/rooms/$roomId/invite", { + $roomId: roomId + }); + const identityServerUrl = this.getIdentityServerUrl(true); + if (!identityServerUrl) { + return Promise.reject(new _httpApi.MatrixError({ + error: "No supplied identity server URL", + errcode: "ORG.MATRIX.JSSDK_MISSING_PARAM" + })); + } + const params = { + id_server: identityServerUrl, + medium: medium, + address: address + }; + if (this.identityServer?.getAccessToken && (await this.doesServerAcceptIdentityAccessToken())) { + const identityAccessToken = await this.identityServer.getAccessToken(); + if (identityAccessToken) { + params["id_access_token"] = identityAccessToken; + } + } + return this.http.authedRequest(_httpApi.Method.Post, path, undefined, params); + } + + /** + * @returns Promise which resolves: `{}` an empty object. + * @returns Rejects: with an error response. + */ + leave(roomId) { + return this.membershipChange(roomId, undefined, "leave"); + } + + /** + * Leaves all rooms in the chain of room upgrades based on the given room. By + * default, this will leave all the previous and upgraded rooms, including the + * given room. To only leave the given room and any previous rooms, keeping the + * upgraded (modern) rooms untouched supply `false` to `includeFuture`. + * @param roomId - The room ID to start leaving at + * @param includeFuture - If true, the whole chain (past and future) of + * upgraded rooms will be left. + * @returns Promise which resolves when completed with an object keyed + * by room ID and value of the error encountered when leaving or null. + */ + leaveRoomChain(roomId, includeFuture = true) { + const upgradeHistory = this.getRoomUpgradeHistory(roomId); + let eligibleToLeave = upgradeHistory; + if (!includeFuture) { + eligibleToLeave = []; + for (const room of upgradeHistory) { + eligibleToLeave.push(room); + if (room.roomId === roomId) { + break; + } + } + } + const populationResults = {}; + const promises = []; + const doLeave = roomId => { + return this.leave(roomId).then(() => { + delete populationResults[roomId]; + }).catch(err => { + // suppress error + populationResults[roomId] = err; + }); + }; + for (const room of eligibleToLeave) { + promises.push(doLeave(room.roomId)); + } + return Promise.all(promises).then(() => populationResults); + } + + /** + * @param reason - Optional. + * @returns Promise which resolves: TODO + * @returns Rejects: with an error response. + */ + ban(roomId, userId, reason) { + return this.membershipChange(roomId, userId, "ban", reason); + } + + /** + * @param deleteRoom - True to delete the room from the store on success. + * Default: true. + * @returns Promise which resolves: `{}` an empty object. + * @returns Rejects: with an error response. + */ + forget(roomId, deleteRoom = true) { + const promise = this.membershipChange(roomId, undefined, "forget"); + if (!deleteRoom) { + return promise; + } + return promise.then(response => { + this.store.removeRoom(roomId); + this.emit(ClientEvent.DeleteRoom, roomId); + return response; + }); + } + + /** + * @returns Promise which resolves: Object (currently empty) + * @returns Rejects: with an error response. + */ + unban(roomId, userId) { + // unbanning != set their state to leave: this used to be + // the case, but was then changed so that leaving was always + // a revoking of privilege, otherwise two people racing to + // kick / ban someone could end up banning and then un-banning + // them. + const path = utils.encodeUri("/rooms/$roomId/unban", { + $roomId: roomId + }); + const data = { + user_id: userId + }; + return this.http.authedRequest(_httpApi.Method.Post, path, undefined, data); + } + + /** + * @param reason - Optional. + * @returns Promise which resolves: `{}` an empty object. + * @returns Rejects: with an error response. + */ + kick(roomId, userId, reason) { + const path = utils.encodeUri("/rooms/$roomId/kick", { + $roomId: roomId + }); + const data = { + user_id: userId, + reason: reason + }; + return this.http.authedRequest(_httpApi.Method.Post, path, undefined, data); + } + membershipChange(roomId, userId, membership, reason) { + // API returns an empty object + const path = utils.encodeUri("/rooms/$room_id/$membership", { + $room_id: roomId, + $membership: membership + }); + return this.http.authedRequest(_httpApi.Method.Post, path, undefined, { + user_id: userId, + // may be undefined e.g. on leave + reason: reason + }); + } + + /** + * Obtain a dict of actions which should be performed for this event according + * to the push rules for this user. Caches the dict on the event. + * @param event - The event to get push actions for. + * @param forceRecalculate - forces to recalculate actions for an event + * Useful when an event just got decrypted + * @returns A dict of actions to perform. + */ + getPushActionsForEvent(event, forceRecalculate = false) { + if (!event.getPushActions() || forceRecalculate) { + const { + actions, + rule + } = this.pushProcessor.actionsAndRuleForEvent(event); + event.setPushDetails(actions, rule); + } + return event.getPushActions(); + } + + /** + * Obtain a dict of actions which should be performed for this event according + * to the push rules for this user. Caches the dict on the event. + * @param event - The event to get push actions for. + * @param forceRecalculate - forces to recalculate actions for an event + * Useful when an event just got decrypted + * @returns A dict of actions to perform. + */ + getPushDetailsForEvent(event, forceRecalculate = false) { + if (!event.getPushDetails() || forceRecalculate) { + const { + actions, + rule + } = this.pushProcessor.actionsAndRuleForEvent(event); + event.setPushDetails(actions, rule); + } + return event.getPushDetails(); + } + + /** + * @param info - The kind of info to set (e.g. 'avatar_url') + * @param data - The JSON object to set. + * @returns + * @returns Rejects: with an error response. + */ + // eslint-disable-next-line camelcase + setProfileInfo(info, data) { + const path = utils.encodeUri("/profile/$userId/$info", { + $userId: this.credentials.userId, + $info: info + }); + return this.http.authedRequest(_httpApi.Method.Put, path, undefined, data); + } + + /** + * @returns Promise which resolves: `{}` an empty object. + * @returns Rejects: with an error response. + */ + async setDisplayName(name) { + const prom = await this.setProfileInfo("displayname", { + displayname: name + }); + // XXX: synthesise a profile update for ourselves because Synapse is broken and won't + const user = this.getUser(this.getUserId()); + if (user) { + user.displayName = name; + user.emit(_user.UserEvent.DisplayName, user.events.presence, user); + } + return prom; + } + + /** + * @returns Promise which resolves: `{}` an empty object. + * @returns Rejects: with an error response. + */ + async setAvatarUrl(url) { + const prom = await this.setProfileInfo("avatar_url", { + avatar_url: url + }); + // XXX: synthesise a profile update for ourselves because Synapse is broken and won't + const user = this.getUser(this.getUserId()); + if (user) { + user.avatarUrl = url; + user.emit(_user.UserEvent.AvatarUrl, user.events.presence, user); + } + return prom; + } + + /** + * Turn an MXC URL into an HTTP one. <strong>This method is experimental and + * may change.</strong> + * @param mxcUrl - The MXC URL + * @param width - The desired width of the thumbnail. + * @param height - The desired height of the thumbnail. + * @param resizeMethod - The thumbnail resize method to use, either + * "crop" or "scale". + * @param allowDirectLinks - If true, return any non-mxc URLs + * directly. Fetching such URLs will leak information about the user to + * anyone they share a room with. If false, will return null for such URLs. + * @returns the avatar URL or null. + */ + mxcUrlToHttp(mxcUrl, width, height, resizeMethod, allowDirectLinks) { + return (0, _contentRepo.getHttpUriForMxc)(this.baseUrl, mxcUrl, width, height, resizeMethod, allowDirectLinks); + } + + /** + * @param opts - Options to apply + * @returns Promise which resolves + * @returns Rejects: with an error response. + * @throws If 'presence' isn't a valid presence enum value. + */ + async setPresence(opts) { + const path = utils.encodeUri("/presence/$userId/status", { + $userId: this.credentials.userId + }); + const validStates = ["offline", "online", "unavailable"]; + if (validStates.indexOf(opts.presence) === -1) { + throw new Error("Bad presence value: " + opts.presence); + } + await this.http.authedRequest(_httpApi.Method.Put, path, undefined, opts); + } + + /** + * @param userId - The user to get presence for + * @returns Promise which resolves: The presence state for this user. + * @returns Rejects: with an error response. + */ + getPresence(userId) { + const path = utils.encodeUri("/presence/$userId/status", { + $userId: userId + }); + return this.http.authedRequest(_httpApi.Method.Get, path); + } + + /** + * Retrieve older messages from the given room and put them in the timeline. + * + * If this is called multiple times whilst a request is ongoing, the <i>same</i> + * Promise will be returned. If there was a problem requesting scrollback, there + * will be a small delay before another request can be made (to prevent tight-looping + * when there is no connection). + * + * @param room - The room to get older messages in. + * @param limit - Optional. The maximum number of previous events to + * pull in. Default: 30. + * @returns Promise which resolves: Room. If you are at the beginning + * of the timeline, `Room.oldState.paginationToken` will be + * `null`. + * @returns Rejects: with an error response. + */ + scrollback(room, limit = 30) { + let timeToWaitMs = 0; + let info = this.ongoingScrollbacks[room.roomId] || {}; + if (info.promise) { + return info.promise; + } else if (info.errorTs) { + const timeWaitedMs = Date.now() - info.errorTs; + timeToWaitMs = Math.max(SCROLLBACK_DELAY_MS - timeWaitedMs, 0); + } + if (room.oldState.paginationToken === null) { + return Promise.resolve(room); // already at the start. + } + // attempt to grab more events from the store first + const numAdded = this.store.scrollback(room, limit).length; + if (numAdded === limit) { + // store contained everything we needed. + return Promise.resolve(room); + } + // reduce the required number of events appropriately + limit = limit - numAdded; + const promise = new Promise((resolve, reject) => { + // wait for a time before doing this request + // (which may be 0 in order not to special case the code paths) + (0, utils.sleep)(timeToWaitMs).then(() => { + return this.createMessagesRequest(room.roomId, room.oldState.paginationToken, limit, _eventTimeline.Direction.Backward); + }).then(res => { + const matrixEvents = res.chunk.map(this.getEventMapper()); + if (res.state) { + const stateEvents = res.state.map(this.getEventMapper()); + room.currentState.setUnknownStateEvents(stateEvents); + } + const [timelineEvents, threadedEvents, unknownRelations] = room.partitionThreadedEvents(matrixEvents); + this.processAggregatedTimelineEvents(room, timelineEvents); + room.addEventsToTimeline(timelineEvents, true, room.getLiveTimeline()); + this.processThreadEvents(room, threadedEvents, true); + unknownRelations.forEach(event => room.relations.aggregateChildEvent(event)); + room.oldState.paginationToken = res.end ?? null; + if (res.chunk.length === 0) { + room.oldState.paginationToken = null; + } + this.store.storeEvents(room, matrixEvents, res.end ?? null, true); + delete this.ongoingScrollbacks[room.roomId]; + resolve(room); + }).catch(err => { + this.ongoingScrollbacks[room.roomId] = { + errorTs: Date.now() + }; + reject(err); + }); + }); + info = { + promise + }; + this.ongoingScrollbacks[room.roomId] = info; + return promise; + } + getEventMapper(options) { + return (0, _eventMapper.eventMapperFor)(this, options || {}); + } + + /** + * Get an EventTimeline for the given event + * + * <p>If the EventTimelineSet object already has the given event in its store, the + * corresponding timeline will be returned. Otherwise, a /context request is + * made, and used to construct an EventTimeline. + * If the event does not belong to this EventTimelineSet then undefined will be returned. + * + * @param timelineSet - The timelineSet to look for the event in, must be bound to a room + * @param eventId - The ID of the event to look for + * + * @returns Promise which resolves: + * {@link EventTimeline} including the given event + */ + async getEventTimeline(timelineSet, eventId) { + // don't allow any timeline support unless it's been enabled. + if (!this.timelineSupport) { + throw new Error("timeline support is disabled. Set the 'timelineSupport'" + " parameter to true when creating MatrixClient to enable it."); + } + if (!timelineSet?.room) { + throw new Error("getEventTimeline only supports room timelines"); + } + if (timelineSet.getTimelineForEvent(eventId)) { + return timelineSet.getTimelineForEvent(eventId); + } + if (timelineSet.thread && this.supportsThreads()) { + return this.getThreadTimeline(timelineSet, eventId); + } + const path = utils.encodeUri("/rooms/$roomId/context/$eventId", { + $roomId: timelineSet.room.roomId, + $eventId: eventId + }); + let params = undefined; + if (this.clientOpts?.lazyLoadMembers) { + params = { + filter: JSON.stringify(_filter.Filter.LAZY_LOADING_MESSAGES_FILTER) + }; + } + + // TODO: we should implement a backoff (as per scrollback()) to deal more nicely with HTTP errors. + const res = await this.http.authedRequest(_httpApi.Method.Get, path, params); + if (!res.event) { + throw new Error("'event' not in '/context' result - homeserver too old?"); + } + + // by the time the request completes, the event might have ended up in the timeline. + if (timelineSet.getTimelineForEvent(eventId)) { + return timelineSet.getTimelineForEvent(eventId); + } + const mapper = this.getEventMapper(); + const event = mapper(res.event); + if (event.isRelation(_thread.THREAD_RELATION_TYPE.name)) { + _logger.logger.warn("Tried loading a regular timeline at the position of a thread event"); + return undefined; + } + const events = [ + // Order events from most recent to oldest (reverse-chronological). + // We start with the last event, since that's the point at which we have known state. + // events_after is already backwards; events_before is forwards. + ...res.events_after.reverse().map(mapper), event, ...res.events_before.map(mapper)]; + + // Here we handle non-thread timelines only, but still process any thread events to populate thread summaries. + let timeline = timelineSet.getTimelineForEvent(events[0].getId()); + if (timeline) { + timeline.getState(_eventTimeline.EventTimeline.BACKWARDS).setUnknownStateEvents(res.state.map(mapper)); + } else { + timeline = timelineSet.addTimeline(); + timeline.initialiseState(res.state.map(mapper)); + timeline.getState(_eventTimeline.EventTimeline.FORWARDS).paginationToken = res.end; + } + const [timelineEvents, threadedEvents, unknownRelations] = timelineSet.room.partitionThreadedEvents(events); + timelineSet.addEventsToTimeline(timelineEvents, true, timeline, res.start); + // The target event is not in a thread but process the contextual events, so we can show any threads around it. + this.processThreadEvents(timelineSet.room, threadedEvents, true); + this.processAggregatedTimelineEvents(timelineSet.room, timelineEvents); + unknownRelations.forEach(event => timelineSet.relations.aggregateChildEvent(event)); + + // There is no guarantee that the event ended up in "timeline" (we might have switched to a neighbouring + // timeline) - so check the room's index again. On the other hand, there's no guarantee the event ended up + // anywhere, if it was later redacted, so we just return the timeline we first thought of. + return timelineSet.getTimelineForEvent(eventId) ?? timelineSet.room.findThreadForEvent(event)?.liveTimeline ?? + // for Threads degraded support + timeline; + } + async getThreadTimeline(timelineSet, eventId) { + if (!this.supportsThreads()) { + throw new Error("could not get thread timeline: no client support"); + } + if (!timelineSet.room) { + throw new Error("could not get thread timeline: not a room timeline"); + } + if (!timelineSet.thread) { + throw new Error("could not get thread timeline: not a thread timeline"); + } + const path = utils.encodeUri("/rooms/$roomId/context/$eventId", { + $roomId: timelineSet.room.roomId, + $eventId: eventId + }); + const params = { + limit: "0" + }; + if (this.clientOpts?.lazyLoadMembers) { + params.filter = JSON.stringify(_filter.Filter.LAZY_LOADING_MESSAGES_FILTER); + } + + // TODO: we should implement a backoff (as per scrollback()) to deal more nicely with HTTP errors. + const res = await this.http.authedRequest(_httpApi.Method.Get, path, params); + const mapper = this.getEventMapper(); + const event = mapper(res.event); + if (!timelineSet.canContain(event)) { + return undefined; + } + const recurse = this.canSupport.get(_feature.Feature.RelationsRecursion) !== _feature.ServerSupport.Unsupported; + if (_thread.Thread.hasServerSideSupport) { + if (_thread.Thread.hasServerSideFwdPaginationSupport) { + if (!timelineSet.thread) { + throw new Error("could not get thread timeline: not a thread timeline"); + } + const thread = timelineSet.thread; + const resOlder = await this.fetchRelations(timelineSet.room.roomId, thread.id, _thread.THREAD_RELATION_TYPE.name, null, { + dir: _eventTimeline.Direction.Backward, + from: res.start, + recurse: recurse || undefined + }); + const resNewer = await this.fetchRelations(timelineSet.room.roomId, thread.id, _thread.THREAD_RELATION_TYPE.name, null, { + dir: _eventTimeline.Direction.Forward, + from: res.end, + recurse: recurse || undefined + }); + const events = [ + // Order events from most recent to oldest (reverse-chronological). + // We start with the last event, since that's the point at which we have known state. + // events_after is already backwards; events_before is forwards. + ...resNewer.chunk.reverse().map(mapper), event, ...resOlder.chunk.map(mapper)]; + for (const event of events) { + await timelineSet.thread?.processEvent(event); + } + + // Here we handle non-thread timelines only, but still process any thread events to populate thread summaries. + let timeline = timelineSet.getTimelineForEvent(event.getId()); + if (timeline) { + timeline.getState(_eventTimeline.EventTimeline.BACKWARDS).setUnknownStateEvents(res.state.map(mapper)); + } else { + timeline = timelineSet.addTimeline(); + timeline.initialiseState(res.state.map(mapper)); + } + timelineSet.addEventsToTimeline(events, true, timeline, resNewer.next_batch); + if (!resOlder.next_batch) { + const originalEvent = await this.fetchRoomEvent(timelineSet.room.roomId, thread.id); + timelineSet.addEventsToTimeline([mapper(originalEvent)], true, timeline, null); + } + timeline.setPaginationToken(resOlder.next_batch ?? null, _eventTimeline.Direction.Backward); + timeline.setPaginationToken(resNewer.next_batch ?? null, _eventTimeline.Direction.Forward); + this.processAggregatedTimelineEvents(timelineSet.room, events); + + // There is no guarantee that the event ended up in "timeline" (we might have switched to a neighbouring + // timeline) - so check the room's index again. On the other hand, there's no guarantee the event ended up + // anywhere, if it was later redacted, so we just return the timeline we first thought of. + return timelineSet.getTimelineForEvent(eventId) ?? timeline; + } else { + // Where the event is a thread reply (not a root) and running in MSC-enabled mode the Thread timeline only + // functions contiguously, so we have to jump through some hoops to get our target event in it. + // XXX: workaround for https://github.com/vector-im/element-meta/issues/150 + + const thread = timelineSet.thread; + const resOlder = await this.fetchRelations(timelineSet.room.roomId, thread.id, _thread.THREAD_RELATION_TYPE.name, null, { + dir: _eventTimeline.Direction.Backward, + from: res.start, + recurse: recurse || undefined + }); + const eventsNewer = []; + let nextBatch = res.end; + while (nextBatch) { + const resNewer = await this.fetchRelations(timelineSet.room.roomId, thread.id, _thread.THREAD_RELATION_TYPE.name, null, { + dir: _eventTimeline.Direction.Forward, + from: nextBatch, + recurse: recurse || undefined + }); + nextBatch = resNewer.next_batch ?? null; + eventsNewer.push(...resNewer.chunk); + } + const events = [ + // Order events from most recent to oldest (reverse-chronological). + // We start with the last event, since that's the point at which we have known state. + // events_after is already backwards; events_before is forwards. + ...eventsNewer.reverse().map(mapper), event, ...resOlder.chunk.map(mapper)]; + for (const event of events) { + await timelineSet.thread?.processEvent(event); + } + + // Here we handle non-thread timelines only, but still process any thread events to populate thread + // summaries. + const timeline = timelineSet.getLiveTimeline(); + timeline.getState(_eventTimeline.EventTimeline.BACKWARDS).setUnknownStateEvents(res.state.map(mapper)); + timelineSet.addEventsToTimeline(events, true, timeline, null); + if (!resOlder.next_batch) { + const originalEvent = await this.fetchRoomEvent(timelineSet.room.roomId, thread.id); + timelineSet.addEventsToTimeline([mapper(originalEvent)], true, timeline, null); + } + timeline.setPaginationToken(resOlder.next_batch ?? null, _eventTimeline.Direction.Backward); + timeline.setPaginationToken(null, _eventTimeline.Direction.Forward); + this.processAggregatedTimelineEvents(timelineSet.room, events); + return timeline; + } + } + } + + /** + * Get an EventTimeline for the latest events in the room. This will just + * call `/messages` to get the latest message in the room, then use + * `client.getEventTimeline(...)` to construct a new timeline from it. + * + * @param timelineSet - The timelineSet to find or add the timeline to + * + * @returns Promise which resolves: + * {@link EventTimeline} timeline with the latest events in the room + */ + async getLatestTimeline(timelineSet) { + // don't allow any timeline support unless it's been enabled. + if (!this.timelineSupport) { + throw new Error("timeline support is disabled. Set the 'timelineSupport'" + " parameter to true when creating MatrixClient to enable it."); + } + if (!timelineSet.room) { + throw new Error("getLatestTimeline only supports room timelines"); + } + let event; + if (timelineSet.threadListType !== null) { + const res = await this.createThreadListMessagesRequest(timelineSet.room.roomId, null, 1, _eventTimeline.Direction.Backward, timelineSet.threadListType, timelineSet.getFilter()); + event = res.chunk?.[0]; + } else if (timelineSet.thread && _thread.Thread.hasServerSideSupport) { + const recurse = this.canSupport.get(_feature.Feature.RelationsRecursion) !== _feature.ServerSupport.Unsupported; + const res = await this.fetchRelations(timelineSet.room.roomId, timelineSet.thread.id, _thread.THREAD_RELATION_TYPE.name, null, { + dir: _eventTimeline.Direction.Backward, + limit: 1, + recurse: recurse || undefined + }); + event = res.chunk?.[0]; + } else { + const messagesPath = utils.encodeUri("/rooms/$roomId/messages", { + $roomId: timelineSet.room.roomId + }); + const params = { + dir: "b" + }; + if (this.clientOpts?.lazyLoadMembers) { + params.filter = JSON.stringify(_filter.Filter.LAZY_LOADING_MESSAGES_FILTER); + } + const res = await this.http.authedRequest(_httpApi.Method.Get, messagesPath, params); + event = res.chunk?.[0]; + } + if (!event) { + throw new Error("No message returned when trying to construct getLatestTimeline"); + } + return this.getEventTimeline(timelineSet, event.event_id); + } + + /** + * 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 limit - the maximum amount of events the retrieve + * @param dir - 'f' or 'b' + * @param timelineFilter - the timeline filter to pass + */ + // XXX: Intended private, used in code. + createMessagesRequest(roomId, fromToken, limit = 30, dir, timelineFilter) { + const path = utils.encodeUri("/rooms/$roomId/messages", { + $roomId: roomId + }); + const params = { + limit: limit.toString(), + dir: dir + }; + if (fromToken) { + params.from = fromToken; + } + 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.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()?.toJSON()); + } + if (filter) { + params.filter = JSON.stringify(filter); + } + return this.http.authedRequest(_httpApi.Method.Get, path, params); + } + + /** + * 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 limit - the maximum amount of events the retrieve + * @param dir - 'f' or 'b' + * @param timelineFilter - the timeline filter to pass + */ + // XXX: Intended private, used by room.fetchRoomThreads + createThreadListMessagesRequest(roomId, fromToken, limit = 30, dir = _eventTimeline.Direction.Backward, threadListType = _thread.ThreadFilterType.All, timelineFilter) { + const path = utils.encodeUri("/rooms/$roomId/threads", { + $roomId: roomId + }); + const params = { + limit: limit.toString(), + dir: dir, + include: (0, _thread.threadFilterTypeToFilter)(threadListType) + }; + if (fromToken) { + params.from = fromToken; + } + let filter = {}; + if (this.clientOpts?.lazyLoadMembers) { + // create a shallow copy of LAZY_LOADING_MESSAGES_FILTER, + // so the timelineFilter doesn't get written into it below + filter = _objectSpread({}, _filter.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 = _objectSpread(_objectSpread({}, filter), timelineFilter.getRoomTimelineFilterComponent()?.toJSON()); + } + if (Object.keys(filter).length) { + params.filter = JSON.stringify(filter); + } + const opts = { + prefix: _thread.Thread.hasServerSideListSupport === _thread.FeatureSupport.Stable ? "/_matrix/client/v1" : "/_matrix/client/unstable/org.matrix.msc3856" + }; + return this.http.authedRequest(_httpApi.Method.Get, path, params, undefined, opts).then(res => _objectSpread(_objectSpread({}, res), {}, { + chunk: res.chunk?.reverse(), + start: res.prev_batch, + end: res.next_batch + })); + } + + /** + * Take an EventTimeline, and back/forward-fill results. + * + * @param eventTimeline - timeline object to be updated + * + * @returns Promise which resolves to a boolean: false if there are no + * events and we reached either end of the timeline; else true. + */ + paginateEventTimeline(eventTimeline, opts) { + const isNotifTimeline = eventTimeline.getTimelineSet() === this.notifTimelineSet; + const room = this.getRoom(eventTimeline.getRoomId()); + const threadListType = eventTimeline.getTimelineSet().threadListType; + const thread = eventTimeline.getTimelineSet().thread; + + // TODO: we should implement a backoff (as per scrollback()) to deal more + // nicely with HTTP errors. + opts = opts || {}; + const backwards = opts.backwards || false; + if (isNotifTimeline) { + if (!backwards) { + throw new Error("paginateNotifTimeline can only paginate backwards"); + } + } + const dir = backwards ? _eventTimeline.EventTimeline.BACKWARDS : _eventTimeline.EventTimeline.FORWARDS; + const token = eventTimeline.getPaginationToken(dir); + const pendingRequest = eventTimeline.paginationRequests[dir]; + if (pendingRequest) { + // already a request in progress - return the existing promise + return pendingRequest; + } + let path; + let params; + let promise; + if (isNotifTimeline) { + path = "/notifications"; + params = { + limit: (opts.limit ?? 30).toString(), + only: "highlight" + }; + if (token && token !== "end") { + params.from = token; + } + promise = this.http.authedRequest(_httpApi.Method.Get, path, params).then(async res => { + const token = res.next_token; + const matrixEvents = []; + res.notifications = res.notifications.filter(utils.noUnsafeEventProps); + for (let i = 0; i < res.notifications.length; i++) { + const notification = res.notifications[i]; + const event = this.getEventMapper()(notification.event); + + // @TODO(kerrya) reprocessing every notification is ugly + // remove if we get server MSC3994 support + this.getPushDetailsForEvent(event, true); + event.event.room_id = notification.room_id; // XXX: gutwrenching + matrixEvents[i] = event; + } + + // No need to partition events for threads here, everything lives + // in the notification timeline set + const timelineSet = eventTimeline.getTimelineSet(); + timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, token); + this.processAggregatedTimelineEvents(timelineSet.room, matrixEvents); + + // if we've hit the end of the timeline, we need to stop trying to + // paginate. We need to keep the 'forwards' token though, to make sure + // we can recover from gappy syncs. + if (backwards && !res.next_token) { + eventTimeline.setPaginationToken(null, dir); + } + return Boolean(res.next_token); + }).finally(() => { + eventTimeline.paginationRequests[dir] = null; + }); + eventTimeline.paginationRequests[dir] = promise; + } else if (threadListType !== null) { + if (!room) { + throw new Error("Unknown room " + eventTimeline.getRoomId()); + } + if (!_thread.Thread.hasServerSideFwdPaginationSupport && dir === _eventTimeline.Direction.Forward) { + throw new Error("Cannot paginate threads forwards without server-side support for MSC 3715"); + } + promise = this.createThreadListMessagesRequest(eventTimeline.getRoomId(), token, opts.limit, dir, threadListType, eventTimeline.getFilter()).then(res => { + if (res.state) { + const roomState = eventTimeline.getState(dir); + const stateEvents = res.state.filter(utils.noUnsafeEventProps).map(this.getEventMapper()); + roomState.setUnknownStateEvents(stateEvents); + } + const token = res.end; + const matrixEvents = res.chunk.filter(utils.noUnsafeEventProps).map(this.getEventMapper()); + const timelineSet = eventTimeline.getTimelineSet(); + timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, token); + this.processAggregatedTimelineEvents(room, matrixEvents); + this.processThreadRoots(room, matrixEvents, backwards); + + // if we've hit the end of the timeline, we need to stop trying to + // paginate. We need to keep the 'forwards' token though, to make sure + // we can recover from gappy syncs. + if (backwards && res.end == res.start) { + eventTimeline.setPaginationToken(null, dir); + } + return res.end !== res.start; + }).finally(() => { + eventTimeline.paginationRequests[dir] = null; + }); + eventTimeline.paginationRequests[dir] = promise; + } else if (thread) { + const room = this.getRoom(eventTimeline.getRoomId() ?? undefined); + if (!room) { + throw new Error("Unknown room " + eventTimeline.getRoomId()); + } + const recurse = this.canSupport.get(_feature.Feature.RelationsRecursion) !== _feature.ServerSupport.Unsupported; + promise = this.fetchRelations(eventTimeline.getRoomId() ?? "", thread.id, _thread.THREAD_RELATION_TYPE.name, null, { + dir, + limit: opts.limit, + from: token ?? undefined, + recurse: recurse || undefined + }).then(async res => { + const mapper = this.getEventMapper(); + const matrixEvents = res.chunk.filter(utils.noUnsafeEventProps).map(mapper); + + // Process latest events first + for (const event of matrixEvents.slice().reverse()) { + await thread?.processEvent(event); + const sender = event.getSender(); + if (!backwards || thread?.getEventReadUpTo(sender) === null) { + room.addLocalEchoReceipt(sender, event, _read_receipts.ReceiptType.Read); + } + } + const newToken = res.next_batch; + const timelineSet = eventTimeline.getTimelineSet(); + timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, newToken ?? null); + if (!newToken && backwards) { + const originalEvent = await this.fetchRoomEvent(eventTimeline.getRoomId() ?? "", thread.id); + timelineSet.addEventsToTimeline([mapper(originalEvent)], true, eventTimeline, null); + } + this.processAggregatedTimelineEvents(timelineSet.room, matrixEvents); + + // if we've hit the end of the timeline, we need to stop trying to + // paginate. We need to keep the 'forwards' token though, to make sure + // we can recover from gappy syncs. + if (backwards && !newToken) { + eventTimeline.setPaginationToken(null, dir); + } + return Boolean(newToken); + }).finally(() => { + eventTimeline.paginationRequests[dir] = null; + }); + eventTimeline.paginationRequests[dir] = promise; + } else { + if (!room) { + throw new Error("Unknown room " + eventTimeline.getRoomId()); + } + promise = this.createMessagesRequest(eventTimeline.getRoomId(), token, opts.limit, dir, eventTimeline.getFilter()).then(res => { + if (res.state) { + const roomState = eventTimeline.getState(dir); + const stateEvents = res.state.filter(utils.noUnsafeEventProps).map(this.getEventMapper()); + roomState.setUnknownStateEvents(stateEvents); + } + const token = res.end; + const matrixEvents = res.chunk.filter(utils.noUnsafeEventProps).map(this.getEventMapper()); + const timelineSet = eventTimeline.getTimelineSet(); + const [timelineEvents,, unknownRelations] = room.partitionThreadedEvents(matrixEvents); + timelineSet.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token); + this.processAggregatedTimelineEvents(room, timelineEvents); + this.processThreadRoots(room, timelineEvents.filter(it => it.getServerAggregatedRelation(_thread.THREAD_RELATION_TYPE.name)), false); + unknownRelations.forEach(event => room.relations.aggregateChildEvent(event)); + const atEnd = res.end === undefined || res.end === res.start; + + // if we've hit the end of the timeline, we need to stop trying to + // paginate. We need to keep the 'forwards' token though, to make sure + // we can recover from gappy syncs. + if (backwards && atEnd) { + eventTimeline.setPaginationToken(null, dir); + } + return !atEnd; + }).finally(() => { + eventTimeline.paginationRequests[dir] = null; + }); + eventTimeline.paginationRequests[dir] = promise; + } + return promise; + } + + /** + * Reset the notifTimelineSet entirely, paginating in some historical notifs as + * a starting point for subsequent pagination. + */ + resetNotifTimelineSet() { + if (!this.notifTimelineSet) { + return; + } + + // FIXME: This thing is a total hack, and results in duplicate events being + // added to the timeline both from /sync and /notifications, and lots of + // slow and wasteful processing and pagination. The correct solution is to + // extend /messages or /search or something to filter on notifications. + + // use the fictitious token 'end'. in practice we would ideally give it + // the oldest backwards pagination token from /sync, but /sync doesn't + // know about /notifications, so we have no choice but to start paginating + // from the current point in time. This may well overlap with historical + // notifs which are then inserted into the timeline by /sync responses. + this.notifTimelineSet.resetLiveTimeline("end"); + + // we could try to paginate a single event at this point in order to get + // a more valid pagination token, but it just ends up with an out of order + // timeline. given what a mess this is and given we're going to have duplicate + // events anyway, just leave it with the dummy token for now. + /* + this.paginateNotifTimeline(this._notifTimelineSet.getLiveTimeline(), { + backwards: true, + limit: 1 + }); + */ + } + + /** + * Peek into a room and receive updates about the room. This only works if the + * history visibility for the room is world_readable. + * @param roomId - The room to attempt to peek into. + * @returns Promise which resolves: Room object + * @returns Rejects: with an error response. + */ + peekInRoom(roomId) { + this.peekSync?.stopPeeking(); + this.peekSync = new _sync.SyncApi(this, this.clientOpts, this.buildSyncApiOptions()); + return this.peekSync.peek(roomId); + } + + /** + * Stop any ongoing room peeking. + */ + stopPeeking() { + if (this.peekSync) { + this.peekSync.stopPeeking(); + this.peekSync = null; + } + } + + /** + * Set r/w flags for guest access in a room. + * @param roomId - The room to configure guest access in. + * @param opts - Options + * @returns Promise which resolves + * @returns Rejects: with an error response. + */ + setGuestAccess(roomId, opts) { + const writePromise = this.sendStateEvent(roomId, _event2.EventType.RoomGuestAccess, { + guest_access: opts.allowJoin ? "can_join" : "forbidden" + }, ""); + let readPromise = Promise.resolve(undefined); + if (opts.allowRead) { + readPromise = this.sendStateEvent(roomId, _event2.EventType.RoomHistoryVisibility, { + history_visibility: "world_readable" + }, ""); + } + return Promise.all([readPromise, writePromise]).then(); // .then() to hide results for contract + } + + /** + * Requests an email verification token for the purposes of registration. + * This API requests a token from the homeserver. + * The doesServerRequireIdServerParam() method can be used to determine if + * the server requires the id_server parameter to be provided. + * + * Parameters and return value are as for requestEmailToken + * @param email - As requestEmailToken + * @param clientSecret - As requestEmailToken + * @param sendAttempt - As requestEmailToken + * @param nextLink - As requestEmailToken + * @returns Promise which resolves: As requestEmailToken + */ + requestRegisterEmailToken(email, clientSecret, sendAttempt, nextLink) { + return this.requestTokenFromEndpoint("/register/email/requestToken", { + email: email, + client_secret: clientSecret, + send_attempt: sendAttempt, + next_link: nextLink + }); + } + + /** + * Requests a text message verification token for the purposes of registration. + * This API requests a token from the homeserver. + * The doesServerRequireIdServerParam() method can be used to determine if + * the server requires the id_server parameter to be provided. + * + * @param phoneCountry - The ISO 3166-1 alpha-2 code for the country in which + * phoneNumber should be parsed relative to. + * @param phoneNumber - The phone number, in national or international format + * @param clientSecret - As requestEmailToken + * @param sendAttempt - As requestEmailToken + * @param nextLink - As requestEmailToken + * @returns Promise which resolves: As requestEmailToken + */ + requestRegisterMsisdnToken(phoneCountry, phoneNumber, clientSecret, sendAttempt, nextLink) { + return this.requestTokenFromEndpoint("/register/msisdn/requestToken", { + country: phoneCountry, + phone_number: phoneNumber, + client_secret: clientSecret, + send_attempt: sendAttempt, + next_link: nextLink + }); + } + + /** + * Requests an email verification token for the purposes of adding a + * third party identifier to an account. + * This API requests a token from the homeserver. + * The doesServerRequireIdServerParam() method can be used to determine if + * the server requires the id_server parameter to be provided. + * If an account with the given email address already exists and is + * associated with an account other than the one the user is authed as, + * it will either send an email to the address informing them of this + * or return M_THREEPID_IN_USE (which one is up to the homeserver). + * + * @param email - As requestEmailToken + * @param clientSecret - As requestEmailToken + * @param sendAttempt - As requestEmailToken + * @param nextLink - As requestEmailToken + * @returns Promise which resolves: As requestEmailToken + */ + requestAdd3pidEmailToken(email, clientSecret, sendAttempt, nextLink) { + return this.requestTokenFromEndpoint("/account/3pid/email/requestToken", { + email: email, + client_secret: clientSecret, + send_attempt: sendAttempt, + next_link: nextLink + }); + } + + /** + * Requests a text message verification token for the purposes of adding a + * third party identifier to an account. + * This API proxies the identity server /validate/email/requestToken API, + * adding specific behaviour for the addition of phone numbers to an + * account, as requestAdd3pidEmailToken. + * + * @param phoneCountry - As requestRegisterMsisdnToken + * @param phoneNumber - As requestRegisterMsisdnToken + * @param clientSecret - As requestEmailToken + * @param sendAttempt - As requestEmailToken + * @param nextLink - As requestEmailToken + * @returns Promise which resolves: As requestEmailToken + */ + requestAdd3pidMsisdnToken(phoneCountry, phoneNumber, clientSecret, sendAttempt, nextLink) { + return this.requestTokenFromEndpoint("/account/3pid/msisdn/requestToken", { + country: phoneCountry, + phone_number: phoneNumber, + client_secret: clientSecret, + send_attempt: sendAttempt, + next_link: nextLink + }); + } + + /** + * Requests an email verification token for the purposes of resetting + * the password on an account. + * This API proxies the identity server /validate/email/requestToken API, + * adding specific behaviour for the password resetting. Specifically, + * if no account with the given email address exists, it may either + * return M_THREEPID_NOT_FOUND or send an email + * to the address informing them of this (which one is up to the homeserver). + * + * requestEmailToken calls the equivalent API directly on the identity server, + * therefore bypassing the password reset specific logic. + * + * @param email - As requestEmailToken + * @param clientSecret - As requestEmailToken + * @param sendAttempt - As requestEmailToken + * @param nextLink - As requestEmailToken + * @returns Promise which resolves: As requestEmailToken + */ + requestPasswordEmailToken(email, clientSecret, sendAttempt, nextLink) { + return this.requestTokenFromEndpoint("/account/password/email/requestToken", { + email: email, + client_secret: clientSecret, + send_attempt: sendAttempt, + next_link: nextLink + }); + } + + /** + * Requests a text message verification token for the purposes of resetting + * the password on an account. + * This API proxies the identity server /validate/email/requestToken API, + * adding specific behaviour for the password resetting, as requestPasswordEmailToken. + * + * @param phoneCountry - As requestRegisterMsisdnToken + * @param phoneNumber - As requestRegisterMsisdnToken + * @param clientSecret - As requestEmailToken + * @param sendAttempt - As requestEmailToken + * @param nextLink - As requestEmailToken + * @returns Promise which resolves: As requestEmailToken + */ + requestPasswordMsisdnToken(phoneCountry, phoneNumber, clientSecret, sendAttempt, nextLink) { + return this.requestTokenFromEndpoint("/account/password/msisdn/requestToken", { + country: phoneCountry, + phone_number: phoneNumber, + client_secret: clientSecret, + send_attempt: sendAttempt, + next_link: nextLink + }); + } + + /** + * Internal utility function for requesting validation tokens from usage-specific + * requestToken endpoints. + * + * @param endpoint - The endpoint to send the request to + * @param params - Parameters for the POST request + * @returns Promise which resolves: As requestEmailToken + */ + async requestTokenFromEndpoint(endpoint, params) { + const postParams = Object.assign({}, params); + + // If the HS supports separate add and bind, then requestToken endpoints + // don't need an IS as they are all validated by the HS directly. + if (!(await this.doesServerSupportSeparateAddAndBind()) && this.idBaseUrl) { + const idServerUrl = new URL(this.idBaseUrl); + postParams.id_server = idServerUrl.host; + if (this.identityServer?.getAccessToken && (await this.doesServerAcceptIdentityAccessToken())) { + const identityAccessToken = await this.identityServer.getAccessToken(); + if (identityAccessToken) { + postParams.id_access_token = identityAccessToken; + } + } + } + return this.http.request(_httpApi.Method.Post, endpoint, undefined, postParams); + } + + /** + * Get the room-kind push rule associated with a room. + * @param scope - "global" or device-specific. + * @param roomId - the id of the room. + * @returns the rule or undefined. + */ + getRoomPushRule(scope, roomId) { + // There can be only room-kind push rule per room + // and its id is the room id. + if (this.pushRules) { + return this.pushRules[scope]?.room?.find(rule => rule.rule_id === roomId); + } else { + throw new Error("SyncApi.sync() must be done before accessing to push rules."); + } + } + + /** + * Set a room-kind muting push rule in a room. + * The operation also updates MatrixClient.pushRules at the end. + * @param scope - "global" or device-specific. + * @param roomId - the id of the room. + * @param mute - the mute state. + * @returns Promise which resolves: result object + * @returns Rejects: with an error response. + */ + setRoomMutePushRule(scope, roomId, mute) { + let promise; + let hasDontNotifyRule = false; + + // Get the existing room-kind push rule if any + const roomPushRule = this.getRoomPushRule(scope, roomId); + if (roomPushRule?.actions.includes(_PushRules.PushRuleActionName.DontNotify)) { + hasDontNotifyRule = true; + } + if (!mute) { + // Remove the rule only if it is a muting rule + if (hasDontNotifyRule) { + promise = this.deletePushRule(scope, _PushRules.PushRuleKind.RoomSpecific, roomPushRule.rule_id); + } + } else { + if (!roomPushRule) { + promise = this.addPushRule(scope, _PushRules.PushRuleKind.RoomSpecific, roomId, { + actions: [_PushRules.PushRuleActionName.DontNotify] + }); + } else if (!hasDontNotifyRule) { + // Remove the existing one before setting the mute push rule + // This is a workaround to SYN-590 (Push rule update fails) + const deferred = utils.defer(); + this.deletePushRule(scope, _PushRules.PushRuleKind.RoomSpecific, roomPushRule.rule_id).then(() => { + this.addPushRule(scope, _PushRules.PushRuleKind.RoomSpecific, roomId, { + actions: [_PushRules.PushRuleActionName.DontNotify] + }).then(() => { + deferred.resolve(); + }).catch(err => { + deferred.reject(err); + }); + }).catch(err => { + deferred.reject(err); + }); + promise = deferred.promise; + } + } + if (promise) { + return new Promise((resolve, reject) => { + // Update this.pushRules when the operation completes + promise.then(() => { + this.getPushRules().then(result => { + this.pushRules = result; + resolve(); + }).catch(err => { + reject(err); + }); + }).catch(err => { + // Update it even if the previous operation fails. This can help the + // app to recover when push settings has been modified from another client + this.getPushRules().then(result => { + this.pushRules = result; + reject(err); + }).catch(err2 => { + reject(err); + }); + }); + }); + } + } + searchMessageText(opts) { + const roomEvents = { + search_term: opts.query + }; + if ("keys" in opts) { + roomEvents.keys = opts.keys; + } + return this.search({ + body: { + search_categories: { + room_events: roomEvents + } + } + }); + } + + /** + * Perform a server-side search for room events. + * + * The returned promise resolves to an object containing the fields: + * + * * count: estimate of the number of results + * * next_batch: token for back-pagination; if undefined, there are no more results + * * highlights: a list of words to highlight from the stemming algorithm + * * results: a list of results + * + * Each entry in the results list is a SearchResult. + * + * @returns Promise which resolves: result object + * @returns Rejects: with an error response. + */ + searchRoomEvents(opts) { + // TODO: support search groups + + const body = { + search_categories: { + room_events: { + search_term: opts.term, + filter: opts.filter, + order_by: _search.SearchOrderBy.Recent, + event_context: { + before_limit: 1, + after_limit: 1, + include_profile: true + } + } + } + }; + const searchResults = { + _query: body, + results: [], + highlights: [] + }; + return this.search({ + body: body + }).then(res => this.processRoomEventsSearch(searchResults, res)); + } + + /** + * Take a result from an earlier searchRoomEvents call, and backfill results. + * + * @param searchResults - the results object to be updated + * @returns Promise which resolves: updated result object + * @returns Rejects: with an error response. + */ + backPaginateRoomEventsSearch(searchResults) { + // TODO: we should implement a backoff (as per scrollback()) to deal more + // nicely with HTTP errors. + + if (!searchResults.next_batch) { + return Promise.reject(new Error("Cannot backpaginate event search any further")); + } + if (searchResults.pendingRequest) { + // already a request in progress - return the existing promise + return searchResults.pendingRequest; + } + const searchOpts = { + body: searchResults._query, + next_batch: searchResults.next_batch + }; + const promise = this.search(searchOpts, searchResults.abortSignal).then(res => this.processRoomEventsSearch(searchResults, res)).finally(() => { + searchResults.pendingRequest = undefined; + }); + searchResults.pendingRequest = promise; + return promise; + } + + /** + * helper for searchRoomEvents and backPaginateRoomEventsSearch. Processes the + * response from the API call and updates the searchResults + * + * @returns searchResults + * @internal + */ + // XXX: Intended private, used in code + processRoomEventsSearch(searchResults, response) { + const roomEvents = response.search_categories.room_events; + searchResults.count = roomEvents.count; + searchResults.next_batch = roomEvents.next_batch; + + // combine the highlight list with our existing list; + const highlights = new Set(roomEvents.highlights); + searchResults.highlights.forEach(hl => { + highlights.add(hl); + }); + + // turn it back into a list. + searchResults.highlights = Array.from(highlights); + const mapper = this.getEventMapper(); + + // append the new results to our existing results + const resultsLength = roomEvents.results?.length ?? 0; + for (let i = 0; i < resultsLength; i++) { + const sr = _searchResult.SearchResult.fromJson(roomEvents.results[i], mapper); + const room = this.getRoom(sr.context.getEvent().getRoomId()); + if (room) { + // Copy over a known event sender if we can + for (const ev of sr.context.getTimeline()) { + const sender = room.getMember(ev.getSender()); + if (!ev.sender && sender) ev.sender = sender; + } + } + searchResults.results.push(sr); + } + return searchResults; + } + + /** + * Populate the store with rooms the user has left. + * @returns Promise which resolves: TODO - Resolved when the rooms have + * been added to the data store. + * @returns Rejects: with an error response. + */ + syncLeftRooms() { + // Guard against multiple calls whilst ongoing and multiple calls post success + if (this.syncedLeftRooms) { + return Promise.resolve([]); // don't call syncRooms again if it succeeded. + } + + if (this.syncLeftRoomsPromise) { + return this.syncLeftRoomsPromise; // return the ongoing request + } + + const syncApi = new _sync.SyncApi(this, this.clientOpts, this.buildSyncApiOptions()); + this.syncLeftRoomsPromise = syncApi.syncLeftRooms(); + + // cleanup locks + this.syncLeftRoomsPromise.then(() => { + _logger.logger.log("Marking success of sync left room request"); + this.syncedLeftRooms = true; // flip the bit on success + }).finally(() => { + this.syncLeftRoomsPromise = undefined; // cleanup ongoing request state + }); + + return this.syncLeftRoomsPromise; + } + + /** + * Create a new filter. + * @param content - The HTTP body for the request + * @returns Promise which resolves to a Filter object. + * @returns Rejects: with an error response. + */ + createFilter(content) { + const path = utils.encodeUri("/user/$userId/filter", { + $userId: this.credentials.userId + }); + return this.http.authedRequest(_httpApi.Method.Post, path, undefined, content).then(response => { + // persist the filter + const filter = _filter.Filter.fromJson(this.credentials.userId, response.filter_id, content); + this.store.storeFilter(filter); + return filter; + }); + } + + /** + * Retrieve a filter. + * @param userId - The user ID of the filter owner + * @param filterId - The filter ID to retrieve + * @param allowCached - True to allow cached filters to be returned. + * Default: True. + * @returns Promise which resolves: a Filter object + * @returns Rejects: with an error response. + */ + getFilter(userId, filterId, allowCached) { + if (allowCached) { + const filter = this.store.getFilter(userId, filterId); + if (filter) { + return Promise.resolve(filter); + } + } + const path = utils.encodeUri("/user/$userId/filter/$filterId", { + $userId: userId, + $filterId: filterId + }); + return this.http.authedRequest(_httpApi.Method.Get, path).then(response => { + // persist the filter + const filter = _filter.Filter.fromJson(userId, filterId, response); + this.store.storeFilter(filter); + return filter; + }); + } + + /** + * @returns Filter ID + */ + async getOrCreateFilter(filterName, filter) { + const filterId = this.store.getFilterIdByName(filterName); + let existingId; + if (filterId) { + // check that the existing filter matches our expectations + try { + const existingFilter = await this.getFilter(this.credentials.userId, filterId, true); + if (existingFilter) { + const oldDef = existingFilter.getDefinition(); + const newDef = filter.getDefinition(); + if (utils.deepCompare(oldDef, newDef)) { + // super, just use that. + // debuglog("Using existing filter ID %s: %s", filterId, + // JSON.stringify(oldDef)); + existingId = filterId; + } + } + } catch (error) { + // Synapse currently returns the following when the filter cannot be found: + // { + // errcode: "M_UNKNOWN", + // name: "M_UNKNOWN", + // message: "No row found", + // } + if (error.errcode !== "M_UNKNOWN" && error.errcode !== "M_NOT_FOUND") { + throw error; + } + } + // if the filter doesn't exist anymore on the server, remove from store + if (!existingId) { + this.store.setFilterIdByName(filterName, undefined); + } + } + if (existingId) { + return existingId; + } + + // create a new filter + const createdFilter = await this.createFilter(filter.getDefinition()); + this.store.setFilterIdByName(filterName, createdFilter.filterId); + return createdFilter.filterId; + } + + /** + * Gets a bearer token from the homeserver that the user can + * present to a third party in order to prove their ownership + * of the Matrix account they are logged into. + * @returns Promise which resolves: Token object + * @returns Rejects: with an error response. + */ + getOpenIdToken() { + const path = utils.encodeUri("/user/$userId/openid/request_token", { + $userId: this.credentials.userId + }); + return this.http.authedRequest(_httpApi.Method.Post, path, undefined, {}); + } + /** + * @returns Promise which resolves: ITurnServerResponse object + * @returns Rejects: with an error response. + */ + turnServer() { + return this.http.authedRequest(_httpApi.Method.Get, "/voip/turnServer"); + } + + /** + * Get the TURN servers for this homeserver. + * @returns The servers or an empty list. + */ + getTurnServers() { + return this.turnServers || []; + } + + /** + * Get the unix timestamp (in milliseconds) at which the current + * TURN credentials (from getTurnServers) expire + * @returns The expiry timestamp in milliseconds + */ + getTurnServersExpiry() { + return this.turnServersExpiry; + } + get pollingTurnServers() { + return this.checkTurnServersIntervalID !== undefined; + } + + // XXX: Intended private, used in code. + async checkTurnServers() { + if (!this.canSupportVoip) { + return; + } + let credentialsGood = false; + const remainingTime = this.turnServersExpiry - Date.now(); + if (remainingTime > TURN_CHECK_INTERVAL) { + _logger.logger.debug("TURN creds are valid for another " + remainingTime + " ms: not fetching new ones."); + credentialsGood = true; + } else { + _logger.logger.debug("Fetching new TURN credentials"); + try { + const res = await this.turnServer(); + if (res.uris) { + _logger.logger.log("Got TURN URIs: " + res.uris + " refresh in " + res.ttl + " secs"); + // map the response to a format that can be fed to RTCPeerConnection + const servers = { + urls: res.uris, + username: res.username, + credential: res.password + }; + this.turnServers = [servers]; + // The TTL is in seconds but we work in ms + this.turnServersExpiry = Date.now() + res.ttl * 1000; + credentialsGood = true; + this.emit(ClientEvent.TurnServers, this.turnServers); + } + } catch (err) { + _logger.logger.error("Failed to get TURN URIs", err); + if (err.httpStatus === 403) { + // We got a 403, so there's no point in looping forever. + _logger.logger.info("TURN access unavailable for this account: stopping credentials checks"); + if (this.checkTurnServersIntervalID !== null) global.clearInterval(this.checkTurnServersIntervalID); + this.checkTurnServersIntervalID = undefined; + this.emit(ClientEvent.TurnServersError, err, true); // fatal + } else { + // otherwise, if we failed for whatever reason, try again the next time we're called. + this.emit(ClientEvent.TurnServersError, err, false); // non-fatal + } + } + } + + return credentialsGood; + } + + /** + * Set whether to allow a fallback ICE server should be used for negotiating a + * WebRTC connection if the homeserver doesn't provide any servers. Defaults to + * false. + * + */ + setFallbackICEServerAllowed(allow) { + this.fallbackICEServerAllowed = allow; + } + + /** + * Get whether to allow a fallback ICE server should be used for negotiating a + * WebRTC connection if the homeserver doesn't provide any servers. Defaults to + * false. + * + * @returns + */ + isFallbackICEServerAllowed() { + return this.fallbackICEServerAllowed; + } + + /** + * Determines if the current user is an administrator of the Synapse homeserver. + * Returns false if untrue or the homeserver does not appear to be a Synapse + * homeserver. <strong>This function is implementation specific and may change + * as a result.</strong> + * @returns true if the user appears to be a Synapse administrator. + */ + isSynapseAdministrator() { + const path = utils.encodeUri("/_synapse/admin/v1/users/$userId/admin", { + $userId: this.getUserId() + }); + return this.http.authedRequest(_httpApi.Method.Get, path, undefined, undefined, { + prefix: "" + }).then(r => r.admin); // pull out the specific boolean we want + } + + /** + * Performs a whois lookup on a user using Synapse's administrator API. + * <strong>This function is implementation specific and may change as a + * result.</strong> + * @param userId - the User ID to look up. + * @returns the whois response - see Synapse docs for information. + */ + whoisSynapseUser(userId) { + const path = utils.encodeUri("/_synapse/admin/v1/whois/$userId", { + $userId: userId + }); + return this.http.authedRequest(_httpApi.Method.Get, path, undefined, undefined, { + prefix: "" + }); + } + + /** + * Deactivates a user using Synapse's administrator API. <strong>This + * function is implementation specific and may change as a result.</strong> + * @param userId - the User ID to deactivate. + * @returns the deactivate response - see Synapse docs for information. + */ + deactivateSynapseUser(userId) { + const path = utils.encodeUri("/_synapse/admin/v1/deactivate/$userId", { + $userId: userId + }); + return this.http.authedRequest(_httpApi.Method.Post, path, undefined, undefined, { + prefix: "" + }); + } + async fetchClientWellKnown() { + // `getRawClientConfig` does not throw or reject on network errors, instead + // it absorbs errors and returns `{}`. + this.clientWellKnownPromise = _autodiscovery.AutoDiscovery.getRawClientConfig(this.getDomain() ?? undefined); + this.clientWellKnown = await this.clientWellKnownPromise; + this.emit(ClientEvent.ClientWellKnown, this.clientWellKnown); + } + getClientWellKnown() { + return this.clientWellKnown; + } + waitForClientWellKnown() { + if (!this.clientRunning) { + throw new Error("Client is not running"); + } + return this.clientWellKnownPromise; + } + + /** + * store client options with boolean/string/numeric values + * to know in the next session what flags the sync data was + * created with (e.g. lazy loading) + * @param opts - the complete set of client options + * @returns for store operation + */ + storeClientOptions() { + // XXX: Intended private, used in code + const primTypes = ["boolean", "string", "number"]; + const serializableOpts = Object.entries(this.clientOpts).filter(([key, value]) => { + return primTypes.includes(typeof value); + }).reduce((obj, [key, value]) => { + obj[key] = value; + return obj; + }, {}); + return this.store.storeClientOptions(serializableOpts); + } + + /** + * Gets a set of room IDs in common with another user + * @param userId - The userId to check. + * @returns Promise which resolves to a set of rooms + * @returns Rejects: with an error response. + */ + // eslint-disable-next-line + async _unstable_getSharedRooms(userId) { + const sharedRoomsSupport = await this.doesServerSupportUnstableFeature("uk.half-shot.msc2666"); + const mutualRoomsSupport = await this.doesServerSupportUnstableFeature("uk.half-shot.msc2666.mutual_rooms"); + if (!sharedRoomsSupport && !mutualRoomsSupport) { + throw Error("Server does not support mutual_rooms API"); + } + const path = utils.encodeUri(`/uk.half-shot.msc2666/user/${mutualRoomsSupport ? "mutual_rooms" : "shared_rooms"}/$userId`, { + $userId: userId + }); + const res = await this.http.authedRequest(_httpApi.Method.Get, path, undefined, undefined, { + prefix: _httpApi.ClientPrefix.Unstable + }); + return res.joined; + } + + /** + * Get the API versions supported by the server, along with any + * unstable APIs it supports + * @returns The server /versions response + */ + async getVersions() { + if (this.serverVersionsPromise) { + return this.serverVersionsPromise; + } + this.serverVersionsPromise = this.http.request(_httpApi.Method.Get, "/_matrix/client/versions", undefined, + // queryParams + undefined, + // data + { + prefix: "" + }).catch(e => { + // Need to unset this if it fails, otherwise we'll never retry + this.serverVersionsPromise = undefined; + // but rethrow the exception to anything that was waiting + throw e; + }); + const serverVersions = await this.serverVersionsPromise; + this.canSupport = await (0, _feature.buildFeatureSupportMap)(serverVersions); + return this.serverVersionsPromise; + } + + /** + * Check if a particular spec version is supported by the server. + * @param version - The spec version (such as "r0.5.0") to check for. + * @returns Whether it is supported + */ + async isVersionSupported(version) { + const { + versions + } = await this.getVersions(); + return versions && versions.includes(version); + } + + /** + * Query the server to see if it supports members lazy loading + * @returns true if server supports lazy loading + */ + async doesServerSupportLazyLoading() { + const response = await this.getVersions(); + if (!response) return false; + const versions = response["versions"]; + const unstableFeatures = response["unstable_features"]; + return versions && versions.includes("r0.5.0") || unstableFeatures && unstableFeatures["m.lazy_load_members"]; + } + + /** + * Query the server to see if the `id_server` parameter is required + * when registering with an 3pid, adding a 3pid or resetting password. + * @returns true if id_server parameter is required + */ + async doesServerRequireIdServerParam() { + const response = await this.getVersions(); + if (!response) return true; + const versions = response["versions"]; + + // Supporting r0.6.0 is the same as having the flag set to false + if (versions && versions.includes("r0.6.0")) { + return false; + } + const unstableFeatures = response["unstable_features"]; + if (!unstableFeatures) return true; + if (unstableFeatures["m.require_identity_server"] === undefined) { + return true; + } else { + return unstableFeatures["m.require_identity_server"]; + } + } + + /** + * Query the server to see if the `id_access_token` parameter can be safely + * passed to the homeserver. Some homeservers may trigger errors if they are not + * prepared for the new parameter. + * @returns true if id_access_token can be sent + */ + async doesServerAcceptIdentityAccessToken() { + const response = await this.getVersions(); + if (!response) return false; + const versions = response["versions"]; + const unstableFeatures = response["unstable_features"]; + return versions && versions.includes("r0.6.0") || unstableFeatures && unstableFeatures["m.id_access_token"]; + } + + /** + * Query the server to see if it supports separate 3PID add and bind functions. + * This affects the sequence of API calls clients should use for these operations, + * so it's helpful to be able to check for support. + * @returns true if separate functions are supported + */ + async doesServerSupportSeparateAddAndBind() { + const response = await this.getVersions(); + if (!response) return false; + const versions = response["versions"]; + const unstableFeatures = response["unstable_features"]; + return versions?.includes("r0.6.0") || unstableFeatures?.["m.separate_add_and_bind"]; + } + + /** + * Query the server to see if it lists support for an unstable feature + * in the /versions response + * @param feature - the feature name + * @returns true if the feature is supported + */ + async doesServerSupportUnstableFeature(feature) { + const response = await this.getVersions(); + if (!response) return false; + const unstableFeatures = response["unstable_features"]; + return unstableFeatures && !!unstableFeatures[feature]; + } + + /** + * Query the server to see if it is forcing encryption to be enabled for + * a given room preset, based on the /versions response. + * @param presetName - The name of the preset to check. + * @returns true if the server is forcing encryption + * for the preset. + */ + async doesServerForceEncryptionForPreset(presetName) { + const response = await this.getVersions(); + if (!response) return false; + const unstableFeatures = response["unstable_features"]; + + // The preset name in the versions response will be without the _chat suffix. + const versionsPresetName = presetName.includes("_chat") ? presetName.substring(0, presetName.indexOf("_chat")) : presetName; + return unstableFeatures && !!unstableFeatures[`io.element.e2ee_forced.${versionsPresetName}`]; + } + async doesServerSupportThread() { + if (await this.isVersionSupported("v1.4")) { + return { + threads: _thread.FeatureSupport.Stable, + list: _thread.FeatureSupport.Stable, + fwdPagination: _thread.FeatureSupport.Stable + }; + } + try { + const [threadUnstable, threadStable, listUnstable, listStable, fwdPaginationUnstable, fwdPaginationStable] = await Promise.all([this.doesServerSupportUnstableFeature("org.matrix.msc3440"), this.doesServerSupportUnstableFeature("org.matrix.msc3440.stable"), this.doesServerSupportUnstableFeature("org.matrix.msc3856"), this.doesServerSupportUnstableFeature("org.matrix.msc3856.stable"), this.doesServerSupportUnstableFeature("org.matrix.msc3715"), this.doesServerSupportUnstableFeature("org.matrix.msc3715.stable")]); + return { + threads: (0, _thread.determineFeatureSupport)(threadStable, threadUnstable), + list: (0, _thread.determineFeatureSupport)(listStable, listUnstable), + fwdPagination: (0, _thread.determineFeatureSupport)(fwdPaginationStable, fwdPaginationUnstable) + }; + } catch (e) { + return { + threads: _thread.FeatureSupport.None, + list: _thread.FeatureSupport.None, + fwdPagination: _thread.FeatureSupport.None + }; + } + } + + /** + * Query the server to see if it supports the MSC2457 `logout_devices` parameter when setting password + * @returns true if server supports the `logout_devices` parameter + */ + doesServerSupportLogoutDevices() { + return this.isVersionSupported("r0.6.1"); + } + + /** + * Get if lazy loading members is being used. + * @returns Whether or not members are lazy loaded by this client + */ + hasLazyLoadMembersEnabled() { + return !!this.clientOpts?.lazyLoadMembers; + } + + /** + * Set a function which is called when /sync returns a 'limited' response. + * It is called with a room ID and returns a boolean. It should return 'true' if the SDK + * can SAFELY remove events from this room. It may not be safe to remove events if there + * are other references to the timelines for this room, e.g because the client is + * actively viewing events in this room. + * Default: returns false. + * @param cb - The callback which will be invoked. + */ + setCanResetTimelineCallback(cb) { + this.canResetTimelineCallback = cb; + } + + /** + * Get the callback set via `setCanResetTimelineCallback`. + * @returns The callback or null + */ + getCanResetTimelineCallback() { + return this.canResetTimelineCallback; + } + + /** + * Returns relations for a given event. Handles encryption transparently, + * with the caveat that the amount of events returned might be 0, even though you get a nextBatch. + * When the returned promise resolves, all messages should have finished trying to decrypt. + * @param roomId - the room of the event + * @param eventId - the id of the event + * @param relationType - the rel_type of the relations requested + * @param eventType - the event type of the relations requested + * @param opts - options with optional values for the request. + * @returns an object with `events` as `MatrixEvent[]` and optionally `nextBatch` if more relations are available. + */ + async relations(roomId, eventId, relationType, eventType, opts = { + dir: _eventTimeline.Direction.Backward + }) { + const fetchedEventType = eventType ? this.getEncryptedIfNeededEventType(roomId, eventType) : null; + const [eventResult, result] = await Promise.all([this.fetchRoomEvent(roomId, eventId), this.fetchRelations(roomId, eventId, relationType, fetchedEventType, opts)]); + const mapper = this.getEventMapper(); + const originalEvent = eventResult ? mapper(eventResult) : undefined; + let events = result.chunk.map(mapper); + if (fetchedEventType === _event2.EventType.RoomMessageEncrypted) { + const allEvents = originalEvent ? events.concat(originalEvent) : events; + await Promise.all(allEvents.map(e => this.decryptEventIfNeeded(e))); + if (eventType !== null) { + events = events.filter(e => e.getType() === eventType); + } + } + if (originalEvent && relationType === _event2.RelationType.Replace) { + events = events.filter(e => e.getSender() === originalEvent.getSender()); + } + return { + originalEvent: originalEvent ?? null, + events, + nextBatch: result.next_batch ?? null, + prevBatch: result.prev_batch ?? null + }; + } + + /** + * The app may wish to see if we have a key cached without + * triggering a user interaction. + */ + getCrossSigningCacheCallbacks() { + // XXX: Private member access + return this.crypto?.crossSigningInfo.getCacheCallbacks(); + } + + /** + * Generates a random string suitable for use as a client secret. <strong>This + * method is experimental and may change.</strong> + * @returns A new client secret + */ + generateClientSecret() { + return (0, _randomstring.randomString)(32); + } + + /** + * Attempts to decrypt an event + * @param event - The event to decrypt + * @returns A decryption promise + */ + decryptEventIfNeeded(event, options) { + if (event.shouldAttemptDecryption() && this.isCryptoEnabled()) { + event.attemptDecryption(this.cryptoBackend, options); + } + if (event.isBeingDecrypted()) { + return event.getDecryptionPromise(); + } else { + return Promise.resolve(); + } + } + termsUrlForService(serviceType, baseUrl) { + switch (serviceType) { + case _serviceTypes.SERVICE_TYPES.IS: + return this.http.getUrl("/terms", undefined, _httpApi.IdentityPrefix.V2, baseUrl); + case _serviceTypes.SERVICE_TYPES.IM: + return this.http.getUrl("/terms", undefined, "/_matrix/integrations/v1", baseUrl); + default: + throw new Error("Unsupported service type"); + } + } + + /** + * Get the Homeserver URL of this client + * @returns Homeserver URL of this client + */ + getHomeserverUrl() { + return this.baseUrl; + } + + /** + * Get the identity server URL of this client + * @param stripProto - whether or not to strip the protocol from the URL + * @returns Identity server URL of this client + */ + getIdentityServerUrl(stripProto = false) { + if (stripProto && (this.idBaseUrl?.startsWith("http://") || this.idBaseUrl?.startsWith("https://"))) { + return this.idBaseUrl.split("://")[1]; + } + return this.idBaseUrl; + } + + /** + * Set the identity server URL of this client + * @param url - New identity server URL + */ + setIdentityServerUrl(url) { + this.idBaseUrl = utils.ensureNoTrailingSlash(url); + this.http.setIdBaseUrl(this.idBaseUrl); + } + + /** + * Get the access token associated with this account. + * @returns The access_token or null + */ + getAccessToken() { + return this.http.opts.accessToken || null; + } + + /** + * Set the access token associated with this account. + * @param token - The new access token. + */ + setAccessToken(token) { + this.http.opts.accessToken = token; + } + + /** + * @returns true if there is a valid access_token for this client. + */ + isLoggedIn() { + return this.http.opts.accessToken !== undefined; + } + + /** + * Make up a new transaction id + * + * @returns a new, unique, transaction id + */ + makeTxnId() { + return "m" + new Date().getTime() + "." + this.txnCtr++; + } + + /** + * Check whether a username is available prior to registration. An error response + * indicates an invalid/unavailable username. + * @param username - The username to check the availability of. + * @returns Promise which resolves: to boolean of whether the username is available. + */ + isUsernameAvailable(username) { + return this.http.authedRequest(_httpApi.Method.Get, "/register/available", { + username + }).then(response => { + return response.available; + }).catch(response => { + if (response.errcode === "M_USER_IN_USE") { + return false; + } + return Promise.reject(response); + }); + } + + /** + * @param bindThreepids - Set key 'email' to true to bind any email + * threepid uses during registration in the identity server. Set 'msisdn' to + * true to bind msisdn. + * @returns Promise which resolves: TODO + * @returns Rejects: with an error response. + */ + register(username, password, sessionId, auth, bindThreepids, guestAccessToken, inhibitLogin) { + // backwards compat + if (bindThreepids === true) { + bindThreepids = { + email: true + }; + } else if (bindThreepids === null || bindThreepids === undefined || bindThreepids === false) { + bindThreepids = {}; + } + if (sessionId) { + auth.session = sessionId; + } + const params = { + auth: auth, + refresh_token: true // always ask for a refresh token - does nothing if unsupported + }; + + if (username !== undefined && username !== null) { + params.username = username; + } + if (password !== undefined && password !== null) { + params.password = password; + } + if (bindThreepids.email) { + params.bind_email = true; + } + if (bindThreepids.msisdn) { + params.bind_msisdn = true; + } + if (guestAccessToken !== undefined && guestAccessToken !== null) { + params.guest_access_token = guestAccessToken; + } + if (inhibitLogin !== undefined && inhibitLogin !== null) { + params.inhibit_login = inhibitLogin; + } + // Temporary parameter added to make the register endpoint advertise + // msisdn flows. This exists because there are clients that break + // when given stages they don't recognise. This parameter will cease + // to be necessary once these old clients are gone. + // Only send it if we send any params at all (the password param is + // mandatory, so if we send any params, we'll send the password param) + if (password !== undefined && password !== null) { + params.x_show_msisdn = true; + } + return this.registerRequest(params); + } + + /** + * Register a guest account. + * This method returns the auth info needed to create a new authenticated client, + * Remember to call `setGuest(true)` on the (guest-)authenticated client, e.g: + * ```javascript + * const tmpClient = await sdk.createClient(MATRIX_INSTANCE); + * const { user_id, device_id, access_token } = tmpClient.registerGuest(); + * const client = createClient({ + * baseUrl: MATRIX_INSTANCE, + * accessToken: access_token, + * userId: user_id, + * deviceId: device_id, + * }) + * client.setGuest(true); + * ``` + * + * @param body - JSON HTTP body to provide. + * @returns Promise which resolves: JSON object that contains: + * `{ user_id, device_id, access_token, home_server }` + * @returns Rejects: with an error response. + */ + registerGuest({ + body + } = {}) { + // TODO: Types + return this.registerRequest(body || {}, "guest"); + } + + /** + * @param data - parameters for registration request + * @param kind - type of user to register. may be "guest" + * @returns Promise which resolves: to the /register response + * @returns Rejects: with an error response. + */ + registerRequest(data, kind) { + const params = {}; + if (kind) { + params.kind = kind; + } + return this.http.request(_httpApi.Method.Post, "/register", params, data); + } + + /** + * Refreshes an access token using a provided refresh token. The refresh token + * must be valid for the current access token known to the client instance. + * + * Note that this function will not cause a logout if the token is deemed + * unknown by the server - the caller is responsible for managing logout + * actions on error. + * @param refreshToken - The refresh token. + * @returns Promise which resolves to the new token. + * @returns Rejects with an error response. + */ + refreshToken(refreshToken) { + return this.http.authedRequest(_httpApi.Method.Post, "/refresh", undefined, { + refresh_token: refreshToken + }, { + prefix: _httpApi.ClientPrefix.V1, + inhibitLogoutEmit: true // we don't want to cause logout loops + }); + } + + /** + * @returns Promise which resolves to the available login flows + * @returns Rejects: with an error response. + */ + loginFlows() { + return this.http.request(_httpApi.Method.Get, "/login"); + } + + /** + * @returns Promise which resolves: TODO + * @returns Rejects: with an error response. + */ + login(loginType, data) { + // TODO: Types + const loginData = { + type: loginType + }; + + // merge data into loginData + Object.assign(loginData, data); + return this.http.authedRequest(_httpApi.Method.Post, "/login", undefined, loginData).then(response => { + if (response.access_token && response.user_id) { + this.http.opts.accessToken = response.access_token; + this.credentials = { + userId: response.user_id + }; + } + return response; + }); + } + + /** + * @returns Promise which resolves: TODO + * @returns Rejects: with an error response. + */ + loginWithPassword(user, password) { + // TODO: Types + return this.login("m.login.password", { + user: user, + password: password + }); + } + + /** + * @param relayState - URL Callback after SAML2 Authentication + * @returns Promise which resolves: TODO + * @returns Rejects: with an error response. + */ + loginWithSAML2(relayState) { + // TODO: Types + return this.login("m.login.saml2", { + relay_state: relayState + }); + } + + /** + * @param redirectUrl - The URL to redirect to after the HS + * authenticates with CAS. + * @returns The HS URL to hit to begin the CAS login process. + */ + getCasLoginUrl(redirectUrl) { + return this.getSsoLoginUrl(redirectUrl, "cas"); + } + + /** + * @param redirectUrl - The URL to redirect to after the HS + * authenticates with the SSO. + * @param loginType - The type of SSO login we are doing (sso or cas). + * Defaults to 'sso'. + * @param idpId - The ID of the Identity Provider being targeted, optional. + * @param action - the SSO flow to indicate to the IdP, optional. + * @returns The HS URL to hit to begin the SSO login process. + */ + getSsoLoginUrl(redirectUrl, loginType = "sso", idpId, action) { + let url = "/login/" + loginType + "/redirect"; + if (idpId) { + url += "/" + idpId; + } + const params = { + redirectUrl, + [SSO_ACTION_PARAM.unstable]: action + }; + return this.http.getUrl(url, params, _httpApi.ClientPrefix.R0).href; + } + + /** + * @param token - Login token previously received from homeserver + * @returns Promise which resolves: TODO + * @returns Rejects: with an error response. + */ + loginWithToken(token) { + // TODO: Types + return this.login("m.login.token", { + token: token + }); + } + + /** + * Logs out the current session. + * Obviously, further calls that require authorisation should fail after this + * method is called. The state of the MatrixClient object is not affected: + * it is up to the caller to either reset or destroy the MatrixClient after + * this method succeeds. + * @param stopClient - whether to stop the client before calling /logout to prevent invalid token errors. + * @returns Promise which resolves: On success, the empty object `{}` + */ + async logout(stopClient = false) { + if (this.crypto?.backupManager?.getKeyBackupEnabled()) { + try { + while ((await this.crypto.backupManager.backupPendingKeys(200)) > 0); + } catch (err) { + _logger.logger.error("Key backup request failed when logging out. Some keys may be missing from backup", err); + } + } + if (stopClient) { + this.stopClient(); + this.http.abort(); + } + return this.http.authedRequest(_httpApi.Method.Post, "/logout"); + } + + /** + * Deactivates the logged-in account. + * Obviously, further calls that require authorisation should fail after this + * method is called. The state of the MatrixClient object is not affected: + * it is up to the caller to either reset or destroy the MatrixClient after + * this method succeeds. + * @param auth - Optional. Auth data to supply for User-Interactive auth. + * @param erase - Optional. If set, send as `erase` attribute in the + * JSON request body, indicating whether the account should be erased. Defaults + * to false. + * @returns Promise which resolves: On success, the empty object + */ + deactivateAccount(auth, erase) { + const body = {}; + if (auth) { + body.auth = auth; + } + if (erase !== undefined) { + body.erase = erase; + } + return this.http.authedRequest(_httpApi.Method.Post, "/account/deactivate", undefined, body); + } + + /** + * Make a request for an `m.login.token` to be issued as per + * [MSC3882](https://github.com/matrix-org/matrix-spec-proposals/pull/3882). + * The server may require User-Interactive auth. + * Note that this is UNSTABLE and subject to breaking changes without notice. + * @param auth - Optional. Auth data to supply for User-Interactive auth. + * @returns Promise which resolves: On success, the token response + * or UIA auth data. + */ + async requestLoginToken(auth) { + // use capabilities to determine which revision of the MSC is being used + const capabilities = await this.getCapabilities(); + // use r1 endpoint if capability is exposed otherwise use old r0 endpoint + const endpoint = UNSTABLE_MSC3882_CAPABILITY.findIn(capabilities) ? "/org.matrix.msc3882/login/get_token" // r1 endpoint + : "/org.matrix.msc3882/login/token"; // r0 endpoint + + const body = { + auth + }; + const res = await this.http.authedRequest(_httpApi.Method.Post, endpoint, undefined, + // no query params + body, { + prefix: _httpApi.ClientPrefix.Unstable + }); + + // the representation of expires_in changed from revision 0 to revision 1 so we populate + if ("login_token" in res) { + if (typeof res.expires_in_ms === "number") { + res.expires_in = Math.floor(res.expires_in_ms / 1000); + } else if (typeof res.expires_in === "number") { + res.expires_in_ms = res.expires_in * 1000; + } + } + return res; + } + + /** + * Get the fallback URL to use for unknown interactive-auth stages. + * + * @param loginType - the type of stage being attempted + * @param authSessionId - the auth session ID provided by the homeserver + * + * @returns HS URL to hit to for the fallback interface + */ + getFallbackAuthUrl(loginType, authSessionId) { + const path = utils.encodeUri("/auth/$loginType/fallback/web", { + $loginType: loginType + }); + return this.http.getUrl(path, { + session: authSessionId + }, _httpApi.ClientPrefix.R0).href; + } + + /** + * Create a new room. + * @param options - a list of options to pass to the /createRoom API. + * @returns Promise which resolves: `{room_id: {string}}` + * @returns Rejects: with an error response. + */ + async createRoom(options) { + // eslint-disable-line camelcase + // some valid options include: room_alias_name, visibility, invite + + // inject the id_access_token if inviting 3rd party addresses + const invitesNeedingToken = (options.invite_3pid || []).filter(i => !i.id_access_token); + if (invitesNeedingToken.length > 0 && this.identityServer?.getAccessToken && (await this.doesServerAcceptIdentityAccessToken())) { + const identityAccessToken = await this.identityServer.getAccessToken(); + if (identityAccessToken) { + for (const invite of invitesNeedingToken) { + invite.id_access_token = identityAccessToken; + } + } + } + return this.http.authedRequest(_httpApi.Method.Post, "/createRoom", undefined, options); + } + + /** + * Fetches relations for a given event + * @param roomId - the room of the event + * @param eventId - the id of the event + * @param relationType - the rel_type of the relations requested + * @param eventType - the event type of the relations requested + * @param opts - options with optional values for the request. + * @returns the response, with chunk, prev_batch and, next_batch. + */ + fetchRelations(roomId, eventId, relationType, eventType, opts = { + dir: _eventTimeline.Direction.Backward + }) { + let params = opts; + if (_thread.Thread.hasServerSideFwdPaginationSupport === _thread.FeatureSupport.Experimental) { + params = (0, utils.replaceParam)("dir", "org.matrix.msc3715.dir", params); + } + if (this.canSupport.get(_feature.Feature.RelationsRecursion) === _feature.ServerSupport.Unstable) { + params = (0, utils.replaceParam)("recurse", "org.matrix.msc3981.recurse", params); + } + const queryString = utils.encodeParams(params); + let templatedUrl = "/rooms/$roomId/relations/$eventId"; + if (relationType !== null) { + templatedUrl += "/$relationType"; + if (eventType !== null) { + templatedUrl += "/$eventType"; + } + } else if (eventType !== null) { + _logger.logger.warn(`eventType: ${eventType} ignored when fetching + relations as relationType is null`); + eventType = null; + } + const path = utils.encodeUri(templatedUrl + "?" + queryString, { + $roomId: roomId, + $eventId: eventId, + $relationType: relationType, + $eventType: eventType + }); + return this.http.authedRequest(_httpApi.Method.Get, path, undefined, undefined, { + prefix: _httpApi.ClientPrefix.V1 + }); + } + + /** + * @returns Promise which resolves: TODO + * @returns Rejects: with an error response. + */ + roomState(roomId) { + const path = utils.encodeUri("/rooms/$roomId/state", { + $roomId: roomId + }); + return this.http.authedRequest(_httpApi.Method.Get, path); + } + + /** + * Get an event in a room by its event id. + * + * @returns Promise which resolves to an object containing the event. + * @returns Rejects: with an error response. + */ + fetchRoomEvent(roomId, eventId) { + const path = utils.encodeUri("/rooms/$roomId/event/$eventId", { + $roomId: roomId, + $eventId: eventId + }); + return this.http.authedRequest(_httpApi.Method.Get, path); + } + + /** + * @param includeMembership - the membership type to include in the response + * @param excludeMembership - the membership type to exclude from the response + * @param atEventId - the id of the event for which moment in the timeline the members should be returned for + * @returns Promise which resolves: dictionary of userid to profile information + * @returns Rejects: with an error response. + */ + members(roomId, includeMembership, excludeMembership, atEventId) { + 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(_httpApi.Method.Get, path); + } + + /** + * Upgrades a room to a new protocol version + * @param newVersion - The target version to upgrade to + * @returns Promise which resolves: Object with key 'replacement_room' + * @returns Rejects: with an error response. + */ + upgradeRoom(roomId, newVersion) { + // eslint-disable-line camelcase + const path = utils.encodeUri("/rooms/$roomId/upgrade", { + $roomId: roomId + }); + return this.http.authedRequest(_httpApi.Method.Post, path, undefined, { + new_version: newVersion + }); + } + + /** + * Retrieve a state event. + * @returns Promise which resolves: TODO + * @returns Rejects: with an error response. + */ + getStateEvent(roomId, eventType, stateKey) { + const pathParams = { + $roomId: roomId, + $eventType: eventType, + $stateKey: stateKey + }; + let path = utils.encodeUri("/rooms/$roomId/state/$eventType", pathParams); + if (stateKey !== undefined) { + path = utils.encodeUri(path + "/$stateKey", pathParams); + } + return this.http.authedRequest(_httpApi.Method.Get, path); + } + + /** + * @param opts - Options for the request function. + * @returns Promise which resolves: TODO + * @returns Rejects: with an error response. + */ + sendStateEvent(roomId, eventType, content, stateKey = "", opts = {}) { + const pathParams = { + $roomId: roomId, + $eventType: eventType, + $stateKey: stateKey + }; + let path = utils.encodeUri("/rooms/$roomId/state/$eventType", pathParams); + if (stateKey !== undefined) { + path = utils.encodeUri(path + "/$stateKey", pathParams); + } + return this.http.authedRequest(_httpApi.Method.Put, path, undefined, content, opts); + } + + /** + * @returns Promise which resolves: TODO + * @returns Rejects: with an error response. + */ + roomInitialSync(roomId, limit) { + const path = utils.encodeUri("/rooms/$roomId/initialSync", { + $roomId: roomId + }); + return this.http.authedRequest(_httpApi.Method.Get, path, { + limit: limit?.toString() ?? "30" + }); + } + + /** + * Set a marker to indicate the point in a room before which the user has read every + * event. This can be retrieved from room account data (the event type is `m.fully_read`) + * and displayed as a horizontal line in the timeline that is visually distinct to the + * position of the user's own read receipt. + * @param roomId - ID of the room that has been read + * @param rmEventId - ID of the event that has been read + * @param rrEventId - ID of the event tracked by the read receipt. This is here + * for convenience because the RR and the RM are commonly updated at the same time as + * each other. Optional. + * @param rpEventId - rpEvent the m.read.private read receipt event for when we + * don't want other users to see the read receipts. This is experimental. Optional. + * @returns Promise which resolves: the empty object, `{}`. + */ + async setRoomReadMarkersHttpRequest(roomId, rmEventId, rrEventId, rpEventId) { + const path = utils.encodeUri("/rooms/$roomId/read_markers", { + $roomId: roomId + }); + const content = { + [_read_receipts.ReceiptType.FullyRead]: rmEventId, + [_read_receipts.ReceiptType.Read]: rrEventId + }; + if ((await this.doesServerSupportUnstableFeature("org.matrix.msc2285.stable")) || (await this.isVersionSupported("v1.4"))) { + content[_read_receipts.ReceiptType.ReadPrivate] = rpEventId; + } + return this.http.authedRequest(_httpApi.Method.Post, path, undefined, content); + } + + /** + * @returns Promise which resolves: A list of the user's current rooms + * @returns Rejects: with an error response. + */ + getJoinedRooms() { + const path = utils.encodeUri("/joined_rooms", {}); + return this.http.authedRequest(_httpApi.Method.Get, path); + } + + /** + * Retrieve membership info. for a room. + * @param roomId - ID of the room to get membership for + * @returns Promise which resolves: A list of currently joined users + * and their profile data. + * @returns Rejects: with an error response. + */ + getJoinedRoomMembers(roomId) { + const path = utils.encodeUri("/rooms/$roomId/joined_members", { + $roomId: roomId + }); + return this.http.authedRequest(_httpApi.Method.Get, path); + } + + /** + * @param options - Options for this request + * @param server - The remote server to query for the room list. + * Optional. If unspecified, get the local home + * server's public room list. + * @param limit - Maximum number of entries to return + * @param since - Token to paginate from + * @returns Promise which resolves: IPublicRoomsResponse + * @returns Rejects: with an error response. + */ + publicRooms(_ref = {}) { + let { + server, + limit, + since + } = _ref, + options = _objectWithoutProperties(_ref, _excluded); + const queryParams = { + server, + limit, + since + }; + if (Object.keys(options).length === 0) { + return this.http.authedRequest(_httpApi.Method.Get, "/publicRooms", queryParams); + } else { + return this.http.authedRequest(_httpApi.Method.Post, "/publicRooms", queryParams, options); + } + } + + /** + * Create an alias to room ID mapping. + * @param alias - The room alias to create. + * @param roomId - The room ID to link the alias to. + * @returns Promise which resolves: an empty object `{}` + * @returns Rejects: with an error response. + */ + createAlias(alias, roomId) { + const path = utils.encodeUri("/directory/room/$alias", { + $alias: alias + }); + const data = { + room_id: roomId + }; + return this.http.authedRequest(_httpApi.Method.Put, path, undefined, data); + } + + /** + * Delete an alias to room ID mapping. This alias must be on your local server, + * and you must have sufficient access to do this operation. + * @param alias - The room alias to delete. + * @returns Promise which resolves: an empty object `{}`. + * @returns Rejects: with an error response. + */ + deleteAlias(alias) { + const path = utils.encodeUri("/directory/room/$alias", { + $alias: alias + }); + return this.http.authedRequest(_httpApi.Method.Delete, path); + } + + /** + * Gets the local aliases for the room. Note: this includes all local aliases, unlike the + * curated list from the m.room.canonical_alias state event. + * @param roomId - The room ID to get local aliases for. + * @returns Promise which resolves: an object with an `aliases` property, containing an array of local aliases + * @returns Rejects: with an error response. + */ + getLocalAliases(roomId) { + const path = utils.encodeUri("/rooms/$roomId/aliases", { + $roomId: roomId + }); + const prefix = _httpApi.ClientPrefix.V3; + return this.http.authedRequest(_httpApi.Method.Get, path, undefined, undefined, { + prefix + }); + } + + /** + * Get room info for the given alias. + * @param alias - The room alias to resolve. + * @returns Promise which resolves: Object with room_id and servers. + * @returns Rejects: with an error response. + */ + getRoomIdForAlias(alias) { + // eslint-disable-line camelcase + const path = utils.encodeUri("/directory/room/$alias", { + $alias: alias + }); + return this.http.authedRequest(_httpApi.Method.Get, path); + } + + /** + * @returns Promise which resolves: Object with room_id and servers. + * @returns Rejects: with an error response. + * @deprecated use `getRoomIdForAlias` instead + */ + // eslint-disable-next-line camelcase + resolveRoomAlias(roomAlias) { + const path = utils.encodeUri("/directory/room/$alias", { + $alias: roomAlias + }); + return this.http.request(_httpApi.Method.Get, path); + } + + /** + * Get the visibility of a room in the current HS's room directory + * @returns Promise which resolves: TODO + * @returns Rejects: with an error response. + */ + getRoomDirectoryVisibility(roomId) { + const path = utils.encodeUri("/directory/list/room/$roomId", { + $roomId: roomId + }); + return this.http.authedRequest(_httpApi.Method.Get, path); + } + + /** + * Set the visbility of a room in the current HS's room directory + * @param visibility - "public" to make the room visible + * in the public directory, or "private" to make + * it invisible. + * @returns Promise which resolves: to an empty object `{}` + * @returns Rejects: with an error response. + */ + setRoomDirectoryVisibility(roomId, visibility) { + const path = utils.encodeUri("/directory/list/room/$roomId", { + $roomId: roomId + }); + return this.http.authedRequest(_httpApi.Method.Put, path, undefined, { + visibility + }); + } + + /** + * Set the visbility of a room bridged to a 3rd party network in + * the current HS's room directory. + * @param networkId - the network ID of the 3rd party + * instance under which this room is published under. + * @param visibility - "public" to make the room visible + * in the public directory, or "private" to make + * it invisible. + * @returns Promise which resolves: result object + * @returns Rejects: with an error response. + */ + setRoomDirectoryVisibilityAppService(networkId, roomId, visibility) { + // TODO: Types + const path = utils.encodeUri("/directory/list/appservice/$networkId/$roomId", { + $networkId: networkId, + $roomId: roomId + }); + return this.http.authedRequest(_httpApi.Method.Put, path, undefined, { + visibility: visibility + }); + } + + /** + * Query the user directory with a term matching user IDs, display names and domains. + * @param term - the term with which to search. + * @param limit - the maximum number of results to return. The server will + * apply a limit if unspecified. + * @returns Promise which resolves: an array of results. + */ + searchUserDirectory({ + term, + limit + }) { + const body = { + search_term: term + }; + if (limit !== undefined) { + body.limit = limit; + } + return this.http.authedRequest(_httpApi.Method.Post, "/user_directory/search", undefined, body); + } + + /** + * Upload a file to the media repository on the homeserver. + * + * @param file - The object to upload. On a browser, something that + * can be sent to XMLHttpRequest.send (typically a File). Under node.js, + * a a Buffer, String or ReadStream. + * + * @param opts - options object + * + * @returns Promise which resolves to response object, as + * determined by this.opts.onlyData, opts.rawResponse, and + * opts.onlyContentUri. Rejects with an error (usually a MatrixError). + */ + uploadContent(file, opts) { + return this.http.uploadContent(file, opts); + } + + /** + * Cancel a file upload in progress + * @param upload - The object returned from uploadContent + * @returns true if canceled, otherwise false + */ + cancelUpload(upload) { + return this.http.cancelUpload(upload); + } + + /** + * Get a list of all file uploads in progress + * @returns Array of objects representing current uploads. + * Currently in progress is element 0. Keys: + * - promise: The promise associated with the upload + * - loaded: Number of bytes uploaded + * - total: Total number of bytes to upload + */ + getCurrentUploads() { + return this.http.getCurrentUploads(); + } + + /** + * @param info - The kind of info to retrieve (e.g. 'displayname', + * 'avatar_url'). + * @returns Promise which resolves: TODO + * @returns Rejects: with an error response. + */ + getProfileInfo(userId, info + // eslint-disable-next-line camelcase + ) { + const path = info ? utils.encodeUri("/profile/$userId/$info", { + $userId: userId, + $info: info + }) : utils.encodeUri("/profile/$userId", { + $userId: userId + }); + return this.http.authedRequest(_httpApi.Method.Get, path); + } + + /** + * @returns Promise which resolves to a list of the user's threepids. + * @returns Rejects: with an error response. + */ + getThreePids() { + return this.http.authedRequest(_httpApi.Method.Get, "/account/3pid"); + } + + /** + * Add a 3PID to your homeserver account and optionally bind it to an identity + * server as well. An identity server is required as part of the `creds` object. + * + * This API is deprecated, and you should instead use `addThreePidOnly` + * for homeservers that support it. + * + * @returns Promise which resolves: on success + * @returns Rejects: with an error response. + */ + addThreePid(creds, bind) { + // TODO: Types + const path = "/account/3pid"; + const data = { + threePidCreds: creds, + bind: bind + }; + return this.http.authedRequest(_httpApi.Method.Post, path, undefined, data); + } + + /** + * Add a 3PID to your homeserver account. This API does not use an identity + * server, as the homeserver is expected to handle 3PID ownership validation. + * + * You can check whether a homeserver supports this API via + * `doesServerSupportSeparateAddAndBind`. + * + * @param data - A object with 3PID validation data from having called + * `account/3pid/<medium>/requestToken` on the homeserver. + * @returns Promise which resolves: to an empty object `{}` + * @returns Rejects: with an error response. + */ + async addThreePidOnly(data) { + const path = "/account/3pid/add"; + const prefix = (await this.isVersionSupported("r0.6.0")) ? _httpApi.ClientPrefix.R0 : _httpApi.ClientPrefix.Unstable; + return this.http.authedRequest(_httpApi.Method.Post, path, undefined, data, { + prefix + }); + } + + /** + * Bind a 3PID for discovery onto an identity server via the homeserver. The + * identity server handles 3PID ownership validation and the homeserver records + * the new binding to track where all 3PIDs for the account are bound. + * + * You can check whether a homeserver supports this API via + * `doesServerSupportSeparateAddAndBind`. + * + * @param data - A object with 3PID validation data from having called + * `validate/<medium>/requestToken` on the identity server. It should also + * contain `id_server` and `id_access_token` fields as well. + * @returns Promise which resolves: to an empty object `{}` + * @returns Rejects: with an error response. + */ + async bindThreePid(data) { + const path = "/account/3pid/bind"; + const prefix = (await this.isVersionSupported("r0.6.0")) ? _httpApi.ClientPrefix.R0 : _httpApi.ClientPrefix.Unstable; + return this.http.authedRequest(_httpApi.Method.Post, path, undefined, data, { + prefix + }); + } + + /** + * Unbind a 3PID for discovery on an identity server via the homeserver. The + * homeserver removes its record of the binding to keep an updated record of + * where all 3PIDs for the account are bound. + * + * @param medium - The threepid medium (eg. 'email') + * @param address - The threepid address (eg. 'bob\@example.com') + * this must be as returned by getThreePids. + * @returns Promise which resolves: on success + * @returns Rejects: with an error response. + */ + async unbindThreePid(medium, address + // eslint-disable-next-line camelcase + ) { + const path = "/account/3pid/unbind"; + const data = { + medium, + address, + id_server: this.getIdentityServerUrl(true) + }; + const prefix = (await this.isVersionSupported("r0.6.0")) ? _httpApi.ClientPrefix.R0 : _httpApi.ClientPrefix.Unstable; + return this.http.authedRequest(_httpApi.Method.Post, path, undefined, data, { + prefix + }); + } + + /** + * @param medium - The threepid medium (eg. 'email') + * @param address - The threepid address (eg. 'bob\@example.com') + * this must be as returned by getThreePids. + * @returns Promise which resolves: The server response on success + * (generally the empty JSON object) + * @returns Rejects: with an error response. + */ + deleteThreePid(medium, address + // eslint-disable-next-line camelcase + ) { + const path = "/account/3pid/delete"; + return this.http.authedRequest(_httpApi.Method.Post, path, undefined, { + medium, + address + }); + } + + /** + * Make a request to change your password. + * @param newPassword - The new desired password. + * @param logoutDevices - Should all sessions be logged out after the password change. Defaults to true. + * @returns Promise which resolves: to an empty object `{}` + * @returns Rejects: with an error response. + */ + setPassword(authDict, newPassword, logoutDevices) { + const path = "/account/password"; + const data = { + auth: authDict, + new_password: newPassword, + logout_devices: logoutDevices + }; + return this.http.authedRequest(_httpApi.Method.Post, path, undefined, data); + } + + /** + * Gets all devices recorded for the logged-in user + * @returns Promise which resolves: result object + * @returns Rejects: with an error response. + */ + getDevices() { + return this.http.authedRequest(_httpApi.Method.Get, "/devices"); + } + + /** + * Gets specific device details for the logged-in user + * @param deviceId - device to query + * @returns Promise which resolves: result object + * @returns Rejects: with an error response. + */ + getDevice(deviceId) { + const path = utils.encodeUri("/devices/$device_id", { + $device_id: deviceId + }); + return this.http.authedRequest(_httpApi.Method.Get, path); + } + + /** + * Update the given device + * + * @param deviceId - device to update + * @param body - body of request + * @returns Promise which resolves: to an empty object `{}` + * @returns Rejects: with an error response. + */ + // eslint-disable-next-line camelcase + setDeviceDetails(deviceId, body) { + const path = utils.encodeUri("/devices/$device_id", { + $device_id: deviceId + }); + return this.http.authedRequest(_httpApi.Method.Put, path, undefined, body); + } + + /** + * Delete the given device + * + * @param deviceId - device to delete + * @param auth - Optional. Auth data to supply for User-Interactive auth. + * @returns Promise which resolves: result object + * @returns Rejects: with an error response. + */ + deleteDevice(deviceId, auth) { + const path = utils.encodeUri("/devices/$device_id", { + $device_id: deviceId + }); + const body = {}; + if (auth) { + body.auth = auth; + } + return this.http.authedRequest(_httpApi.Method.Delete, path, undefined, body); + } + + /** + * Delete multiple device + * + * @param devices - IDs of the devices to delete + * @param auth - Optional. Auth data to supply for User-Interactive auth. + * @returns Promise which resolves: result object + * @returns Rejects: with an error response. + */ + deleteMultipleDevices(devices, auth) { + const body = { + devices + }; + if (auth) { + body.auth = auth; + } + const path = "/delete_devices"; + return this.http.authedRequest(_httpApi.Method.Post, path, undefined, body); + } + + /** + * Gets all pushers registered for the logged-in user + * + * @returns Promise which resolves: Array of objects representing pushers + * @returns Rejects: with an error response. + */ + async getPushers() { + const response = await this.http.authedRequest(_httpApi.Method.Get, "/pushers"); + + // Migration path for clients that connect to a homeserver that does not support + // MSC3881 yet, see https://github.com/matrix-org/matrix-spec-proposals/blob/kerry/remote-push-toggle/proposals/3881-remote-push-notification-toggling.md#migration + if (!(await this.doesServerSupportUnstableFeature("org.matrix.msc3881"))) { + response.pushers = response.pushers.map(pusher => { + if (!pusher.hasOwnProperty(_event2.PUSHER_ENABLED.name)) { + pusher[_event2.PUSHER_ENABLED.name] = true; + } + return pusher; + }); + } + return response; + } + + /** + * Adds a new pusher or updates an existing pusher + * + * @param pusher - Object representing a pusher + * @returns Promise which resolves: Empty json object on success + * @returns Rejects: with an error response. + */ + setPusher(pusher) { + const path = "/pushers/set"; + return this.http.authedRequest(_httpApi.Method.Post, path, undefined, pusher); + } + + /** + * Removes an existing pusher + * @param pushKey - pushkey of pusher to remove + * @param appId - app_id of pusher to remove + * @returns Promise which resolves: Empty json object on success + * @returns Rejects: with an error response. + */ + removePusher(pushKey, appId) { + const path = "/pushers/set"; + const body = { + pushkey: pushKey, + app_id: appId, + kind: null // marks pusher for removal + }; + + return this.http.authedRequest(_httpApi.Method.Post, path, undefined, body); + } + + /** + * Persists local notification settings + * @returns Promise which resolves: an empty object + * @returns Rejects: with an error response. + */ + setLocalNotificationSettings(deviceId, notificationSettings) { + const key = `${_event2.LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`; + return this.setAccountData(key, notificationSettings); + } + + /** + * Get the push rules for the account from the server. + * @returns Promise which resolves to the push rules. + * @returns Rejects: with an error response. + */ + getPushRules() { + return this.http.authedRequest(_httpApi.Method.Get, "/pushrules/").then(rules => { + this.setPushRules(rules); + return this.pushRules; + }); + } + + /** + * Update the push rules for the account. This should be called whenever + * updated push rules are available. + */ + setPushRules(rules) { + // Fix-up defaults, if applicable. + this.pushRules = _pushprocessor.PushProcessor.rewriteDefaultRules(rules, this.getUserId()); + // Pre-calculate any necessary caches. + this.pushProcessor.updateCachedPushRuleKeys(this.pushRules); + } + + /** + * @returns Promise which resolves: an empty object `{}` + * @returns Rejects: with an error response. + */ + addPushRule(scope, kind, ruleId, body) { + // NB. Scope not uri encoded because devices need the '/' + const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", { + $kind: kind, + $ruleId: ruleId + }); + return this.http.authedRequest(_httpApi.Method.Put, path, undefined, body); + } + + /** + * @returns Promise which resolves: an empty object `{}` + * @returns Rejects: with an error response. + */ + deletePushRule(scope, kind, ruleId) { + // NB. Scope not uri encoded because devices need the '/' + const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", { + $kind: kind, + $ruleId: ruleId + }); + return this.http.authedRequest(_httpApi.Method.Delete, path); + } + + /** + * Enable or disable a push notification rule. + * @returns Promise which resolves: to an empty object `{}` + * @returns Rejects: with an error response. + */ + setPushRuleEnabled(scope, kind, ruleId, enabled) { + const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId/enabled", { + $kind: kind, + $ruleId: ruleId + }); + return this.http.authedRequest(_httpApi.Method.Put, path, undefined, { + enabled: enabled + }); + } + + /** + * Set the actions for a push notification rule. + * @returns Promise which resolves: to an empty object `{}` + * @returns Rejects: with an error response. + */ + setPushRuleActions(scope, kind, ruleId, actions) { + const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId/actions", { + $kind: kind, + $ruleId: ruleId + }); + return this.http.authedRequest(_httpApi.Method.Put, path, undefined, { + actions: actions + }); + } + + /** + * Perform a server-side search. + * @param next_batch - the batch token to pass in the query string + * @param body - the JSON object to pass to the request body. + * @param abortSignal - optional signal used to cancel the http request. + * @returns Promise which resolves to the search response object. + * @returns Rejects: with an error response. + */ + search({ + body, + next_batch: nextBatch + }, abortSignal) { + const queryParams = {}; + if (nextBatch) { + queryParams.next_batch = nextBatch; + } + return this.http.authedRequest(_httpApi.Method.Post, "/search", queryParams, body, { + abortSignal + }); + } + + /** + * Upload keys + * + * @param content - body of upload request + * + * @param opts - this method no longer takes any opts, + * used to take opts.device_id but this was not removed from the spec as a redundant parameter + * + * @returns Promise which resolves: result object. Rejects: with + * an error response ({@link MatrixError}). + */ + uploadKeysRequest(content, opts) { + return this.http.authedRequest(_httpApi.Method.Post, "/keys/upload", undefined, content); + } + uploadKeySignatures(content) { + return this.http.authedRequest(_httpApi.Method.Post, "/keys/signatures/upload", undefined, content, { + prefix: _httpApi.ClientPrefix.V3 + }); + } + + /** + * Download device keys + * + * @param userIds - list of users to get keys for + * + * @param token - sync token to pass in the query request, to help + * the HS give the most recent results + * + * @returns Promise which resolves: result object. Rejects: with + * an error response ({@link MatrixError}). + */ + downloadKeysForUsers(userIds, { + token + } = {}) { + const content = { + device_keys: {} + }; + if (token !== undefined) { + content.token = token; + } + userIds.forEach(u => { + content.device_keys[u] = []; + }); + return this.http.authedRequest(_httpApi.Method.Post, "/keys/query", undefined, content); + } + + /** + * Claim one-time keys + * + * @param devices - a list of [userId, deviceId] pairs + * + * @param keyAlgorithm - desired key type + * + * @param timeout - the time (in milliseconds) to wait for keys from remote + * servers + * + * @returns Promise which resolves: result object. Rejects: with + * an error response ({@link MatrixError}). + */ + claimOneTimeKeys(devices, keyAlgorithm = "signed_curve25519", timeout) { + const queries = {}; + if (keyAlgorithm === undefined) { + keyAlgorithm = "signed_curve25519"; + } + for (const [userId, deviceId] of devices) { + const query = queries[userId] || {}; + (0, utils.safeSet)(queries, userId, query); + (0, utils.safeSet)(query, deviceId, keyAlgorithm); + } + const content = { + one_time_keys: queries + }; + if (timeout) { + content.timeout = timeout; + } + const path = "/keys/claim"; + return this.http.authedRequest(_httpApi.Method.Post, path, undefined, content); + } + + /** + * Ask the server for a list of users who have changed their device lists + * between a pair of sync tokens + * + * + * @returns Promise which resolves: result object. Rejects: with + * an error response ({@link MatrixError}). + */ + getKeyChanges(oldToken, newToken) { + const qps = { + from: oldToken, + to: newToken + }; + return this.http.authedRequest(_httpApi.Method.Get, "/keys/changes", qps); + } + uploadDeviceSigningKeys(auth, keys) { + // API returns empty object + const data = Object.assign({}, keys); + if (auth) Object.assign(data, { + auth + }); + return this.http.authedRequest(_httpApi.Method.Post, "/keys/device_signing/upload", undefined, data, { + prefix: _httpApi.ClientPrefix.Unstable + }); + } + + /** + * Register with an identity server using the OpenID token from the user's + * Homeserver, which can be retrieved via + * {@link MatrixClient#getOpenIdToken}. + * + * Note that the `/account/register` endpoint (as well as IS authentication in + * general) was added as part of the v2 API version. + * + * @returns Promise which resolves: with object containing an Identity + * Server access token. + * @returns Rejects: with an error response. + */ + registerWithIdentityServer(hsOpenIdToken) { + if (!this.idBaseUrl) { + throw new Error("No identity server base URL set"); + } + const uri = this.http.getUrl("/account/register", undefined, _httpApi.IdentityPrefix.V2, this.idBaseUrl); + return this.http.requestOtherUrl(_httpApi.Method.Post, uri, hsOpenIdToken); + } + + /** + * Requests an email verification token directly from an identity server. + * + * This API is used as part of binding an email for discovery on an identity + * server. The validation data that results should be passed to the + * `bindThreePid` method to complete the binding process. + * + * @param email - The email address to request a token for + * @param clientSecret - A secret binary string generated by the client. + * It is recommended this be around 16 ASCII characters. + * @param sendAttempt - If an identity server sees a duplicate request + * with the same sendAttempt, it will not send another email. + * To request another email to be sent, use a larger value for + * the sendAttempt param as was used in the previous request. + * @param nextLink - Optional If specified, the client will be redirected + * to this link after validation. + * @param identityAccessToken - The `access_token` field of the identity + * server `/account/register` response (see {@link registerWithIdentityServer}). + * + * @returns Promise which resolves: TODO + * @returns Rejects: with an error response. + * @throws Error if no identity server is set + */ + requestEmailToken(email, clientSecret, sendAttempt, nextLink, identityAccessToken) { + const params = { + client_secret: clientSecret, + email: email, + send_attempt: sendAttempt?.toString() + }; + if (nextLink) { + params.next_link = nextLink; + } + return this.http.idServerRequest(_httpApi.Method.Post, "/validate/email/requestToken", params, _httpApi.IdentityPrefix.V2, identityAccessToken); + } + + /** + * Requests a MSISDN verification token directly from an identity server. + * + * This API is used as part of binding a MSISDN for discovery on an identity + * server. The validation data that results should be passed to the + * `bindThreePid` method to complete the binding process. + * + * @param phoneCountry - The ISO 3166-1 alpha-2 code for the country in + * which phoneNumber should be parsed relative to. + * @param phoneNumber - The phone number, in national or international + * format + * @param clientSecret - A secret binary string generated by the client. + * It is recommended this be around 16 ASCII characters. + * @param sendAttempt - If an identity server sees a duplicate request + * with the same sendAttempt, it will not send another SMS. + * To request another SMS to be sent, use a larger value for + * the sendAttempt param as was used in the previous request. + * @param nextLink - Optional If specified, the client will be redirected + * to this link after validation. + * @param identityAccessToken - The `access_token` field of the Identity + * Server `/account/register` response (see {@link registerWithIdentityServer}). + * + * @returns Promise which resolves to an object with a sid string + * @returns Rejects: with an error response. + * @throws Error if no identity server is set + */ + requestMsisdnToken(phoneCountry, phoneNumber, clientSecret, sendAttempt, nextLink, identityAccessToken) { + const params = { + client_secret: clientSecret, + country: phoneCountry, + phone_number: phoneNumber, + send_attempt: sendAttempt?.toString() + }; + if (nextLink) { + params.next_link = nextLink; + } + return this.http.idServerRequest(_httpApi.Method.Post, "/validate/msisdn/requestToken", params, _httpApi.IdentityPrefix.V2, identityAccessToken); + } + + /** + * Submits a MSISDN token to the identity server + * + * This is used when submitting the code sent by SMS to a phone number. + * The identity server has an equivalent API for email but the js-sdk does + * not expose this, since email is normally validated by the user clicking + * a link rather than entering a code. + * + * @param sid - The sid given in the response to requestToken + * @param clientSecret - A secret binary string generated by the client. + * This must be the same value submitted in the requestToken call. + * @param msisdnToken - The MSISDN token, as enetered by the user. + * @param identityAccessToken - The `access_token` field of the Identity + * Server `/account/register` response (see {@link registerWithIdentityServer}). + * + * @returns Promise which resolves: Object, containing success boolean. + * @returns Rejects: with an error response. + * @throws Error if No identity server is set + */ + submitMsisdnToken(sid, clientSecret, msisdnToken, identityAccessToken) { + const params = { + sid: sid, + client_secret: clientSecret, + token: msisdnToken + }; + return this.http.idServerRequest(_httpApi.Method.Post, "/validate/msisdn/submitToken", params, _httpApi.IdentityPrefix.V2, identityAccessToken); + } + + /** + * Submits a MSISDN token to an arbitrary URL. + * + * This is used when submitting the code sent by SMS to a phone number in the + * newer 3PID flow where the homeserver validates 3PID ownership (as part of + * `requestAdd3pidMsisdnToken`). The homeserver response may include a + * `submit_url` to specify where the token should be sent, and this helper can + * be used to pass the token to this URL. + * + * @param url - The URL to submit the token to + * @param sid - The sid given in the response to requestToken + * @param clientSecret - A secret binary string generated by the client. + * This must be the same value submitted in the requestToken call. + * @param msisdnToken - The MSISDN token, as enetered by the user. + * + * @returns Promise which resolves: Object, containing success boolean. + * @returns Rejects: with an error response. + */ + submitMsisdnTokenOtherUrl(url, sid, clientSecret, msisdnToken) { + const params = { + sid: sid, + client_secret: clientSecret, + token: msisdnToken + }; + return this.http.requestOtherUrl(_httpApi.Method.Post, url, params); + } + + /** + * Gets the V2 hashing information from the identity server. Primarily useful for + * lookups. + * @param identityAccessToken - The access token for the identity server. + * @returns The hashing information for the identity server. + */ + getIdentityHashDetails(identityAccessToken) { + // TODO: Types + return this.http.idServerRequest(_httpApi.Method.Get, "/hash_details", undefined, _httpApi.IdentityPrefix.V2, identityAccessToken); + } + + /** + * Performs a hashed lookup of addresses against the identity server. This is + * only supported on identity servers which have at least the version 2 API. + * @param addressPairs - An array of 2 element arrays. + * The first element of each pair is the address, the second is the 3PID medium. + * Eg: `["email@example.org", "email"]` + * @param identityAccessToken - The access token for the identity server. + * @returns A collection of address mappings to + * found MXIDs. Results where no user could be found will not be listed. + */ + async identityHashedLookup(addressPairs, identityAccessToken) { + const params = { + // addresses: ["email@example.org", "10005550000"], + // algorithm: "sha256", + // pepper: "abc123" + }; + + // Get hash information first before trying to do a lookup + const hashes = await this.getIdentityHashDetails(identityAccessToken); + if (!hashes || !hashes["lookup_pepper"] || !hashes["algorithms"]) { + throw new Error("Unsupported identity server: bad response"); + } + params["pepper"] = hashes["lookup_pepper"]; + const localMapping = { + // hashed identifier => plain text address + // For use in this function's return format + }; + + // When picking an algorithm, we pick the hashed over no hashes + if (hashes["algorithms"].includes("sha256")) { + // Abuse the olm hashing + const olmutil = new global.Olm.Utility(); + params["addresses"] = addressPairs.map(p => { + const addr = p[0].toLowerCase(); // lowercase to get consistent hashes + const med = p[1].toLowerCase(); + const hashed = olmutil.sha256(`${addr} ${med} ${params["pepper"]}`).replace(/\+/g, "-").replace(/\//g, "_"); // URL-safe base64 + // Map the hash to a known (case-sensitive) address. We use the case + // sensitive version because the caller might be expecting that. + localMapping[hashed] = p[0]; + return hashed; + }); + params["algorithm"] = "sha256"; + } else if (hashes["algorithms"].includes("none")) { + params["addresses"] = addressPairs.map(p => { + const addr = p[0].toLowerCase(); // lowercase to get consistent hashes + const med = p[1].toLowerCase(); + const unhashed = `${addr} ${med}`; + // Map the unhashed values to a known (case-sensitive) address. We use + // the case-sensitive version because the caller might be expecting that. + localMapping[unhashed] = p[0]; + return unhashed; + }); + params["algorithm"] = "none"; + } else { + throw new Error("Unsupported identity server: unknown hash algorithm"); + } + const response = await this.http.idServerRequest(_httpApi.Method.Post, "/lookup", params, _httpApi.IdentityPrefix.V2, identityAccessToken); + if (!response?.["mappings"]) return []; // no results + + const foundAddresses = []; + for (const hashed of Object.keys(response["mappings"])) { + const mxid = response["mappings"][hashed]; + const plainAddress = localMapping[hashed]; + if (!plainAddress) { + throw new Error("Identity server returned more results than expected"); + } + foundAddresses.push({ + address: plainAddress, + mxid + }); + } + return foundAddresses; + } + + /** + * Looks up the public Matrix ID mapping for a given 3rd party + * identifier from the identity server + * + * @param medium - The medium of the threepid, eg. 'email' + * @param address - The textual address of the threepid + * @param identityAccessToken - The `access_token` field of the Identity + * Server `/account/register` response (see {@link registerWithIdentityServer}). + * + * @returns Promise which resolves: A threepid mapping + * object or the empty object if no mapping + * exists + * @returns Rejects: with an error response. + */ + async lookupThreePid(medium, address, identityAccessToken) { + // TODO: Types + // Note: we're using the V2 API by calling this function, but our + // function contract requires a V1 response. We therefore have to + // convert it manually. + const response = await this.identityHashedLookup([[address, medium]], identityAccessToken); + const result = response.find(p => p.address === address); + if (!result) { + return {}; + } + const mapping = { + address, + medium, + mxid: result.mxid + + // We can't reasonably fill these parameters: + // not_before + // not_after + // ts + // signatures + }; + + return mapping; + } + + /** + * Looks up the public Matrix ID mappings for multiple 3PIDs. + * + * @param query - Array of arrays containing + * [medium, address] + * @param identityAccessToken - The `access_token` field of the Identity + * Server `/account/register` response (see {@link registerWithIdentityServer}). + * + * @returns Promise which resolves: Lookup results from IS. + * @returns Rejects: with an error response. + */ + async bulkLookupThreePids(query, identityAccessToken) { + // TODO: Types + // Note: we're using the V2 API by calling this function, but our + // function contract requires a V1 response. We therefore have to + // convert it manually. + const response = await this.identityHashedLookup( + // We have to reverse the query order to get [address, medium] pairs + query.map(p => [p[1], p[0]]), identityAccessToken); + const v1results = []; + for (const mapping of response) { + const originalQuery = query.find(p => p[1] === mapping.address); + if (!originalQuery) { + throw new Error("Identity sever returned unexpected results"); + } + v1results.push([originalQuery[0], + // medium + mapping.address, mapping.mxid]); + } + return { + threepids: v1results + }; + } + + /** + * Get account info from the identity server. This is useful as a neutral check + * to verify that other APIs are likely to approve access by testing that the + * token is valid, terms have been agreed, etc. + * + * @param identityAccessToken - The `access_token` field of the Identity + * Server `/account/register` response (see {@link registerWithIdentityServer}). + * + * @returns Promise which resolves: an object with account info. + * @returns Rejects: with an error response. + */ + getIdentityAccount(identityAccessToken) { + // TODO: Types + return this.http.idServerRequest(_httpApi.Method.Get, "/account", undefined, _httpApi.IdentityPrefix.V2, identityAccessToken); + } + + /** + * Send an event to a specific list of devices. + * This is a low-level API that simply wraps the HTTP API + * call to send to-device messages. We recommend using + * queueToDevice() which is a higher level API. + * + * @param eventType - type of event to send + * content to send. Map from user_id to device_id to content object. + * @param txnId - transaction id. One will be made up if not + * supplied. + * @returns Promise which resolves: to an empty object `{}` + */ + sendToDevice(eventType, contentMap, txnId) { + const path = utils.encodeUri("/sendToDevice/$eventType/$txnId", { + $eventType: eventType, + $txnId: txnId ? txnId : this.makeTxnId() + }); + const body = { + messages: utils.recursiveMapToObject(contentMap) + }; + const targets = new Map(); + for (const [userId, deviceMessages] of contentMap) { + targets.set(userId, Array.from(deviceMessages.keys())); + } + _logger.logger.log(`PUT ${path}`, targets); + return this.http.authedRequest(_httpApi.Method.Put, path, undefined, body); + } + + /** + * Sends events directly to specific devices using Matrix's to-device + * messaging system. The batch will be split up into appropriately sized + * batches for sending and stored in the store so they can be retried + * later if they fail to send. Retries will happen automatically. + * @param batch - The to-device messages to send + */ + queueToDevice(batch) { + return this.toDeviceMessageQueue.queueBatch(batch); + } + + /** + * Get the third party protocols that can be reached using + * this HS + * @returns Promise which resolves to the result object + */ + getThirdpartyProtocols() { + return this.http.authedRequest(_httpApi.Method.Get, "/thirdparty/protocols").then(response => { + // sanity check + if (!response || typeof response !== "object") { + throw new Error(`/thirdparty/protocols did not return an object: ${response}`); + } + return response; + }); + } + + /** + * Get information on how a specific place on a third party protocol + * may be reached. + * @param protocol - The protocol given in getThirdpartyProtocols() + * @param params - Protocol-specific parameters, as given in the + * response to getThirdpartyProtocols() + * @returns Promise which resolves to the result object + */ + getThirdpartyLocation(protocol, params) { + const path = utils.encodeUri("/thirdparty/location/$protocol", { + $protocol: protocol + }); + return this.http.authedRequest(_httpApi.Method.Get, path, params); + } + + /** + * Get information on how a specific user on a third party protocol + * may be reached. + * @param protocol - The protocol given in getThirdpartyProtocols() + * @param params - Protocol-specific parameters, as given in the + * response to getThirdpartyProtocols() + * @returns Promise which resolves to the result object + */ + getThirdpartyUser(protocol, params) { + // TODO: Types + const path = utils.encodeUri("/thirdparty/user/$protocol", { + $protocol: protocol + }); + return this.http.authedRequest(_httpApi.Method.Get, path, params); + } + getTerms(serviceType, baseUrl) { + // TODO: Types + const url = this.termsUrlForService(serviceType, baseUrl); + return this.http.requestOtherUrl(_httpApi.Method.Get, url); + } + agreeToTerms(serviceType, baseUrl, accessToken, termsUrls) { + const url = this.termsUrlForService(serviceType, baseUrl); + const headers = { + Authorization: "Bearer " + accessToken + }; + return this.http.requestOtherUrl(_httpApi.Method.Post, url, { + user_accepts: termsUrls + }, { + headers + }); + } + + /** + * Reports an event as inappropriate to the server, which may then notify the appropriate people. + * @param roomId - The room in which the event being reported is located. + * @param eventId - The event to report. + * @param score - The score to rate this content as where -100 is most offensive and 0 is inoffensive. + * @param reason - The reason the content is being reported. May be blank. + * @returns Promise which resolves to an empty object if successful + */ + reportEvent(roomId, eventId, score, reason) { + const path = utils.encodeUri("/rooms/$roomId/report/$eventId", { + $roomId: roomId, + $eventId: eventId + }); + return this.http.authedRequest(_httpApi.Method.Post, path, undefined, { + score, + reason + }); + } + + /** + * Fetches or paginates a room hierarchy as defined by MSC2946. + * Falls back gracefully to sourcing its data from `getSpaceSummary` if this API is not yet supported by the server. + * @param roomId - The ID of the space-room to use as the root of the summary. + * @param limit - The maximum number of rooms to return per page. + * @param maxDepth - The maximum depth in the tree from the root room to return. + * @param suggestedOnly - Whether to only return rooms with suggested=true. + * @param fromToken - The opaque token to paginate a previous request. + * @returns the response, with next_batch & rooms fields. + */ + getRoomHierarchy(roomId, limit, maxDepth, suggestedOnly = false, fromToken) { + const path = utils.encodeUri("/rooms/$roomId/hierarchy", { + $roomId: roomId + }); + const queryParams = { + suggested_only: String(suggestedOnly), + max_depth: maxDepth?.toString(), + from: fromToken, + limit: limit?.toString() + }; + return this.http.authedRequest(_httpApi.Method.Get, path, queryParams, undefined, { + prefix: _httpApi.ClientPrefix.V1 + }).catch(e => { + if (e.errcode === "M_UNRECOGNIZED") { + // fall back to the prefixed hierarchy API. + return this.http.authedRequest(_httpApi.Method.Get, path, queryParams, undefined, { + prefix: "/_matrix/client/unstable/org.matrix.msc2946" + }); + } + throw e; + }); + } + + /** + * Creates a new file tree space with the given name. The client will pick + * defaults for how it expects to be able to support the remaining API offered + * by the returned class. + * + * Note that this is UNSTABLE and may have breaking changes without notice. + * @param name - The name of the tree space. + * @returns Promise which resolves to the created space. + */ + async unstableCreateFileTree(name) { + const { + room_id: roomId + } = await this.createRoom({ + name: name, + preset: _partials.Preset.PrivateChat, + power_level_content_override: _objectSpread(_objectSpread({}, _MSC3089TreeSpace.DEFAULT_TREE_POWER_LEVELS_TEMPLATE), {}, { + users: { + [this.getUserId()]: 100 + } + }), + creation_content: { + [_event2.RoomCreateTypeField]: _event2.RoomType.Space + }, + initial_state: [{ + type: _event2.UNSTABLE_MSC3088_PURPOSE.name, + state_key: _event2.UNSTABLE_MSC3089_TREE_SUBTYPE.name, + content: { + [_event2.UNSTABLE_MSC3088_ENABLED.name]: true + } + }, { + type: _event2.EventType.RoomEncryption, + state_key: "", + content: { + algorithm: olmlib.MEGOLM_ALGORITHM + } + }] + }); + return new _MSC3089TreeSpace.MSC3089TreeSpace(this, roomId); + } + + /** + * Gets a reference to a tree space, if the room ID given is a tree space. If the room + * does not appear to be a tree space then null is returned. + * + * Note that this is UNSTABLE and may have breaking changes without notice. + * @param roomId - The room ID to get a tree space reference for. + * @returns The tree space, or null if not a tree space. + */ + unstableGetFileTreeSpace(roomId) { + const room = this.getRoom(roomId); + if (room?.getMyMembership() !== "join") return null; + const createEvent = room.currentState.getStateEvents(_event2.EventType.RoomCreate, ""); + const purposeEvent = room.currentState.getStateEvents(_event2.UNSTABLE_MSC3088_PURPOSE.name, _event2.UNSTABLE_MSC3089_TREE_SUBTYPE.name); + if (!createEvent) throw new Error("Expected single room create event"); + if (!purposeEvent?.getContent()?.[_event2.UNSTABLE_MSC3088_ENABLED.name]) return null; + if (createEvent.getContent()?.[_event2.RoomCreateTypeField] !== _event2.RoomType.Space) return null; + return new _MSC3089TreeSpace.MSC3089TreeSpace(this, roomId); + } + + /** + * Perform a single MSC3575 sliding sync request. + * @param req - The request to make. + * @param proxyBaseUrl - The base URL for the sliding sync proxy. + * @param abortSignal - Optional signal to abort request mid-flight. + * @returns The sliding sync response, or a standard error. + * @throws on non 2xx status codes with an object with a field "httpStatus":number. + */ + slidingSync(req, proxyBaseUrl, abortSignal) { + const qps = {}; + if (req.pos) { + qps.pos = req.pos; + delete req.pos; + } + if (req.timeout) { + qps.timeout = req.timeout; + delete req.timeout; + } + const clientTimeout = req.clientTimeout; + delete req.clientTimeout; + return this.http.authedRequest(_httpApi.Method.Post, "/sync", qps, req, { + prefix: "/_matrix/client/unstable/org.matrix.msc3575", + baseUrl: proxyBaseUrl, + localTimeoutMs: clientTimeout, + abortSignal + }); + } + + /** + * @deprecated use supportsThreads() instead + */ + supportsExperimentalThreads() { + _logger.logger.warn(`supportsExperimentalThreads() is deprecated, use supportThreads() instead`); + return this.clientOpts?.experimentalThreadSupport || false; + } + + /** + * A helper to determine thread support + * @returns a boolean to determine if threads are enabled + */ + supportsThreads() { + return this.clientOpts?.threadSupport || false; + } + + /** + * A helper to determine intentional mentions support + * @returns a boolean to determine if intentional mentions are enabled + * @experimental + */ + supportsIntentionalMentions() { + return this.clientOpts?.intentionalMentions || false; + } + + /** + * Fetches the summary of a room as defined by an initial version of MSC3266 and implemented in Synapse + * Proposed at https://github.com/matrix-org/matrix-doc/pull/3266 + * @param roomIdOrAlias - The ID or alias of the room to get the summary of. + * @param via - The list of servers which know about the room if only an ID was provided. + */ + async getRoomSummary(roomIdOrAlias, via) { + const path = utils.encodeUri("/rooms/$roomid/summary", { + $roomid: roomIdOrAlias + }); + return this.http.authedRequest(_httpApi.Method.Get, path, { + via + }, undefined, { + prefix: "/_matrix/client/unstable/im.nheko.summary" + }); + } + + /** + * Processes a list of threaded events and adds them to their respective timelines + * @param room - the room the adds the threaded events + * @param threadedEvents - an array of the threaded events + * @param toStartOfTimeline - the direction in which we want to add the events + */ + processThreadEvents(room, threadedEvents, toStartOfTimeline) { + room.processThreadedEvents(threadedEvents, toStartOfTimeline); + } + + /** + * Processes a list of thread roots and creates a thread model + * @param room - the room to create the threads in + * @param threadedEvents - an array of thread roots + * @param toStartOfTimeline - the direction + */ + processThreadRoots(room, threadedEvents, toStartOfTimeline) { + room.processThreadRoots(threadedEvents, toStartOfTimeline); + } + processBeaconEvents(room, events) { + this.processAggregatedTimelineEvents(room, events); + } + + /** + * Calls aggregation functions for event types that are aggregated + * Polls and location beacons + * @param room - room the events belong to + * @param events - timeline events to be processed + * @returns + */ + processAggregatedTimelineEvents(room, events) { + if (!events?.length) return; + if (!room) return; + room.currentState.processBeaconEvents(events, this); + room.processPollEvents(events); + } + + /** + * Fetches information about the user for the configured access token. + */ + async whoami() { + return this.http.authedRequest(_httpApi.Method.Get, "/account/whoami"); + } + + /** + * Find the event_id closest to the given timestamp in the given direction. + * @returns Resolves: A promise of an object containing the event_id and + * origin_server_ts of the closest event to the timestamp in the given direction + * @returns Rejects: when the request fails (module:http-api.MatrixError) + */ + async timestampToEvent(roomId, timestamp, dir) { + const path = utils.encodeUri("/rooms/$roomId/timestamp_to_event", { + $roomId: roomId + }); + const queryParams = { + ts: timestamp.toString(), + dir: dir + }; + try { + return await this.http.authedRequest(_httpApi.Method.Get, path, queryParams, undefined, { + prefix: _httpApi.ClientPrefix.V1 + }); + } catch (err) { + // Fallback to the prefixed unstable endpoint. Since the stable endpoint is + // new, we should also try the unstable endpoint before giving up. We can + // remove this fallback request in a year (remove after 2023-11-28). + if (err.errcode === "M_UNRECOGNIZED" && ( + // XXX: The 400 status code check should be removed in the future + // when Synapse is compliant with MSC3743. + err.httpStatus === 400 || + // This the correct standard status code for an unsupported + // endpoint according to MSC3743. Not Found and Method Not Allowed + // both indicate that this endpoint+verb combination is + // not supported. + err.httpStatus === 404 || err.httpStatus === 405)) { + return await this.http.authedRequest(_httpApi.Method.Get, path, queryParams, undefined, { + prefix: "/_matrix/client/unstable/org.matrix.msc3030" + }); + } + throw err; + } + } +} + +/** + * recalculates an accurate notifications count on event decryption. + * Servers do not have enough knowledge about encrypted events to calculate an + * accurate notification_count + */ +exports.MatrixClient = MatrixClient; +_defineProperty(MatrixClient, "RESTORE_BACKUP_ERROR_BAD_KEY", "RESTORE_BACKUP_ERROR_BAD_KEY"); +function fixNotificationCountOnDecryption(cli, event) { + const ourUserId = cli.getUserId(); + const eventId = event.getId(); + const room = cli.getRoom(event.getRoomId()); + if (!room || !ourUserId || !eventId) return; + const oldActions = event.getPushActions(); + const actions = cli.getPushActionsForEvent(event, true); + const isThreadEvent = !!event.threadRootId && !event.isThreadRoot; + const currentHighlightCount = room.getUnreadCountForEventContext(_room.NotificationCountType.Highlight, event); + + // Ensure the unread counts are kept up to date if the event is encrypted + // We also want to make sure that the notification count goes up if we already + // have encrypted events to avoid other code from resetting 'highlight' to zero. + const oldHighlight = !!oldActions?.tweaks?.highlight; + const newHighlight = !!actions?.tweaks?.highlight; + let hasReadEvent; + if (isThreadEvent) { + const thread = room.getThread(event.threadRootId); + hasReadEvent = thread ? thread.hasUserReadEvent(ourUserId, eventId) : + // If the thread object does not exist in the room yet, we don't + // want to calculate notification for this event yet. We have not + // restored the read receipts yet and can't accurately calculate + // notifications at this stage. + // + // This issue can likely go away when MSC3874 is implemented + true; + } else { + hasReadEvent = room.hasUserReadEvent(ourUserId, eventId); + } + if (hasReadEvent) { + // If the event has been read, ignore it. + return; + } + if (oldHighlight !== newHighlight || currentHighlightCount > 0) { + // TODO: Handle mentions received while the client is offline + // See also https://github.com/vector-im/element-web/issues/9069 + let newCount = currentHighlightCount; + if (newHighlight && !oldHighlight) newCount++; + if (!newHighlight && oldHighlight) newCount--; + if (isThreadEvent) { + room.setThreadUnreadNotificationCount(event.threadRootId, _room.NotificationCountType.Highlight, newCount); + } else { + room.setUnreadNotificationCount(_room.NotificationCountType.Highlight, newCount); + } + } + + // Total count is used to typically increment a room notification counter, but not loudly highlight it. + const currentTotalCount = room.getUnreadCountForEventContext(_room.NotificationCountType.Total, event); + + // `notify` is used in practice for incrementing the total count + const newNotify = !!actions?.notify; + + // The room total count is NEVER incremented by the server for encrypted rooms. We basically ignore + // the server here as it's always going to tell us to increment for encrypted events. + if (newNotify) { + if (isThreadEvent) { + room.setThreadUnreadNotificationCount(event.threadRootId, _room.NotificationCountType.Total, currentTotalCount + 1); + } else { + room.setUnreadNotificationCount(_room.NotificationCountType.Total, currentTotalCount + 1); + } + } +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/common-crypto/CryptoBackend.js b/comm/chat/protocols/matrix/lib/matrix-sdk/common-crypto/CryptoBackend.js new file mode 100644 index 0000000000..430afc16cd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/common-crypto/CryptoBackend.js @@ -0,0 +1,5 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/content-helpers.js b/comm/chat/protocols/matrix/lib/matrix-sdk/content-helpers.js new file mode 100644 index 0000000000..0ca2390b43 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/content-helpers.js @@ -0,0 +1,266 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.makeBeaconInfoContent = exports.makeBeaconContent = exports.getTextForLocationEvent = void 0; +exports.makeEmoteMessage = makeEmoteMessage; +exports.makeHtmlEmote = makeHtmlEmote; +exports.makeHtmlMessage = makeHtmlMessage; +exports.makeHtmlNotice = makeHtmlNotice; +exports.makeLocationContent = void 0; +exports.makeNotice = makeNotice; +exports.makeTextMessage = makeTextMessage; +exports.parseTopicContent = exports.parseLocationEvent = exports.parseBeaconInfoContent = exports.parseBeaconContent = exports.makeTopicContent = void 0; +var _event = require("./@types/event"); +var _extensible_events = require("./@types/extensible_events"); +var _utilities = require("./extensible_events_v1/utilities"); +var _location = require("./@types/location"); +var _topic = require("./@types/topic"); +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2018 - 2022 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/** + * Generates the content for a HTML Message event + * @param body - the plaintext body of the message + * @param htmlBody - the HTML representation of the message + * @returns + */ +function makeHtmlMessage(body, htmlBody) { + return { + msgtype: _event.MsgType.Text, + format: "org.matrix.custom.html", + body: body, + formatted_body: htmlBody + }; +} + +/** + * Generates the content for a HTML Notice event + * @param body - the plaintext body of the notice + * @param htmlBody - the HTML representation of the notice + * @returns + */ +function makeHtmlNotice(body, htmlBody) { + return { + msgtype: _event.MsgType.Notice, + format: "org.matrix.custom.html", + body: body, + formatted_body: htmlBody + }; +} + +/** + * Generates the content for a HTML Emote event + * @param body - the plaintext body of the emote + * @param htmlBody - the HTML representation of the emote + * @returns + */ +function makeHtmlEmote(body, htmlBody) { + return { + msgtype: _event.MsgType.Emote, + format: "org.matrix.custom.html", + body: body, + formatted_body: htmlBody + }; +} + +/** + * Generates the content for a Plaintext Message event + * @param body - the plaintext body of the emote + * @returns + */ +function makeTextMessage(body) { + return { + msgtype: _event.MsgType.Text, + body: body + }; +} + +/** + * Generates the content for a Plaintext Notice event + * @param body - the plaintext body of the notice + * @returns + */ +function makeNotice(body) { + return { + msgtype: _event.MsgType.Notice, + body: body + }; +} + +/** + * Generates the content for a Plaintext Emote event + * @param body - the plaintext body of the emote + * @returns + */ +function makeEmoteMessage(body) { + return { + msgtype: _event.MsgType.Emote, + body: body + }; +} + +/** Location content helpers */ + +const getTextForLocationEvent = (uri, assetType, timestamp, description) => { + const date = `at ${new Date(timestamp).toISOString()}`; + const assetName = assetType === _location.LocationAssetType.Self ? "User" : undefined; + const quotedDescription = description ? `"${description}"` : undefined; + return [assetName, "Location", quotedDescription, uri, date].filter(Boolean).join(" "); +}; + +/** + * Generates the content for a Location event + * @param uri - a geo:// uri for the location + * @param timestamp - the timestamp when the location was correct (milliseconds since the UNIX epoch) + * @param description - the (optional) label for this location on the map + * @param assetType - the (optional) asset type of this location e.g. "m.self" + * @param text - optional. A text for the location + */ +exports.getTextForLocationEvent = getTextForLocationEvent; +const makeLocationContent = (text, uri, timestamp, description, assetType) => { + const defaultedText = text ?? getTextForLocationEvent(uri, assetType || _location.LocationAssetType.Self, timestamp, description); + const timestampEvent = timestamp ? { + [_location.M_TIMESTAMP.name]: timestamp + } : {}; + return _objectSpread({ + msgtype: _event.MsgType.Location, + body: defaultedText, + geo_uri: uri, + [_location.M_LOCATION.name]: { + description, + uri + }, + [_location.M_ASSET.name]: { + type: assetType || _location.LocationAssetType.Self + }, + [_extensible_events.M_TEXT.name]: defaultedText + }, timestampEvent); +}; + +/** + * Parse location event content and transform to + * a backwards compatible modern m.location event format + */ +exports.makeLocationContent = makeLocationContent; +const parseLocationEvent = wireEventContent => { + const location = _location.M_LOCATION.findIn(wireEventContent); + const asset = _location.M_ASSET.findIn(wireEventContent); + const timestamp = _location.M_TIMESTAMP.findIn(wireEventContent); + const text = _extensible_events.M_TEXT.findIn(wireEventContent); + const geoUri = location?.uri ?? wireEventContent?.geo_uri; + const description = location?.description; + const assetType = asset?.type ?? _location.LocationAssetType.Self; + const fallbackText = text ?? wireEventContent.body; + return makeLocationContent(fallbackText, geoUri, timestamp ?? undefined, description, assetType); +}; + +/** + * Topic event helpers + */ +exports.parseLocationEvent = parseLocationEvent; +const makeTopicContent = (topic, htmlTopic) => { + const renderings = [{ + body: topic, + mimetype: "text/plain" + }]; + if ((0, _utilities.isProvided)(htmlTopic)) { + renderings.push({ + body: htmlTopic, + mimetype: "text/html" + }); + } + return { + topic, + [_topic.M_TOPIC.name]: renderings + }; +}; +exports.makeTopicContent = makeTopicContent; +const parseTopicContent = content => { + const mtopic = _topic.M_TOPIC.findIn(content); + if (!Array.isArray(mtopic)) { + return { + text: content.topic + }; + } + const text = mtopic?.find(r => !(0, _utilities.isProvided)(r.mimetype) || r.mimetype === "text/plain")?.body ?? content.topic; + const html = mtopic?.find(r => r.mimetype === "text/html")?.body; + return { + text, + html + }; +}; + +/** + * Beacon event helpers + */ +exports.parseTopicContent = parseTopicContent; +const makeBeaconInfoContent = (timeout, isLive, description, assetType, timestamp) => ({ + description, + timeout, + live: isLive, + [_location.M_TIMESTAMP.name]: timestamp || Date.now(), + [_location.M_ASSET.name]: { + type: assetType ?? _location.LocationAssetType.Self + } +}); +exports.makeBeaconInfoContent = makeBeaconInfoContent; +/** + * Flatten beacon info event content + */ +const parseBeaconInfoContent = content => { + const { + description, + timeout, + live + } = content; + const timestamp = _location.M_TIMESTAMP.findIn(content) ?? undefined; + const asset = _location.M_ASSET.findIn(content); + return { + description, + timeout, + live, + assetType: asset?.type, + timestamp + }; +}; +exports.parseBeaconInfoContent = parseBeaconInfoContent; +const makeBeaconContent = (uri, timestamp, beaconInfoEventId, description) => ({ + [_location.M_LOCATION.name]: { + description, + uri + }, + [_location.M_TIMESTAMP.name]: timestamp, + "m.relates_to": { + rel_type: _extensible_events.REFERENCE_RELATION.name, + event_id: beaconInfoEventId + } +}); +exports.makeBeaconContent = makeBeaconContent; +const parseBeaconContent = content => { + const location = _location.M_LOCATION.findIn(content); + const timestamp = _location.M_TIMESTAMP.findIn(content) ?? undefined; + return { + description: location?.description, + uri: location?.uri, + timestamp + }; +}; +exports.parseBeaconContent = parseBeaconContent;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/content-repo.js b/comm/chat/protocols/matrix/lib/matrix-sdk/content-repo.js new file mode 100644 index 0000000000..2e17f8c71c --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/content-repo.js @@ -0,0 +1,74 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.getHttpUriForMxc = getHttpUriForMxc; +var _utils = require("./utils"); +/* +Copyright 2015 - 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Get the HTTP URL for an MXC URI. + * @param baseUrl - The base homeserver url which has a content repo. + * @param mxc - The mxc:// URI. + * @param width - The desired width of the thumbnail. + * @param height - The desired height of the thumbnail. + * @param resizeMethod - The thumbnail resize method to use, either + * "crop" or "scale". + * @param allowDirectLinks - If true, return any non-mxc URLs + * directly. Fetching such URLs will leak information about the user to + * anyone they share a room with. If false, will return the emptry string + * for such URLs. + * @returns The complete URL to the content. + */ +function getHttpUriForMxc(baseUrl, mxc, width, height, resizeMethod, allowDirectLinks = false) { + if (typeof mxc !== "string" || !mxc) { + return ""; + } + if (mxc.indexOf("mxc://") !== 0) { + if (allowDirectLinks) { + return mxc; + } else { + return ""; + } + } + let serverAndMediaId = mxc.slice(6); // strips mxc:// + let prefix = "/_matrix/media/r0/download/"; + const params = {}; + if (width) { + params["width"] = Math.round(width).toString(); + } + if (height) { + params["height"] = Math.round(height).toString(); + } + if (resizeMethod) { + params["method"] = resizeMethod; + } + if (Object.keys(params).length > 0) { + // these are thumbnailing params so they probably want the + // thumbnailing API... + prefix = "/_matrix/media/r0/thumbnail/"; + } + const fragmentOffset = serverAndMediaId.indexOf("#"); + let fragment = ""; + if (fragmentOffset >= 0) { + fragment = serverAndMediaId.slice(fragmentOffset); + serverAndMediaId = serverAndMediaId.slice(0, fragmentOffset); + } + const urlParams = Object.keys(params).length === 0 ? "" : "?" + (0, _utils.encodeParams)(params); + return baseUrl + prefix + serverAndMediaId + urlParams + fragment; +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto-api.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto-api.js new file mode 100644 index 0000000000..cb1d0427e9 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto-api.js @@ -0,0 +1,105 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +var _exportNames = { + CrossSigningKey: true, + DeviceVerificationStatus: true +}; +exports.DeviceVerificationStatus = exports.CrossSigningKey = void 0; +var _verification = require("./crypto-api/verification"); +Object.keys(_verification).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _verification[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _verification[key]; + } + }); +}); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +/** Types of cross-signing key */ +let CrossSigningKey = /*#__PURE__*/function (CrossSigningKey) { + CrossSigningKey["Master"] = "master"; + CrossSigningKey["SelfSigning"] = "self_signing"; + CrossSigningKey["UserSigning"] = "user_signing"; + return CrossSigningKey; +}({}); +/** + * Public interface to the cryptography parts of the js-sdk + * + * @remarks Currently, this is a work-in-progress. In time, more methods will be added here. + */ +/** + * Options object for `CryptoApi.bootstrapCrossSigning`. + */ +exports.CrossSigningKey = CrossSigningKey; +class DeviceVerificationStatus { + constructor(opts) { + /** + * True if this device has been signed by its owner (and that signature verified). + * + * This doesn't necessarily mean that we have verified the device, since we may not have verified the + * owner's cross-signing key. + */ + _defineProperty(this, "signedByOwner", void 0); + /** + * True if this device has been verified via cross signing. + * + * This does *not* take into account `trustCrossSignedDevices`. + */ + _defineProperty(this, "crossSigningVerified", void 0); + /** + * TODO: tofu magic wtf does this do? + */ + _defineProperty(this, "tofu", void 0); + /** + * True if the device has been marked as locally verified. + */ + _defineProperty(this, "localVerified", void 0); + /** + * True if the client has been configured to trust cross-signed devices via {@link CryptoApi#setTrustCrossSignedDevices}. + */ + _defineProperty(this, "trustCrossSignedDevices", void 0); + this.signedByOwner = opts.signedByOwner ?? false; + this.crossSigningVerified = opts.crossSigningVerified ?? false; + this.tofu = opts.tofu ?? false; + this.localVerified = opts.localVerified ?? false; + this.trustCrossSignedDevices = opts.trustCrossSignedDevices ?? false; + } + + /** + * Check if we should consider this device "verified". + * + * A device is "verified" if either: + * * it has been manually marked as such via {@link MatrixClient#setDeviceVerified}. + * * it has been cross-signed with a verified signing key, **and** the client has been configured to trust + * cross-signed devices via {@link Crypto.CryptoApi#setTrustCrossSignedDevices}. + * + * @returns true if this device is verified via any means. + */ + isVerified() { + return this.localVerified || this.trustCrossSignedDevices && this.crossSigningVerified; + } +} +exports.DeviceVerificationStatus = DeviceVerificationStatus;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto-api/verification.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto-api/verification.js new file mode 100644 index 0000000000..0f1f97e7ef --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto-api/verification.js @@ -0,0 +1,46 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.VerifierEvent = void 0; +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +/** Events emitted by `Verifier`. */ +let VerifierEvent = /*#__PURE__*/function (VerifierEvent) { + VerifierEvent["Cancel"] = "cancel"; + VerifierEvent["ShowSas"] = "show_sas"; + VerifierEvent["ShowReciprocateQr"] = "show_reciprocate_qr"; + return VerifierEvent; +}({}); +/** Listener type map for {@link VerifierEvent}s. */ +/** + * Callbacks for user actions while a QR code is displayed. + * + * This is exposed as the payload of a `VerifierEvent.ShowReciprocateQr` event, or can be retrieved directly from the + * verifier as `reciprocateQREvent`. + */ +/** + * Callbacks for user actions while a SAS is displayed. + * + * This is exposed as the payload of a `VerifierEvent.ShowSas` event, or directly from the verifier as `sasEvent`. + */ +/** A generated SAS to be shown to the user, in alternative formats */ +/** + * An emoji for the generated SAS. A tuple `[emoji, name]` where `emoji` is the emoji itself and `name` is the + * English name. + */ +exports.VerifierEvent = VerifierEvent;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/CrossSigning.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/CrossSigning.js new file mode 100644 index 0000000000..be8c9607f4 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/CrossSigning.js @@ -0,0 +1,703 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.UserTrustLevel = exports.DeviceTrustLevel = exports.CrossSigningLevel = exports.CrossSigningInfo = void 0; +exports.createCryptoStoreCacheCallbacks = createCryptoStoreCacheCallbacks; +exports.requestKeysDuringVerification = requestKeysDuringVerification; +var _olmlib = require("./olmlib"); +var _logger = require("../logger"); +var _indexeddbCryptoStore = require("../crypto/store/indexeddb-crypto-store"); +var _aes = require("./aes"); +var _cryptoApi = require("../crypto-api"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ /** + * Cross signing methods + */ +const KEY_REQUEST_TIMEOUT_MS = 1000 * 60; +function publicKeyFromKeyInfo(keyInfo) { + // `keys` is an object with { [`ed25519:${pubKey}`]: pubKey } + // We assume only a single key, and we want the bare form without type + // prefix, so we select the values. + return Object.values(keyInfo.keys)[0]; +} +class CrossSigningInfo { + /** + * Information about a user's cross-signing keys + * + * @param userId - the user that the information is about + * @param callbacks - Callbacks used to interact with the app + * Requires getCrossSigningKey and saveCrossSigningKeys + * @param cacheCallbacks - Callbacks used to interact with the cache + */ + constructor(userId, callbacks = {}, cacheCallbacks = {}) { + this.userId = userId; + this.callbacks = callbacks; + this.cacheCallbacks = cacheCallbacks; + _defineProperty(this, "keys", {}); + _defineProperty(this, "firstUse", true); + // This tracks whether we've ever verified this user with any identity. + // When you verify a user, any devices online at the time that receive + // the verifying signature via the homeserver will latch this to true + // and can use it in the future to detect cases where the user has + // become unverified later for any reason. + _defineProperty(this, "crossSigningVerifiedBefore", false); + } + static fromStorage(obj, userId) { + const res = new CrossSigningInfo(userId); + for (const prop in obj) { + if (obj.hasOwnProperty(prop)) { + // @ts-ignore - ts doesn't like this and nor should we + res[prop] = obj[prop]; + } + } + return res; + } + toStorage() { + return { + keys: this.keys, + firstUse: this.firstUse, + crossSigningVerifiedBefore: this.crossSigningVerifiedBefore + }; + } + + /** + * Calls the app callback to ask for a private key + * + * @param type - The key type ("master", "self_signing", or "user_signing") + * @param expectedPubkey - The matching public key or undefined to use + * the stored public key for the given key type. + * @returns An array with [ public key, Olm.PkSigning ] + */ + async getCrossSigningKey(type, expectedPubkey) { + const shouldCache = ["master", "self_signing", "user_signing"].indexOf(type) >= 0; + if (!this.callbacks.getCrossSigningKey) { + throw new Error("No getCrossSigningKey callback supplied"); + } + if (expectedPubkey === undefined) { + expectedPubkey = this.getId(type); + } + function validateKey(key) { + if (!key) return; + const signing = new global.Olm.PkSigning(); + const gotPubkey = signing.init_with_seed(key); + if (gotPubkey === expectedPubkey) { + return [gotPubkey, signing]; + } + signing.free(); + } + let privkey = null; + if (this.cacheCallbacks.getCrossSigningKeyCache && shouldCache) { + privkey = await this.cacheCallbacks.getCrossSigningKeyCache(type, expectedPubkey); + } + const cacheresult = validateKey(privkey); + if (cacheresult) { + return cacheresult; + } + privkey = await this.callbacks.getCrossSigningKey(type, expectedPubkey); + const result = validateKey(privkey); + if (result) { + if (this.cacheCallbacks.storeCrossSigningKeyCache && shouldCache) { + await this.cacheCallbacks.storeCrossSigningKeyCache(type, privkey); + } + return result; + } + + /* No keysource even returned a key */ + if (!privkey) { + throw new Error("getCrossSigningKey callback for " + type + " returned falsey"); + } + + /* We got some keys from the keysource, but none of them were valid */ + throw new Error("Key type " + type + " from getCrossSigningKey callback did not match"); + } + + /** + * Check whether the private keys exist in secret storage. + * XXX: This could be static, be we often seem to have an instance when we + * want to know this anyway... + * + * @param secretStorage - The secret store using account data + * @returns map of key name to key info the secret is encrypted + * with, or null if it is not present or not encrypted with a trusted + * key + */ + async isStoredInSecretStorage(secretStorage) { + // check what SSSS keys have encrypted the master key (if any) + const stored = (await secretStorage.isStored("m.cross_signing.master")) || {}; + // then check which of those SSSS keys have also encrypted the SSK and USK + function intersect(s) { + for (const k of Object.keys(stored)) { + if (!s[k]) { + delete stored[k]; + } + } + } + for (const type of ["self_signing", "user_signing"]) { + intersect((await secretStorage.isStored(`m.cross_signing.${type}`)) || {}); + } + return Object.keys(stored).length ? stored : null; + } + + /** + * Store private keys in secret storage for use by other devices. This is + * typically called in conjunction with the creation of new cross-signing + * keys. + * + * @param keys - The keys to store + * @param secretStorage - The secret store using account data + */ + static async storeInSecretStorage(keys, secretStorage) { + for (const [type, privateKey] of keys) { + const encodedKey = (0, _olmlib.encodeBase64)(privateKey); + await secretStorage.store(`m.cross_signing.${type}`, encodedKey); + } + } + + /** + * Get private keys from secret storage created by some other device. This + * also passes the private keys to the app-specific callback. + * + * @param type - The type of key to get. One of "master", + * "self_signing", or "user_signing". + * @param secretStorage - The secret store using account data + * @returns The private key + */ + static async getFromSecretStorage(type, secretStorage) { + const encodedKey = await secretStorage.get(`m.cross_signing.${type}`); + if (!encodedKey) { + return null; + } + return (0, _olmlib.decodeBase64)(encodedKey); + } + + /** + * Check whether the private keys exist in the local key cache. + * + * @param type - The type of key to get. One of "master", + * "self_signing", or "user_signing". Optional, will check all by default. + * @returns True if all keys are stored in the local cache. + */ + async isStoredInKeyCache(type) { + const cacheCallbacks = this.cacheCallbacks; + if (!cacheCallbacks) return false; + const types = type ? [type] : ["master", "self_signing", "user_signing"]; + for (const t of types) { + if (!(await cacheCallbacks.getCrossSigningKeyCache?.(t))) { + return false; + } + } + return true; + } + + /** + * Get cross-signing private keys from the local cache. + * + * @returns A map from key type (string) to private key (Uint8Array) + */ + async getCrossSigningKeysFromCache() { + const keys = new Map(); + const cacheCallbacks = this.cacheCallbacks; + if (!cacheCallbacks) return keys; + for (const type of ["master", "self_signing", "user_signing"]) { + const privKey = await cacheCallbacks.getCrossSigningKeyCache?.(type); + if (!privKey) { + continue; + } + keys.set(type, privKey); + } + return keys; + } + + /** + * Get the ID used to identify the user. This can also be used to test for + * the existence of a given key type. + * + * @param type - The type of key to get the ID of. One of "master", + * "self_signing", or "user_signing". Defaults to "master". + * + * @returns the ID + */ + getId(type = "master") { + if (!this.keys[type]) return null; + const keyInfo = this.keys[type]; + return publicKeyFromKeyInfo(keyInfo); + } + + /** + * Create new cross-signing keys for the given key types. The public keys + * will be held in this class, while the private keys are passed off to the + * `saveCrossSigningKeys` application callback. + * + * @param level - The key types to reset + */ + async resetKeys(level) { + if (!this.callbacks.saveCrossSigningKeys) { + throw new Error("No saveCrossSigningKeys callback supplied"); + } + + // If we're resetting the master key, we reset all keys + if (level === undefined || level & CrossSigningLevel.MASTER || !this.keys.master) { + level = CrossSigningLevel.MASTER | CrossSigningLevel.USER_SIGNING | CrossSigningLevel.SELF_SIGNING; + } else if (level === 0) { + return; + } + const privateKeys = {}; + const keys = {}; + let masterSigning; + let masterPub; + try { + if (level & CrossSigningLevel.MASTER) { + masterSigning = new global.Olm.PkSigning(); + privateKeys.master = masterSigning.generate_seed(); + masterPub = masterSigning.init_with_seed(privateKeys.master); + keys.master = { + user_id: this.userId, + usage: ["master"], + keys: { + ["ed25519:" + masterPub]: masterPub + } + }; + } else { + [masterPub, masterSigning] = await this.getCrossSigningKey("master"); + } + if (level & CrossSigningLevel.SELF_SIGNING) { + const sskSigning = new global.Olm.PkSigning(); + try { + privateKeys.self_signing = sskSigning.generate_seed(); + const sskPub = sskSigning.init_with_seed(privateKeys.self_signing); + keys.self_signing = { + user_id: this.userId, + usage: ["self_signing"], + keys: { + ["ed25519:" + sskPub]: sskPub + } + }; + (0, _olmlib.pkSign)(keys.self_signing, masterSigning, this.userId, masterPub); + } finally { + sskSigning.free(); + } + } + if (level & CrossSigningLevel.USER_SIGNING) { + const uskSigning = new global.Olm.PkSigning(); + try { + privateKeys.user_signing = uskSigning.generate_seed(); + const uskPub = uskSigning.init_with_seed(privateKeys.user_signing); + keys.user_signing = { + user_id: this.userId, + usage: ["user_signing"], + keys: { + ["ed25519:" + uskPub]: uskPub + } + }; + (0, _olmlib.pkSign)(keys.user_signing, masterSigning, this.userId, masterPub); + } finally { + uskSigning.free(); + } + } + Object.assign(this.keys, keys); + this.callbacks.saveCrossSigningKeys(privateKeys); + } finally { + if (masterSigning) { + masterSigning.free(); + } + } + } + + /** + * unsets the keys, used when another session has reset the keys, to disable cross-signing + */ + clearKeys() { + this.keys = {}; + } + setKeys(keys) { + const signingKeys = {}; + if (keys.master) { + if (keys.master.user_id !== this.userId) { + const error = "Mismatched user ID " + keys.master.user_id + " in master key from " + this.userId; + _logger.logger.error(error); + throw new Error(error); + } + if (!this.keys.master) { + // this is the first key we've seen, so first-use is true + this.firstUse = true; + } else if (publicKeyFromKeyInfo(keys.master) !== this.getId()) { + // this is a different key, so first-use is false + this.firstUse = false; + } // otherwise, same key, so no change + signingKeys.master = keys.master; + } else if (this.keys.master) { + signingKeys.master = this.keys.master; + } else { + throw new Error("Tried to set cross-signing keys without a master key"); + } + const masterKey = publicKeyFromKeyInfo(signingKeys.master); + + // verify signatures + if (keys.user_signing) { + if (keys.user_signing.user_id !== this.userId) { + const error = "Mismatched user ID " + keys.master.user_id + " in user_signing key from " + this.userId; + _logger.logger.error(error); + throw new Error(error); + } + try { + (0, _olmlib.pkVerify)(keys.user_signing, masterKey, this.userId); + } catch (e) { + _logger.logger.error("invalid signature on user-signing key"); + // FIXME: what do we want to do here? + throw e; + } + } + if (keys.self_signing) { + if (keys.self_signing.user_id !== this.userId) { + const error = "Mismatched user ID " + keys.master.user_id + " in self_signing key from " + this.userId; + _logger.logger.error(error); + throw new Error(error); + } + try { + (0, _olmlib.pkVerify)(keys.self_signing, masterKey, this.userId); + } catch (e) { + _logger.logger.error("invalid signature on self-signing key"); + // FIXME: what do we want to do here? + throw e; + } + } + + // if everything checks out, then save the keys + if (keys.master) { + this.keys.master = keys.master; + // if the master key is set, then the old self-signing and user-signing keys are obsolete + delete this.keys["self_signing"]; + delete this.keys["user_signing"]; + } + if (keys.self_signing) { + this.keys.self_signing = keys.self_signing; + } + if (keys.user_signing) { + this.keys.user_signing = keys.user_signing; + } + } + updateCrossSigningVerifiedBefore(isCrossSigningVerified) { + // It is critical that this value latches forward from false to true but + // never back to false to avoid a downgrade attack. + if (!this.crossSigningVerifiedBefore && isCrossSigningVerified) { + this.crossSigningVerifiedBefore = true; + } + } + async signObject(data, type) { + if (!this.keys[type]) { + throw new Error("Attempted to sign with " + type + " key but no such key present"); + } + const [pubkey, signing] = await this.getCrossSigningKey(type); + try { + (0, _olmlib.pkSign)(data, signing, this.userId, pubkey); + return data; + } finally { + signing.free(); + } + } + async signUser(key) { + if (!this.keys.user_signing) { + _logger.logger.info("No user signing key: not signing user"); + return; + } + return this.signObject(key.keys.master, "user_signing"); + } + async signDevice(userId, device) { + if (userId !== this.userId) { + throw new Error(`Trying to sign ${userId}'s device; can only sign our own device`); + } + if (!this.keys.self_signing) { + _logger.logger.info("No self signing key: not signing device"); + return; + } + return this.signObject({ + algorithms: device.algorithms, + keys: device.keys, + device_id: device.deviceId, + user_id: userId + }, "self_signing"); + } + + /** + * Check whether a given user is trusted. + * + * @param userCrossSigning - Cross signing info for user + * + * @returns + */ + checkUserTrust(userCrossSigning) { + // if we're checking our own key, then it's trusted if the master key + // and self-signing key match + if (this.userId === userCrossSigning.userId && this.getId() && this.getId() === userCrossSigning.getId() && this.getId("self_signing") && this.getId("self_signing") === userCrossSigning.getId("self_signing")) { + return new UserTrustLevel(true, true, this.firstUse); + } + if (!this.keys.user_signing) { + // If there's no user signing key, they can't possibly be verified. + // They may be TOFU trusted though. + return new UserTrustLevel(false, false, userCrossSigning.firstUse); + } + let userTrusted; + const userMaster = userCrossSigning.keys.master; + const uskId = this.getId("user_signing"); + try { + (0, _olmlib.pkVerify)(userMaster, uskId, this.userId); + userTrusted = true; + } catch (e) { + userTrusted = false; + } + return new UserTrustLevel(userTrusted, userCrossSigning.crossSigningVerifiedBefore, userCrossSigning.firstUse); + } + + /** + * Check whether a given device is trusted. + * + * @param userCrossSigning - Cross signing info for user + * @param device - The device to check + * @param localTrust - Whether the device is trusted locally + * @param trustCrossSignedDevices - Whether we trust cross signed devices + * + * @returns + */ + checkDeviceTrust(userCrossSigning, device, localTrust, trustCrossSignedDevices) { + const userTrust = this.checkUserTrust(userCrossSigning); + const userSSK = userCrossSigning.keys.self_signing; + if (!userSSK) { + // if the user has no self-signing key then we cannot make any + // trust assertions about this device from cross-signing + return new DeviceTrustLevel(false, false, localTrust, trustCrossSignedDevices); + } + const deviceObj = deviceToObject(device, userCrossSigning.userId); + try { + // if we can verify the user's SSK from their master key... + (0, _olmlib.pkVerify)(userSSK, userCrossSigning.getId(), userCrossSigning.userId); + // ...and this device's key from their SSK... + (0, _olmlib.pkVerify)(deviceObj, publicKeyFromKeyInfo(userSSK), userCrossSigning.userId); + // ...then we trust this device as much as far as we trust the user + return DeviceTrustLevel.fromUserTrustLevel(userTrust, localTrust, trustCrossSignedDevices); + } catch (e) { + return new DeviceTrustLevel(false, false, localTrust, trustCrossSignedDevices); + } + } + + /** + * @returns Cache callbacks + */ + getCacheCallbacks() { + return this.cacheCallbacks; + } +} +exports.CrossSigningInfo = CrossSigningInfo; +function deviceToObject(device, userId) { + return { + algorithms: device.algorithms, + keys: device.keys, + device_id: device.deviceId, + user_id: userId, + signatures: device.signatures + }; +} +let CrossSigningLevel = /*#__PURE__*/function (CrossSigningLevel) { + CrossSigningLevel[CrossSigningLevel["MASTER"] = 4] = "MASTER"; + CrossSigningLevel[CrossSigningLevel["USER_SIGNING"] = 2] = "USER_SIGNING"; + CrossSigningLevel[CrossSigningLevel["SELF_SIGNING"] = 1] = "SELF_SIGNING"; + return CrossSigningLevel; +}({}); +/** + * Represents the ways in which we trust a user + */ +exports.CrossSigningLevel = CrossSigningLevel; +class UserTrustLevel { + constructor(crossSigningVerified, crossSigningVerifiedBefore, tofu) { + this.crossSigningVerified = crossSigningVerified; + this.crossSigningVerifiedBefore = crossSigningVerifiedBefore; + this.tofu = tofu; + } + + /** + * @returns true if this user is verified via any means + */ + isVerified() { + return this.isCrossSigningVerified(); + } + + /** + * @returns true if this user is verified via cross signing + */ + isCrossSigningVerified() { + return this.crossSigningVerified; + } + + /** + * @returns true if we ever verified this user before (at least for + * the history of verifications observed by this device). + */ + wasCrossSigningVerified() { + return this.crossSigningVerifiedBefore; + } + + /** + * @returns true if this user's key is trusted on first use + */ + isTofu() { + return this.tofu; + } +} + +/** + * Represents the ways in which we trust a device. + * + * @deprecated Use {@link DeviceVerificationStatus}. + */ +exports.UserTrustLevel = UserTrustLevel; +class DeviceTrustLevel extends _cryptoApi.DeviceVerificationStatus { + constructor(crossSigningVerified, tofu, localVerified, trustCrossSignedDevices, signedByOwner = false) { + super({ + crossSigningVerified, + tofu, + localVerified, + trustCrossSignedDevices, + signedByOwner + }); + } + static fromUserTrustLevel(userTrustLevel, localVerified, trustCrossSignedDevices) { + return new DeviceTrustLevel(userTrustLevel.isCrossSigningVerified(), userTrustLevel.isTofu(), localVerified, trustCrossSignedDevices, true); + } + + /** + * @returns true if this device is verified via cross signing + */ + isCrossSigningVerified() { + return this.crossSigningVerified; + } + + /** + * @returns true if this device is verified locally + */ + isLocallyVerified() { + return this.localVerified; + } + + /** + * @returns true if this device is trusted from a user's key + * that is trusted on first use + */ + isTofu() { + return this.tofu; + } +} +exports.DeviceTrustLevel = DeviceTrustLevel; +function createCryptoStoreCacheCallbacks(store, olmDevice) { + return { + getCrossSigningKeyCache: async function (type, _expectedPublicKey) { + const key = await new Promise(resolve => { + store.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => { + store.getSecretStorePrivateKey(txn, resolve, type); + }); + }); + if (key && key.ciphertext) { + const pickleKey = Buffer.from(olmDevice.pickleKey); + const decrypted = await (0, _aes.decryptAES)(key, pickleKey, type); + return (0, _olmlib.decodeBase64)(decrypted); + } else { + return key; + } + }, + storeCrossSigningKeyCache: async function (type, key) { + if (!(key instanceof Uint8Array)) { + throw new Error(`storeCrossSigningKeyCache expects Uint8Array, got ${key}`); + } + const pickleKey = Buffer.from(olmDevice.pickleKey); + const encryptedKey = await (0, _aes.encryptAES)((0, _olmlib.encodeBase64)(key), pickleKey, type); + return store.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => { + store.storeSecretStorePrivateKey(txn, type, encryptedKey); + }); + } + }; +} +/** + * Request cross-signing keys from another device during verification. + * + * @param baseApis - base Matrix API interface + * @param userId - The user ID being verified + * @param deviceId - The device ID being verified + */ +async function requestKeysDuringVerification(baseApis, userId, deviceId) { + // If this is a self-verification, ask the other party for keys + if (baseApis.getUserId() !== userId) { + return; + } + _logger.logger.log("Cross-signing: Self-verification done; requesting keys"); + // This happens asynchronously, and we're not concerned about waiting for + // it. We return here in order to test. + return new Promise((resolve, reject) => { + const client = baseApis; + const original = client.crypto.crossSigningInfo; + + // We already have all of the infrastructure we need to validate and + // cache cross-signing keys, so instead of replicating that, here we set + // up callbacks that request them from the other device and call + // CrossSigningInfo.getCrossSigningKey() to validate/cache + const crossSigning = new CrossSigningInfo(original.userId, { + getCrossSigningKey: async type => { + _logger.logger.debug("Cross-signing: requesting secret", type, deviceId); + const { + promise + } = client.requestSecret(`m.cross_signing.${type}`, [deviceId]); + const result = await promise; + const decoded = (0, _olmlib.decodeBase64)(result); + return Uint8Array.from(decoded); + } + }, original.getCacheCallbacks()); + crossSigning.keys = original.keys; + + // XXX: get all keys out if we get one key out + // https://github.com/vector-im/element-web/issues/12604 + // then change here to reject on the timeout + // Requests can be ignored, so don't wait around forever + const timeout = new Promise(resolve => { + setTimeout(resolve, KEY_REQUEST_TIMEOUT_MS, new Error("Timeout")); + }); + + // also request and cache the key backup key + const backupKeyPromise = (async () => { + const cachedKey = await client.crypto.getSessionBackupPrivateKey(); + if (!cachedKey) { + _logger.logger.info("No cached backup key found. Requesting..."); + const secretReq = client.requestSecret("m.megolm_backup.v1", [deviceId]); + const base64Key = await secretReq.promise; + _logger.logger.info("Got key backup key, decoding..."); + const decodedKey = (0, _olmlib.decodeBase64)(base64Key); + _logger.logger.info("Decoded backup key, storing..."); + await client.crypto.storeSessionBackupPrivateKey(Uint8Array.from(decodedKey)); + _logger.logger.info("Backup key stored. Starting backup restore..."); + const backupInfo = await client.getKeyBackupVersion(); + // no need to await for this - just let it go in the bg + client.restoreKeyBackupWithCache(undefined, undefined, backupInfo).then(() => { + _logger.logger.info("Backup restored."); + }); + } + })(); + + // We call getCrossSigningKey() for its side-effects + Promise.race([Promise.all([crossSigning.getCrossSigningKey("master"), crossSigning.getCrossSigningKey("self_signing"), crossSigning.getCrossSigningKey("user_signing"), backupKeyPromise]), timeout]).then(resolve, reject); + }).catch(e => { + _logger.logger.warn("Cross-signing: failure while requesting keys:", e); + }); +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/DeviceList.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/DeviceList.js new file mode 100644 index 0000000000..31b8537428 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/DeviceList.js @@ -0,0 +1,860 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.TrackingStatus = exports.DeviceList = void 0; +var _logger = require("../logger"); +var _deviceinfo = require("./deviceinfo"); +var _CrossSigning = require("./CrossSigning"); +var olmlib = _interopRequireWildcard(require("./olmlib")); +var _indexeddbCryptoStore = require("./store/indexeddb-crypto-store"); +var _utils = require("../utils"); +var _typedEventEmitter = require("../models/typed-event-emitter"); +var _index = require("./index"); +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2017 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + 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. + */ /** + * Manages the list of other users' devices + */ +/* State transition diagram for DeviceList.deviceTrackingStatus + * + * | + * stopTrackingDeviceList V + * +---------------------> NOT_TRACKED + * | | + * +<--------------------+ | startTrackingDeviceList + * | | V + * | +-------------> PENDING_DOWNLOAD <--------------------+-+ + * | | ^ | | | + * | | restart download | | start download | | invalidateUserDeviceList + * | | client failed | | | | + * | | | V | | + * | +------------ DOWNLOAD_IN_PROGRESS -------------------+ | + * | | | | + * +<-------------------+ | download successful | + * ^ V | + * +----------------------- UP_TO_DATE ------------------------+ + */ +// constants for DeviceList.deviceTrackingStatus +let TrackingStatus = /*#__PURE__*/function (TrackingStatus) { + TrackingStatus[TrackingStatus["NotTracked"] = 0] = "NotTracked"; + TrackingStatus[TrackingStatus["PendingDownload"] = 1] = "PendingDownload"; + TrackingStatus[TrackingStatus["DownloadInProgress"] = 2] = "DownloadInProgress"; + TrackingStatus[TrackingStatus["UpToDate"] = 3] = "UpToDate"; + return TrackingStatus; +}({}); // user-Id → device-Id → DeviceInfo +exports.TrackingStatus = TrackingStatus; +class DeviceList extends _typedEventEmitter.TypedEventEmitter { + constructor(baseApis, cryptoStore, olmDevice, + // Maximum number of user IDs per request to prevent server overload (#1619) + keyDownloadChunkSize = 250) { + super(); + this.cryptoStore = cryptoStore; + this.keyDownloadChunkSize = keyDownloadChunkSize; + _defineProperty(this, "devices", {}); + _defineProperty(this, "crossSigningInfo", {}); + // map of identity keys to the user who owns it + _defineProperty(this, "userByIdentityKey", {}); + // which users we are tracking device status for. + _defineProperty(this, "deviceTrackingStatus", {}); + // loaded from storage in load() + // The 'next_batch' sync token at the point the data was written, + // ie. a token representing the point immediately after the + // moment represented by the snapshot in the db. + _defineProperty(this, "syncToken", null); + _defineProperty(this, "keyDownloadsInProgressByUser", new Map()); + // Set whenever changes are made other than setting the sync token + _defineProperty(this, "dirty", false); + // Promise resolved when device data is saved + _defineProperty(this, "savePromise", null); + // Function that resolves the save promise + _defineProperty(this, "resolveSavePromise", null); + // The time the save is scheduled for + _defineProperty(this, "savePromiseTime", null); + // The timer used to delay the save + _defineProperty(this, "saveTimer", null); + // True if we have fetched data from the server or loaded a non-empty + // set of device data from the store + _defineProperty(this, "hasFetched", null); + _defineProperty(this, "serialiser", void 0); + this.serialiser = new DeviceListUpdateSerialiser(baseApis, olmDevice, this); + } + + /** + * Load the device tracking state from storage + */ + async load() { + await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_DEVICE_DATA], txn => { + this.cryptoStore.getEndToEndDeviceData(txn, deviceData => { + this.hasFetched = Boolean(deviceData?.devices); + this.devices = deviceData ? deviceData.devices : {}; + this.crossSigningInfo = deviceData ? deviceData.crossSigningInfo || {} : {}; + this.deviceTrackingStatus = deviceData ? deviceData.trackingStatus : {}; + this.syncToken = deviceData?.syncToken ?? null; + this.userByIdentityKey = {}; + for (const user of Object.keys(this.devices)) { + const userDevices = this.devices[user]; + for (const device of Object.keys(userDevices)) { + const idKey = userDevices[device].keys["curve25519:" + device]; + if (idKey !== undefined) { + this.userByIdentityKey[idKey] = user; + } + } + } + }); + }); + for (const u of Object.keys(this.deviceTrackingStatus)) { + // if a download was in progress when we got shut down, it isn't any more. + if (this.deviceTrackingStatus[u] == TrackingStatus.DownloadInProgress) { + this.deviceTrackingStatus[u] = TrackingStatus.PendingDownload; + } + } + } + stop() { + if (this.saveTimer !== null) { + clearTimeout(this.saveTimer); + } + } + + /** + * Save the device tracking state to storage, if any changes are + * pending other than updating the sync token + * + * The actual save will be delayed by a short amount of time to + * aggregate multiple writes to the database. + * + * @param delay - Time in ms before which the save actually happens. + * By default, the save is delayed for a short period in order to batch + * multiple writes, but this behaviour can be disabled by passing 0. + * + * @returns true if the data was saved, false if + * it was not (eg. because no changes were pending). The promise + * will only resolve once the data is saved, so may take some time + * to resolve. + */ + async saveIfDirty(delay = 500) { + if (!this.dirty) return Promise.resolve(false); + // Delay saves for a bit so we can aggregate multiple saves that happen + // in quick succession (eg. when a whole room's devices are marked as known) + + const targetTime = Date.now() + delay; + if (this.savePromiseTime && targetTime < this.savePromiseTime) { + // There's a save scheduled but for after we would like: cancel + // it & schedule one for the time we want + clearTimeout(this.saveTimer); + this.saveTimer = null; + this.savePromiseTime = null; + // (but keep the save promise since whatever called save before + // will still want to know when the save is done) + } + + let savePromise = this.savePromise; + if (savePromise === null) { + savePromise = new Promise(resolve => { + this.resolveSavePromise = resolve; + }); + this.savePromise = savePromise; + } + if (this.saveTimer === null) { + const resolveSavePromise = this.resolveSavePromise; + this.savePromiseTime = targetTime; + this.saveTimer = setTimeout(() => { + _logger.logger.log("Saving device tracking data", this.syncToken); + + // null out savePromise now (after the delay but before the write), + // otherwise we could return the existing promise when the save has + // actually already happened. + this.savePromiseTime = null; + this.saveTimer = null; + this.savePromise = null; + this.resolveSavePromise = null; + this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_DEVICE_DATA], txn => { + this.cryptoStore.storeEndToEndDeviceData({ + devices: this.devices, + crossSigningInfo: this.crossSigningInfo, + trackingStatus: this.deviceTrackingStatus, + syncToken: this.syncToken ?? undefined + }, txn); + }).then(() => { + // The device list is considered dirty until the write completes. + this.dirty = false; + resolveSavePromise?.(true); + }, err => { + _logger.logger.error("Failed to save device tracking data", this.syncToken); + _logger.logger.error(err); + }); + }, delay); + } + return savePromise; + } + + /** + * Gets the sync token last set with setSyncToken + * + * @returns The sync token + */ + getSyncToken() { + return this.syncToken; + } + + /** + * Sets the sync token that the app will pass as the 'since' to the /sync + * endpoint next time it syncs. + * The sync token must always be set after any changes made as a result of + * data in that sync since setting the sync token to a newer one will mean + * those changed will not be synced from the server if a new client starts + * up with that data. + * + * @param st - The sync token + */ + setSyncToken(st) { + this.syncToken = st; + } + + /** + * Ensures up to date keys for a list of users are stored in the session store, + * downloading and storing them if they're not (or if forceDownload is + * true). + * @param userIds - The users to fetch. + * @param forceDownload - Always download the keys even if cached. + * + * @returns A promise which resolves to a map userId-\>deviceId-\>{@link DeviceInfo}. + */ + downloadKeys(userIds, forceDownload) { + const usersToDownload = []; + const promises = []; + userIds.forEach(u => { + const trackingStatus = this.deviceTrackingStatus[u]; + if (this.keyDownloadsInProgressByUser.has(u)) { + // already a key download in progress/queued for this user; its results + // will be good enough for us. + _logger.logger.log(`downloadKeys: already have a download in progress for ` + `${u}: awaiting its result`); + promises.push(this.keyDownloadsInProgressByUser.get(u)); + } else if (forceDownload || trackingStatus != TrackingStatus.UpToDate) { + usersToDownload.push(u); + } + }); + if (usersToDownload.length != 0) { + _logger.logger.log("downloadKeys: downloading for", usersToDownload); + const downloadPromise = this.doKeyDownload(usersToDownload); + promises.push(downloadPromise); + } + if (promises.length === 0) { + _logger.logger.log("downloadKeys: already have all necessary keys"); + } + return Promise.all(promises).then(() => { + return this.getDevicesFromStore(userIds); + }); + } + + /** + * Get the stored device keys for a list of user ids + * + * @param userIds - the list of users to list keys for. + * + * @returns userId-\>deviceId-\>{@link DeviceInfo}. + */ + getDevicesFromStore(userIds) { + const stored = new Map(); + userIds.forEach(userId => { + const deviceMap = new Map(); + this.getStoredDevicesForUser(userId)?.forEach(function (device) { + deviceMap.set(device.deviceId, device); + }); + stored.set(userId, deviceMap); + }); + return stored; + } + + /** + * Returns a list of all user IDs the DeviceList knows about + * + * @returns All known user IDs + */ + getKnownUserIds() { + return Object.keys(this.devices); + } + + /** + * Get the stored device keys for a user id + * + * @param userId - the user to list keys for. + * + * @returns list of devices, or null if we haven't + * managed to get a list of devices for this user yet. + */ + getStoredDevicesForUser(userId) { + const devs = this.devices[userId]; + if (!devs) { + return null; + } + const res = []; + for (const deviceId in devs) { + if (devs.hasOwnProperty(deviceId)) { + res.push(_deviceinfo.DeviceInfo.fromStorage(devs[deviceId], deviceId)); + } + } + return res; + } + + /** + * Get the stored device data for a user, in raw object form + * + * @param userId - the user to get data for + * + * @returns `deviceId->{object}` devices, or undefined if + * there is no data for this user. + */ + getRawStoredDevicesForUser(userId) { + return this.devices[userId]; + } + getStoredCrossSigningForUser(userId) { + if (!this.crossSigningInfo[userId]) return null; + return _CrossSigning.CrossSigningInfo.fromStorage(this.crossSigningInfo[userId], userId); + } + storeCrossSigningForUser(userId, info) { + this.crossSigningInfo[userId] = info; + this.dirty = true; + } + + /** + * Get the stored keys for a single device + * + * + * @returns device, or undefined + * if we don't know about this device + */ + getStoredDevice(userId, deviceId) { + const devs = this.devices[userId]; + if (!devs?.[deviceId]) { + return undefined; + } + return _deviceinfo.DeviceInfo.fromStorage(devs[deviceId], deviceId); + } + + /** + * Get a user ID by one of their device's curve25519 identity key + * + * @param algorithm - encryption algorithm + * @param senderKey - curve25519 key to match + * + * @returns user ID + */ + getUserByIdentityKey(algorithm, senderKey) { + if (algorithm !== olmlib.OLM_ALGORITHM && algorithm !== olmlib.MEGOLM_ALGORITHM) { + // we only deal in olm keys + return null; + } + return this.userByIdentityKey[senderKey]; + } + + /** + * Find a device by curve25519 identity key + * + * @param algorithm - encryption algorithm + * @param senderKey - curve25519 key to match + */ + getDeviceByIdentityKey(algorithm, senderKey) { + const userId = this.getUserByIdentityKey(algorithm, senderKey); + if (!userId) { + return null; + } + const devices = this.devices[userId]; + if (!devices) { + return null; + } + for (const deviceId in devices) { + if (!devices.hasOwnProperty(deviceId)) { + continue; + } + const device = devices[deviceId]; + for (const keyId in device.keys) { + if (!device.keys.hasOwnProperty(keyId)) { + continue; + } + if (keyId.indexOf("curve25519:") !== 0) { + continue; + } + const deviceKey = device.keys[keyId]; + if (deviceKey == senderKey) { + return _deviceinfo.DeviceInfo.fromStorage(device, deviceId); + } + } + } + + // doesn't match a known device + return null; + } + + /** + * Replaces the list of devices for a user with the given device list + * + * @param userId - The user ID + * @param devices - New device info for user + */ + storeDevicesForUser(userId, devices) { + this.setRawStoredDevicesForUser(userId, devices); + this.dirty = true; + } + + /** + * flag the given user for device-list tracking, if they are not already. + * + * This will mean that a subsequent call to refreshOutdatedDeviceLists() + * will download the device list for the user, and that subsequent calls to + * invalidateUserDeviceList will trigger more updates. + * + */ + startTrackingDeviceList(userId) { + // sanity-check the userId. This is mostly paranoia, but if synapse + // can't parse the userId we give it as an mxid, it 500s the whole + // request and we can never update the device lists again (because + // the broken userId is always 'invalid' and always included in any + // refresh request). + // By checking it is at least a string, we can eliminate a class of + // silly errors. + if (typeof userId !== "string") { + throw new Error("userId must be a string; was " + userId); + } + if (!this.deviceTrackingStatus[userId]) { + _logger.logger.log("Now tracking device list for " + userId); + this.deviceTrackingStatus[userId] = TrackingStatus.PendingDownload; + // we don't yet persist the tracking status, since there may be a lot + // of calls; we save all data together once the sync is done + this.dirty = true; + } + } + + /** + * Mark the given user as no longer being tracked for device-list updates. + * + * This won't affect any in-progress downloads, which will still go on to + * complete; it will just mean that we don't think that we have an up-to-date + * list for future calls to downloadKeys. + * + */ + stopTrackingDeviceList(userId) { + if (this.deviceTrackingStatus[userId]) { + _logger.logger.log("No longer tracking device list for " + userId); + this.deviceTrackingStatus[userId] = TrackingStatus.NotTracked; + + // we don't yet persist the tracking status, since there may be a lot + // of calls; we save all data together once the sync is done + this.dirty = true; + } + } + + /** + * Set all users we're currently tracking to untracked + * + * This will flag each user whose devices we are tracking as in need of an + * update. + */ + stopTrackingAllDeviceLists() { + for (const userId of Object.keys(this.deviceTrackingStatus)) { + this.deviceTrackingStatus[userId] = TrackingStatus.NotTracked; + } + this.dirty = true; + } + + /** + * Mark the cached device list for the given user outdated. + * + * If we are not tracking this user's devices, we'll do nothing. Otherwise + * we flag the user as needing an update. + * + * This doesn't actually set off an update, so that several users can be + * batched together. Call refreshOutdatedDeviceLists() for that. + * + */ + invalidateUserDeviceList(userId) { + if (this.deviceTrackingStatus[userId]) { + _logger.logger.log("Marking device list outdated for", userId); + this.deviceTrackingStatus[userId] = TrackingStatus.PendingDownload; + + // we don't yet persist the tracking status, since there may be a lot + // of calls; we save all data together once the sync is done + this.dirty = true; + } + } + + /** + * If we have users who have outdated device lists, start key downloads for them + * + * @returns which completes when the download completes; normally there + * is no need to wait for this (it's mostly for the unit tests). + */ + refreshOutdatedDeviceLists() { + this.saveIfDirty(); + const usersToDownload = []; + for (const userId of Object.keys(this.deviceTrackingStatus)) { + const stat = this.deviceTrackingStatus[userId]; + if (stat == TrackingStatus.PendingDownload) { + usersToDownload.push(userId); + } + } + return this.doKeyDownload(usersToDownload); + } + + /** + * Set the stored device data for a user, in raw object form + * Used only by internal class DeviceListUpdateSerialiser + * + * @param userId - the user to get data for + * + * @param devices - `deviceId->{object}` the new devices + */ + setRawStoredDevicesForUser(userId, devices) { + // remove old devices from userByIdentityKey + if (this.devices[userId] !== undefined) { + for (const [deviceId, dev] of Object.entries(this.devices[userId])) { + const identityKey = dev.keys["curve25519:" + deviceId]; + delete this.userByIdentityKey[identityKey]; + } + } + this.devices[userId] = devices; + + // add new devices into userByIdentityKey + for (const [deviceId, dev] of Object.entries(devices)) { + const identityKey = dev.keys["curve25519:" + deviceId]; + this.userByIdentityKey[identityKey] = userId; + } + } + setRawStoredCrossSigningForUser(userId, info) { + this.crossSigningInfo[userId] = info; + } + + /** + * Fire off download update requests for the given users, and update the + * device list tracking status for them, and the + * keyDownloadsInProgressByUser map for them. + * + * @param users - list of userIds + * + * @returns resolves when all the users listed have + * been updated. rejects if there was a problem updating any of the + * users. + */ + doKeyDownload(users) { + if (users.length === 0) { + // nothing to do + return Promise.resolve(); + } + const prom = this.serialiser.updateDevicesForUsers(users, this.syncToken).then(() => { + finished(true); + }, e => { + _logger.logger.error("Error downloading keys for " + users + ":", e); + finished(false); + throw e; + }); + users.forEach(u => { + this.keyDownloadsInProgressByUser.set(u, prom); + const stat = this.deviceTrackingStatus[u]; + if (stat == TrackingStatus.PendingDownload) { + this.deviceTrackingStatus[u] = TrackingStatus.DownloadInProgress; + } + }); + const finished = success => { + this.emit(_index.CryptoEvent.WillUpdateDevices, users, !this.hasFetched); + users.forEach(u => { + this.dirty = true; + + // we may have queued up another download request for this user + // since we started this request. If that happens, we should + // ignore the completion of the first one. + if (this.keyDownloadsInProgressByUser.get(u) !== prom) { + _logger.logger.log("Another update in the queue for", u, "- not marking up-to-date"); + return; + } + this.keyDownloadsInProgressByUser.delete(u); + const stat = this.deviceTrackingStatus[u]; + if (stat == TrackingStatus.DownloadInProgress) { + if (success) { + // we didn't get any new invalidations since this download started: + // this user's device list is now up to date. + this.deviceTrackingStatus[u] = TrackingStatus.UpToDate; + _logger.logger.log("Device list for", u, "now up to date"); + } else { + this.deviceTrackingStatus[u] = TrackingStatus.PendingDownload; + } + } + }); + this.saveIfDirty(); + this.emit(_index.CryptoEvent.DevicesUpdated, users, !this.hasFetched); + this.hasFetched = true; + }; + return prom; + } +} + +/** + * Serialises updates to device lists + * + * Ensures that results from /keys/query are not overwritten if a second call + * completes *before* an earlier one. + * + * It currently does this by ensuring only one call to /keys/query happens at a + * time (and queuing other requests up). + */ +exports.DeviceList = DeviceList; +class DeviceListUpdateSerialiser { + // The sync token we send with the requests + /* + * @param baseApis - Base API object + * @param olmDevice - The Olm Device + * @param deviceList - The device list object, the device list to be updated + */ + constructor(baseApis, olmDevice, deviceList) { + this.baseApis = baseApis; + this.olmDevice = olmDevice; + this.deviceList = deviceList; + _defineProperty(this, "downloadInProgress", false); + // users which are queued for download + // userId -> true + _defineProperty(this, "keyDownloadsQueuedByUser", {}); + // deferred which is resolved when the queued users are downloaded. + // non-null indicates that we have users queued for download. + _defineProperty(this, "queuedQueryDeferred", void 0); + _defineProperty(this, "syncToken", void 0); + } + + /** + * Make a key query request for the given users + * + * @param users - list of user ids + * + * @param syncToken - sync token to pass in the query request, to + * help the HS give the most recent results + * + * @returns resolves when all the users listed have + * been updated. rejects if there was a problem updating any of the + * users. + */ + updateDevicesForUsers(users, syncToken) { + users.forEach(u => { + this.keyDownloadsQueuedByUser[u] = true; + }); + if (!this.queuedQueryDeferred) { + this.queuedQueryDeferred = (0, _utils.defer)(); + } + + // We always take the new sync token and just use the latest one we've + // been given, since it just needs to be at least as recent as the + // sync response the device invalidation message arrived in + this.syncToken = syncToken; + if (this.downloadInProgress) { + // just queue up these users + _logger.logger.log("Queued key download for", users); + return this.queuedQueryDeferred.promise; + } + + // start a new download. + return this.doQueuedQueries(); + } + doQueuedQueries() { + if (this.downloadInProgress) { + throw new Error("DeviceListUpdateSerialiser.doQueuedQueries called with request active"); + } + const downloadUsers = Object.keys(this.keyDownloadsQueuedByUser); + this.keyDownloadsQueuedByUser = {}; + const deferred = this.queuedQueryDeferred; + this.queuedQueryDeferred = undefined; + _logger.logger.log("Starting key download for", downloadUsers); + this.downloadInProgress = true; + const opts = {}; + if (this.syncToken) { + opts.token = this.syncToken; + } + const factories = []; + for (let i = 0; i < downloadUsers.length; i += this.deviceList.keyDownloadChunkSize) { + const userSlice = downloadUsers.slice(i, i + this.deviceList.keyDownloadChunkSize); + factories.push(() => this.baseApis.downloadKeysForUsers(userSlice, opts)); + } + (0, _utils.chunkPromises)(factories, 3).then(async responses => { + const dk = Object.assign({}, ...responses.map(res => res.device_keys || {})); + const masterKeys = Object.assign({}, ...responses.map(res => res.master_keys || {})); + const ssks = Object.assign({}, ...responses.map(res => res.self_signing_keys || {})); + const usks = Object.assign({}, ...responses.map(res => res.user_signing_keys || {})); + + // yield to other things that want to execute in between users, to + // avoid wedging the CPU + // (https://github.com/vector-im/element-web/issues/3158) + // + // of course we ought to do this in a web worker or similar, but + // this serves as an easy solution for now. + for (const userId of downloadUsers) { + await (0, _utils.sleep)(5); + try { + await this.processQueryResponseForUser(userId, dk[userId], { + master: masterKeys?.[userId], + self_signing: ssks?.[userId], + user_signing: usks?.[userId] + }); + } catch (e) { + // log the error but continue, so that one bad key + // doesn't kill the whole process + _logger.logger.error(`Error processing keys for ${userId}:`, e); + } + } + }).then(() => { + _logger.logger.log("Completed key download for " + downloadUsers); + this.downloadInProgress = false; + deferred?.resolve(); + + // if we have queued users, fire off another request. + if (this.queuedQueryDeferred) { + this.doQueuedQueries(); + } + }, e => { + _logger.logger.warn("Error downloading keys for " + downloadUsers + ":", e); + this.downloadInProgress = false; + deferred?.reject(e); + }); + return deferred.promise; + } + async processQueryResponseForUser(userId, dkResponse, crossSigningResponse) { + _logger.logger.log("got device keys for " + userId + ":", dkResponse); + _logger.logger.log("got cross-signing keys for " + userId + ":", crossSigningResponse); + { + // map from deviceid -> deviceinfo for this user + const userStore = {}; + const devs = this.deviceList.getRawStoredDevicesForUser(userId); + if (devs) { + Object.keys(devs).forEach(deviceId => { + const d = _deviceinfo.DeviceInfo.fromStorage(devs[deviceId], deviceId); + userStore[deviceId] = d; + }); + } + await updateStoredDeviceKeysForUser(this.olmDevice, userId, userStore, dkResponse || {}, this.baseApis.getUserId(), this.baseApis.deviceId); + + // put the updates into the object that will be returned as our results + const storage = {}; + Object.keys(userStore).forEach(deviceId => { + storage[deviceId] = userStore[deviceId].toStorage(); + }); + this.deviceList.setRawStoredDevicesForUser(userId, storage); + } + + // now do the same for the cross-signing keys + { + // FIXME: should we be ignoring empty cross-signing responses, or + // should we be dropping the keys? + if (crossSigningResponse && (crossSigningResponse.master || crossSigningResponse.self_signing || crossSigningResponse.user_signing)) { + const crossSigning = this.deviceList.getStoredCrossSigningForUser(userId) || new _CrossSigning.CrossSigningInfo(userId); + crossSigning.setKeys(crossSigningResponse); + this.deviceList.setRawStoredCrossSigningForUser(userId, crossSigning.toStorage()); + + // NB. Unlike most events in the js-sdk, this one is internal to the + // js-sdk and is not re-emitted + this.deviceList.emit(_index.CryptoEvent.UserCrossSigningUpdated, userId); + } + } + } +} +async function updateStoredDeviceKeysForUser(olmDevice, userId, userStore, userResult, localUserId, localDeviceId) { + let updated = false; + + // remove any devices in the store which aren't in the response + for (const deviceId in userStore) { + if (!userStore.hasOwnProperty(deviceId)) { + continue; + } + if (!(deviceId in userResult)) { + if (userId === localUserId && deviceId === localDeviceId) { + _logger.logger.warn(`Local device ${deviceId} missing from sync, skipping removal`); + continue; + } + _logger.logger.log("Device " + userId + ":" + deviceId + " has been removed"); + delete userStore[deviceId]; + updated = true; + } + } + for (const deviceId in userResult) { + if (!userResult.hasOwnProperty(deviceId)) { + continue; + } + const deviceResult = userResult[deviceId]; + + // check that the user_id and device_id in the response object are + // correct + if (deviceResult.user_id !== userId) { + _logger.logger.warn("Mismatched user_id " + deviceResult.user_id + " in keys from " + userId + ":" + deviceId); + continue; + } + if (deviceResult.device_id !== deviceId) { + _logger.logger.warn("Mismatched device_id " + deviceResult.device_id + " in keys from " + userId + ":" + deviceId); + continue; + } + if (await storeDeviceKeys(olmDevice, userStore, deviceResult)) { + updated = true; + } + } + return updated; +} + +/* + * Process a device in a /query response, and add it to the userStore + * + * returns (a promise for) true if a change was made, else false + */ +async function storeDeviceKeys(olmDevice, userStore, deviceResult) { + if (!deviceResult.keys) { + // no keys? + return false; + } + const deviceId = deviceResult.device_id; + const userId = deviceResult.user_id; + const signKeyId = "ed25519:" + deviceId; + const signKey = deviceResult.keys[signKeyId]; + if (!signKey) { + _logger.logger.warn("Device " + userId + ":" + deviceId + " has no ed25519 key"); + return false; + } + const unsigned = deviceResult.unsigned || {}; + const signatures = deviceResult.signatures || {}; + try { + await olmlib.verifySignature(olmDevice, deviceResult, userId, deviceId, signKey); + } catch (e) { + _logger.logger.warn("Unable to verify signature on device " + userId + ":" + deviceId + ":" + e); + return false; + } + + // DeviceInfo + let deviceStore; + if (deviceId in userStore) { + // already have this device. + deviceStore = userStore[deviceId]; + if (deviceStore.getFingerprint() != signKey) { + // this should only happen if the list has been MITMed; we are + // best off sticking with the original keys. + // + // Should we warn the user about it somehow? + _logger.logger.warn("Ed25519 key for device " + userId + ":" + deviceId + " has changed"); + return false; + } + } else { + userStore[deviceId] = deviceStore = new _deviceinfo.DeviceInfo(deviceId); + } + deviceStore.keys = deviceResult.keys || {}; + deviceStore.algorithms = deviceResult.algorithms || []; + deviceStore.unsigned = unsigned; + deviceStore.signatures = signatures; + return true; +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/EncryptionSetup.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/EncryptionSetup.js new file mode 100644 index 0000000000..7bc39b0d92 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/EncryptionSetup.js @@ -0,0 +1,342 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.EncryptionSetupOperation = exports.EncryptionSetupBuilder = void 0; +var _logger = require("../logger"); +var _event = require("../models/event"); +var _CrossSigning = require("./CrossSigning"); +var _indexeddbCryptoStore = require("./store/indexeddb-crypto-store"); +var _httpApi = require("../http-api"); +var _client = require("../client"); +var _typedEventEmitter = require("../models/typed-event-emitter"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/** + * Builds an EncryptionSetupOperation by calling any of the add.. methods. + * Once done, `buildOperation()` can be called which allows to apply to operation. + * + * This is used as a helper by Crypto to keep track of all the network requests + * and other side-effects of bootstrapping, so it can be applied in one go (and retried in the future) + * Also keeps track of all the private keys created during bootstrapping, so we don't need to prompt for them + * more than once. + */ +class EncryptionSetupBuilder { + /** + * @param accountData - pre-existing account data, will only be read, not written. + * @param delegateCryptoCallbacks - crypto callbacks to delegate to if the key isn't in cache yet + */ + constructor(accountData, delegateCryptoCallbacks) { + _defineProperty(this, "accountDataClientAdapter", void 0); + _defineProperty(this, "crossSigningCallbacks", void 0); + _defineProperty(this, "ssssCryptoCallbacks", void 0); + _defineProperty(this, "crossSigningKeys", void 0); + _defineProperty(this, "keySignatures", void 0); + _defineProperty(this, "keyBackupInfo", void 0); + _defineProperty(this, "sessionBackupPrivateKey", void 0); + this.accountDataClientAdapter = new AccountDataClientAdapter(accountData); + this.crossSigningCallbacks = new CrossSigningCallbacks(); + this.ssssCryptoCallbacks = new SSSSCryptoCallbacks(delegateCryptoCallbacks); + } + + /** + * Adds new cross-signing public keys + * + * @param authUpload - Function called to await an interactive auth + * flow when uploading device signing keys. + * Args: + * A function that makes the request requiring auth. Receives + * the auth data as an object. Can be called multiple times, first with + * an empty authDict, to obtain the flows. + * @param keys - the new keys + */ + addCrossSigningKeys(authUpload, keys) { + this.crossSigningKeys = { + authUpload, + keys + }; + } + + /** + * Adds the key backup info to be updated on the server + * + * Used either to create a new key backup, or add signatures + * from the new MSK. + * + * @param keyBackupInfo - as received from/sent to the server + */ + addSessionBackup(keyBackupInfo) { + this.keyBackupInfo = keyBackupInfo; + } + + /** + * Adds the session backup private key to be updated in the local cache + * + * Used after fixing the format of the key + * + */ + addSessionBackupPrivateKeyToCache(privateKey) { + this.sessionBackupPrivateKey = privateKey; + } + + /** + * Add signatures from a given user and device/x-sign key + * Used to sign the new cross-signing key with the device key + * + */ + addKeySignature(userId, deviceId, signature) { + if (!this.keySignatures) { + this.keySignatures = {}; + } + const userSignatures = this.keySignatures[userId] || {}; + this.keySignatures[userId] = userSignatures; + userSignatures[deviceId] = signature; + } + async setAccountData(type, content) { + await this.accountDataClientAdapter.setAccountData(type, content); + } + + /** + * builds the operation containing all the parts that have been added to the builder + */ + buildOperation() { + const accountData = this.accountDataClientAdapter.values; + return new EncryptionSetupOperation(accountData, this.crossSigningKeys, this.keyBackupInfo, this.keySignatures); + } + + /** + * Stores the created keys locally. + * + * This does not yet store the operation in a way that it can be restored, + * but that is the idea in the future. + */ + async persist(crypto) { + // store private keys in cache + if (this.crossSigningKeys) { + const cacheCallbacks = (0, _CrossSigning.createCryptoStoreCacheCallbacks)(crypto.cryptoStore, crypto.olmDevice); + for (const type of ["master", "self_signing", "user_signing"]) { + _logger.logger.log(`Cache ${type} cross-signing private key locally`); + const privateKey = this.crossSigningCallbacks.privateKeys.get(type); + await cacheCallbacks.storeCrossSigningKeyCache?.(type, privateKey); + } + // store own cross-sign pubkeys as trusted + await crypto.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => { + crypto.cryptoStore.storeCrossSigningKeys(txn, this.crossSigningKeys.keys); + }); + } + // store session backup key in cache + if (this.sessionBackupPrivateKey) { + await crypto.storeSessionBackupPrivateKey(this.sessionBackupPrivateKey); + } + } +} + +/** + * Can be created from EncryptionSetupBuilder, or + * (in a follow-up PR, not implemented yet) restored from storage, to retry. + * + * It does not have knowledge of any private keys, unlike the builder. + */ +exports.EncryptionSetupBuilder = EncryptionSetupBuilder; +class EncryptionSetupOperation { + /** + */ + constructor(accountData, crossSigningKeys, keyBackupInfo, keySignatures) { + this.accountData = accountData; + this.crossSigningKeys = crossSigningKeys; + this.keyBackupInfo = keyBackupInfo; + this.keySignatures = keySignatures; + } + + /** + * Runs the (remaining part of, in the future) operation by sending requests to the server. + */ + async apply(crypto) { + const baseApis = crypto.baseApis; + // upload cross-signing keys + if (this.crossSigningKeys) { + const keys = {}; + for (const [name, key] of Object.entries(this.crossSigningKeys.keys)) { + keys[name + "_key"] = key; + } + + // We must only call `uploadDeviceSigningKeys` from inside this auth + // helper to ensure we properly handle auth errors. + await this.crossSigningKeys.authUpload?.(authDict => { + return baseApis.uploadDeviceSigningKeys(authDict, keys); + }); + + // pass the new keys to the main instance of our own CrossSigningInfo. + crypto.crossSigningInfo.setKeys(this.crossSigningKeys.keys); + } + // set account data + if (this.accountData) { + for (const [type, content] of this.accountData) { + await baseApis.setAccountData(type, content); + } + } + // upload first cross-signing signatures with the new key + // (e.g. signing our own device) + if (this.keySignatures) { + await baseApis.uploadKeySignatures(this.keySignatures); + } + // need to create/update key backup info + if (this.keyBackupInfo) { + if (this.keyBackupInfo.version) { + // session backup signature + // The backup is trusted because the user provided the private key. + // Sign the backup with the cross signing key so the key backup can + // be trusted via cross-signing. + await baseApis.http.authedRequest(_httpApi.Method.Put, "/room_keys/version/" + this.keyBackupInfo.version, undefined, { + algorithm: this.keyBackupInfo.algorithm, + auth_data: this.keyBackupInfo.auth_data + }, { + prefix: _httpApi.ClientPrefix.V3 + }); + } else { + // add new key backup + await baseApis.http.authedRequest(_httpApi.Method.Post, "/room_keys/version", undefined, this.keyBackupInfo, { + prefix: _httpApi.ClientPrefix.V3 + }); + } + } + } +} + +/** + * Catches account data set by SecretStorage during bootstrapping by + * implementing the methods related to account data in MatrixClient + */ +exports.EncryptionSetupOperation = EncryptionSetupOperation; +class AccountDataClientAdapter extends _typedEventEmitter.TypedEventEmitter { + /** + * @param existingValues - existing account data + */ + constructor(existingValues) { + super(); + this.existingValues = existingValues; + // + _defineProperty(this, "values", new Map()); + } + + /** + * @returns the content of the account data + */ + getAccountDataFromServer(type) { + return Promise.resolve(this.getAccountData(type)); + } + + /** + * @returns the content of the account data + */ + getAccountData(type) { + const modifiedValue = this.values.get(type); + if (modifiedValue) { + return modifiedValue; + } + const existingValue = this.existingValues.get(type); + if (existingValue) { + return existingValue.getContent(); + } + return null; + } + setAccountData(type, content) { + const lastEvent = this.values.get(type); + this.values.set(type, content); + // ensure accountData is emitted on the next tick, + // as SecretStorage listens for it while calling this method + // and it seems to rely on this. + return Promise.resolve().then(() => { + const event = new _event.MatrixEvent({ + type, + content + }); + this.emit(_client.ClientEvent.AccountData, event, lastEvent); + return {}; + }); + } +} + +/** + * Catches the private cross-signing keys set during bootstrapping + * by both cache callbacks (see createCryptoStoreCacheCallbacks) as non-cache callbacks. + * See CrossSigningInfo constructor + */ +class CrossSigningCallbacks { + constructor() { + _defineProperty(this, "privateKeys", new Map()); + } + // cache callbacks + getCrossSigningKeyCache(type, expectedPublicKey) { + return this.getCrossSigningKey(type, expectedPublicKey); + } + storeCrossSigningKeyCache(type, key) { + this.privateKeys.set(type, key); + return Promise.resolve(); + } + + // non-cache callbacks + getCrossSigningKey(type, expectedPubkey) { + return Promise.resolve(this.privateKeys.get(type) ?? null); + } + saveCrossSigningKeys(privateKeys) { + for (const [type, privateKey] of Object.entries(privateKeys)) { + this.privateKeys.set(type, privateKey); + } + } +} + +/** + * Catches the 4S private key set during bootstrapping by implementing + * the SecretStorage crypto callbacks + */ +class SSSSCryptoCallbacks { + constructor(delegateCryptoCallbacks) { + this.delegateCryptoCallbacks = delegateCryptoCallbacks; + _defineProperty(this, "privateKeys", new Map()); + } + async getSecretStorageKey({ + keys + }, name) { + for (const keyId of Object.keys(keys)) { + const privateKey = this.privateKeys.get(keyId); + if (privateKey) { + return [keyId, privateKey]; + } + } + // if we don't have the key cached yet, ask + // for it to the general crypto callbacks and cache it + if (this?.delegateCryptoCallbacks?.getSecretStorageKey) { + const result = await this.delegateCryptoCallbacks.getSecretStorageKey({ + keys + }, name); + if (result) { + const [keyId, privateKey] = result; + this.privateKeys.set(keyId, privateKey); + } + return result; + } + return null; + } + addPrivateKey(keyId, keyInfo, privKey) { + this.privateKeys.set(keyId, privKey); + // Also pass along to application to cache if it wishes + this.delegateCryptoCallbacks?.cacheSecretStorageKey?.(keyId, keyInfo, privKey); + } +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/OlmDevice.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/OlmDevice.js new file mode 100644 index 0000000000..1114de97d9 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/OlmDevice.js @@ -0,0 +1,1162 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.WITHHELD_MESSAGES = exports.PayloadTooLargeError = exports.OlmDevice = void 0; +var _logger = require("../logger"); +var _indexeddbCryptoStore = require("./store/indexeddb-crypto-store"); +var algorithms = _interopRequireWildcard(require("./algorithms")); +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2016 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +// 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; +class PayloadTooLargeError extends Error { + constructor(...args) { + super(...args); + _defineProperty(this, "data", { + errcode: "M_TOO_LARGE", + error: "Payload too large for encrypted message" + }); + } +} +exports.PayloadTooLargeError = PayloadTooLargeError; +function checkPayloadLength(payloadString) { + if (payloadString === undefined) { + throw new Error("payloadString undefined"); + } + if (payloadString.length > MAX_PLAINTEXT_LENGTH) { + // might as well fail early here rather than letting the olm library throw + // a cryptic memory allocation error. + // + // Note that even if we manage to do the encryption, the message send may fail, + // because by the time we've wrapped the ciphertext in the event object, it may + // exceed 65K. But at least we won't just fail with "abort()" in that case. + throw new PayloadTooLargeError(`Message too long (${payloadString.length} bytes). ` + `The maximum for an encrypted message is ${MAX_PLAINTEXT_LENGTH} bytes.`); + } +} + +/** data stored in the session store about an inbound group session */ + +/* eslint-disable camelcase */ + +/* eslint-enable camelcase */ + +/** + * Manages the olm cryptography functions. Each OlmDevice has a single + * OlmAccount and a number of OlmSessions. + * + * Accounts and sessions are kept pickled in the cryptoStore. + */ +class OlmDevice { + // set by consumers + + constructor(cryptoStore) { + this.cryptoStore = cryptoStore; + _defineProperty(this, "pickleKey", "DEFAULT_KEY"); + // set by consumers + /** Curve25519 key for the account, unknown until we load the account from storage in init() */ + _defineProperty(this, "deviceCurve25519Key", null); + /** Ed25519 key for the account, unknown until we load the account from storage in init() */ + _defineProperty(this, "deviceEd25519Key", null); + _defineProperty(this, "maxOneTimeKeys", null); + // we don't bother stashing outboundgroupsessions in the cryptoStore - + // instead we keep them here. + _defineProperty(this, "outboundGroupSessionStore", {}); + // Store a set of decrypted message indexes for each group session. + // This partially mitigates a replay attack where a MITM resends a group + // message into the room. + // + // When we decrypt a message and the message index matches a previously + // decrypted message, one possible cause of that is that we are decrypting + // the same event, and may not indicate an actual replay attack. For + // example, this could happen if we receive events, forget about them, and + // then re-fetch them when we backfill. So we store the event ID and + // timestamp corresponding to each message index when we first decrypt it, + // and compare these against the event ID and timestamp every time we use + // that same index. If they match, then we're probably decrypting the same + // event and we don't consider it a replay attack. + // + // Keys are strings of form "<senderKey>|<session_id>|<message_index>" + // Values are objects of the form "{id: <event id>, timestamp: <ts>}" + _defineProperty(this, "inboundGroupSessionMessageIndexes", {}); + // Keep track of sessions that we're starting, so that we don't start + // multiple sessions for the same device at the same time. + _defineProperty(this, "sessionsInProgress", {}); + // set by consumers + // Used by olm to serialise prekey message decryptions + _defineProperty(this, "olmPrekeyPromise", Promise.resolve()); + } + + /** + * @returns The version of Olm. + */ + static getOlmVersion() { + return global.Olm.get_library_version(); + } + + /** + * Initialise the OlmAccount. This must be called before any other operations + * on the OlmDevice. + * + * Data from an exported Olm device can be provided + * in order to re-create this device. + * + * Attempts to load the OlmAccount from the crypto store, or creates one if none is + * found. + * + * Reads the device keys from the OlmAccount object. + * + * @param fromExportedDevice - (Optional) data from exported device + * that must be re-created. + * If present, opts.pickleKey is ignored + * (exported data already provides a pickle key) + * @param pickleKey - (Optional) pickle key to set instead of default one + */ + async init({ + pickleKey, + fromExportedDevice + } = {}) { + let e2eKeys; + const account = new global.Olm.Account(); + try { + if (fromExportedDevice) { + if (pickleKey) { + _logger.logger.warn("ignoring opts.pickleKey" + " because opts.fromExportedDevice is present."); + } + this.pickleKey = fromExportedDevice.pickleKey; + await this.initialiseFromExportedDevice(fromExportedDevice, account); + } else { + if (pickleKey) { + this.pickleKey = pickleKey; + } + await this.initialiseAccount(account); + } + e2eKeys = JSON.parse(account.identity_keys()); + this.maxOneTimeKeys = account.max_number_of_one_time_keys(); + } finally { + account.free(); + } + this.deviceCurve25519Key = e2eKeys.curve25519; + this.deviceEd25519Key = e2eKeys.ed25519; + } + + /** + * Populates the crypto store using data that was exported from an existing device. + * Note that for now only the “account” and “sessions” stores are populated; + * Other stores will be as with a new device. + * + * @param exportedData - Data exported from another device + * through the “export” method. + * @param account - an olm account to initialize + */ + async initialiseFromExportedDevice(exportedData, account) { + await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT, _indexeddbCryptoStore.IndexedDBCryptoStore.STORE_SESSIONS], txn => { + this.cryptoStore.storeAccount(txn, exportedData.pickledAccount); + exportedData.sessions.forEach(session => { + const { + deviceKey, + sessionId + } = session; + const sessionInfo = { + session: session.session, + lastReceivedMessageTs: session.lastReceivedMessageTs + }; + this.cryptoStore.storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn); + }); + }); + account.unpickle(this.pickleKey, exportedData.pickledAccount); + } + async initialiseAccount(account) { + await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => { + this.cryptoStore.getAccount(txn, pickledAccount => { + if (pickledAccount !== null) { + account.unpickle(this.pickleKey, pickledAccount); + } else { + account.create(); + pickledAccount = account.pickle(this.pickleKey); + this.cryptoStore.storeAccount(txn, pickledAccount); + } + }); + }); + } + + /** + * extract our OlmAccount from the crypto store and call the given function + * with the account object + * The `account` object is usable only within the callback passed to this + * function and will be freed as soon the callback returns. It is *not* + * usable for the rest of the lifetime of the transaction. + * This function requires a live transaction object from cryptoStore.doTxn() + * and therefore may only be called in a doTxn() callback. + * + * @param txn - Opaque transaction object from cryptoStore.doTxn() + * @internal + */ + getAccount(txn, func) { + this.cryptoStore.getAccount(txn, pickledAccount => { + const account = new global.Olm.Account(); + try { + account.unpickle(this.pickleKey, pickledAccount); + func(account); + } finally { + account.free(); + } + }); + } + + /* + * Saves an account to the crypto store. + * This function requires a live transaction object from cryptoStore.doTxn() + * and therefore may only be called in a doTxn() callback. + * + * @param txn - Opaque transaction object from cryptoStore.doTxn() + * @param Olm.Account object + * @internal + */ + storeAccount(txn, account) { + this.cryptoStore.storeAccount(txn, account.pickle(this.pickleKey)); + } + + /** + * Export data for re-creating the Olm device later. + * TODO export data other than just account and (P2P) sessions. + * + * @returns The exported data + */ + async export() { + const result = { + pickleKey: this.pickleKey + }; + await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT, _indexeddbCryptoStore.IndexedDBCryptoStore.STORE_SESSIONS], txn => { + this.cryptoStore.getAccount(txn, pickledAccount => { + result.pickledAccount = pickledAccount; + }); + result.sessions = []; + // Note that the pickledSession object we get in the callback + // is not exactly the same thing you get in method _getSession + // see documentation of IndexedDBCryptoStore.getAllEndToEndSessions + this.cryptoStore.getAllEndToEndSessions(txn, pickledSession => { + result.sessions.push(pickledSession); + }); + }); + return result; + } + + /** + * extract an OlmSession from the session store and call the given function + * The session is usable only within the callback passed to this + * function and will be freed as soon the callback returns. It is *not* + * usable for the rest of the lifetime of the transaction. + * + * @param txn - Opaque transaction object from cryptoStore.doTxn() + * @internal + */ + getSession(deviceKey, sessionId, txn, func) { + this.cryptoStore.getEndToEndSession(deviceKey, sessionId, txn, sessionInfo => { + this.unpickleSession(sessionInfo, func); + }); + } + + /** + * Creates a session object from a session pickle and executes the given + * function with it. The session object is destroyed once the function + * returns. + * + * @internal + */ + unpickleSession(sessionInfo, func) { + const session = new global.Olm.Session(); + try { + session.unpickle(this.pickleKey, sessionInfo.session); + const unpickledSessInfo = Object.assign({}, sessionInfo, { + session + }); + func(unpickledSessInfo); + } finally { + session.free(); + } + } + + /** + * store our OlmSession in the session store + * + * @param sessionInfo - `{session: OlmSession, lastReceivedMessageTs: int}` + * @param txn - Opaque transaction object from cryptoStore.doTxn() + * @internal + */ + saveSession(deviceKey, sessionInfo, txn) { + const sessionId = sessionInfo.session.session_id(); + _logger.logger.debug(`Saving Olm session ${sessionId} with device ${deviceKey}: ${sessionInfo.session.describe()}`); + + // Why do we re-use the input object for this, overwriting the same key with a different + // type? Is it because we want to erase the unpickled session to enforce that it's no longer + // used? A comment would be great. + const pickledSessionInfo = Object.assign(sessionInfo, { + session: sessionInfo.session.pickle(this.pickleKey) + }); + this.cryptoStore.storeEndToEndSession(deviceKey, sessionId, pickledSessionInfo, txn); + } + + /** + * get an OlmUtility and call the given function + * + * @returns result of func + * @internal + */ + getUtility(func) { + const utility = new global.Olm.Utility(); + try { + return func(utility); + } finally { + utility.free(); + } + } + + /** + * Signs a message with the ed25519 key for this account. + * + * @param message - message to be signed + * @returns base64-encoded signature + */ + async sign(message) { + let result; + await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => { + this.getAccount(txn, account => { + result = account.sign(message); + }); + }); + return result; + } + + /** + * Get the current (unused, unpublished) one-time keys for this account. + * + * @returns one time keys; an object with the single property + * <tt>curve25519</tt>, which is itself an object mapping key id to Curve25519 + * key. + */ + async getOneTimeKeys() { + let result; + await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => { + this.getAccount(txn, account => { + result = JSON.parse(account.one_time_keys()); + }); + }); + return result; + } + + /** + * Get the maximum number of one-time keys we can store. + * + * @returns number of keys + */ + maxNumberOfOneTimeKeys() { + return this.maxOneTimeKeys ?? -1; + } + + /** + * Marks all of the one-time keys as published. + */ + async markKeysAsPublished() { + await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => { + this.getAccount(txn, account => { + account.mark_keys_as_published(); + this.storeAccount(txn, account); + }); + }); + } + + /** + * Generate some new one-time keys + * + * @param numKeys - number of keys to generate + * @returns Resolved once the account is saved back having generated the keys + */ + generateOneTimeKeys(numKeys) { + return this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => { + this.getAccount(txn, account => { + account.generate_one_time_keys(numKeys); + this.storeAccount(txn, account); + }); + }); + } + + /** + * Generate a new fallback keys + * + * @returns Resolved once the account is saved back having generated the key + */ + async generateFallbackKey() { + await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => { + this.getAccount(txn, account => { + account.generate_fallback_key(); + this.storeAccount(txn, account); + }); + }); + } + async getFallbackKey() { + let result; + await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => { + this.getAccount(txn, account => { + result = JSON.parse(account.unpublished_fallback_key()); + }); + }); + return result; + } + async forgetOldFallbackKey() { + await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => { + this.getAccount(txn, account => { + account.forget_old_fallback_key(); + this.storeAccount(txn, account); + }); + }); + } + + /** + * Generate a new outbound session + * + * The new session will be stored in the cryptoStore. + * + * @param theirIdentityKey - remote user's Curve25519 identity key + * @param theirOneTimeKey - remote user's one-time Curve25519 key + * @returns sessionId for the outbound session. + */ + async createOutboundSession(theirIdentityKey, theirOneTimeKey) { + let newSessionId; + await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT, _indexeddbCryptoStore.IndexedDBCryptoStore.STORE_SESSIONS], txn => { + this.getAccount(txn, account => { + const session = new global.Olm.Session(); + try { + session.create_outbound(account, theirIdentityKey, theirOneTimeKey); + newSessionId = session.session_id(); + this.storeAccount(txn, account); + const sessionInfo = { + session, + // Pretend we've received a message at this point, otherwise + // if we try to send a message to the device, it won't use + // this session + lastReceivedMessageTs: Date.now() + }; + this.saveSession(theirIdentityKey, sessionInfo, txn); + } finally { + session.free(); + } + }); + }, _logger.logger.withPrefix("[createOutboundSession]")); + return newSessionId; + } + + /** + * Generate a new inbound session, given an incoming message + * + * @param theirDeviceIdentityKey - remote user's Curve25519 identity key + * @param messageType - messageType field from the received message (must be 0) + * @param ciphertext - base64-encoded body from the received message + * + * @returns decrypted payload, and + * session id of new session + * + * @throws Error if the received message was not valid (for instance, it didn't use a valid one-time key). + */ + async createInboundSession(theirDeviceIdentityKey, messageType, ciphertext) { + if (messageType !== 0) { + throw new Error("Need messageType == 0 to create inbound session"); + } + let result; // eslint-disable-line camelcase + await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT, _indexeddbCryptoStore.IndexedDBCryptoStore.STORE_SESSIONS], txn => { + this.getAccount(txn, account => { + const session = new global.Olm.Session(); + try { + session.create_inbound_from(account, theirDeviceIdentityKey, ciphertext); + account.remove_one_time_keys(session); + this.storeAccount(txn, account); + const payloadString = session.decrypt(messageType, ciphertext); + const sessionInfo = { + session, + // this counts as a received message: set last received message time + // to now + lastReceivedMessageTs: Date.now() + }; + this.saveSession(theirDeviceIdentityKey, sessionInfo, txn); + result = { + payload: payloadString, + session_id: session.session_id() + }; + } finally { + session.free(); + } + }); + }, _logger.logger.withPrefix("[createInboundSession]")); + return result; + } + + /** + * Get a list of known session IDs for the given device + * + * @param theirDeviceIdentityKey - Curve25519 identity key for the + * remote device + * @returns a list of known session ids for the device + */ + async getSessionIdsForDevice(theirDeviceIdentityKey) { + const log = _logger.logger.withPrefix("[getSessionIdsForDevice]"); + if (theirDeviceIdentityKey in this.sessionsInProgress) { + log.debug(`Waiting for Olm session for ${theirDeviceIdentityKey} to be created`); + try { + await this.sessionsInProgress[theirDeviceIdentityKey]; + } catch (e) { + // if the session failed to be created, just fall through and + // return an empty result + } + } + let sessionIds; + await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_SESSIONS], txn => { + this.cryptoStore.getEndToEndSessions(theirDeviceIdentityKey, txn, sessions => { + sessionIds = Object.keys(sessions); + }); + }, log); + return sessionIds; + } + + /** + * Get the right olm session id for encrypting messages to the given identity key + * + * @param theirDeviceIdentityKey - Curve25519 identity key for the + * remote device + * @param nowait - Don't wait for an in-progress session to complete. + * This should only be set to true of the calling function is the function + * that marked the session as being in-progress. + * @param log - A possibly customised log + * @returns session id, or null if no established session + */ + async getSessionIdForDevice(theirDeviceIdentityKey, nowait = false, log) { + const sessionInfos = await this.getSessionInfoForDevice(theirDeviceIdentityKey, nowait, log); + if (sessionInfos.length === 0) { + return null; + } + // Use the session that has most recently received a message + let idxOfBest = 0; + for (let i = 1; i < sessionInfos.length; i++) { + const thisSessInfo = sessionInfos[i]; + const thisLastReceived = thisSessInfo.lastReceivedMessageTs === undefined ? 0 : thisSessInfo.lastReceivedMessageTs; + const bestSessInfo = sessionInfos[idxOfBest]; + const bestLastReceived = bestSessInfo.lastReceivedMessageTs === undefined ? 0 : bestSessInfo.lastReceivedMessageTs; + if (thisLastReceived > bestLastReceived || thisLastReceived === bestLastReceived && thisSessInfo.sessionId < bestSessInfo.sessionId) { + idxOfBest = i; + } + } + return sessionInfos[idxOfBest].sessionId; + } + + /** + * Get information on the active Olm sessions for a device. + * <p> + * Returns an array, with an entry for each active session. The first entry in + * the result will be the one used for outgoing messages. Each entry contains + * the keys 'hasReceivedMessage' (true if the session has received an incoming + * message and is therefore past the pre-key stage), and 'sessionId'. + * + * @param deviceIdentityKey - Curve25519 identity key for the device + * @param nowait - Don't wait for an in-progress session to complete. + * This should only be set to true of the calling function is the function + * that marked the session as being in-progress. + * @param log - A possibly customised log + */ + async getSessionInfoForDevice(deviceIdentityKey, nowait = false, log = _logger.logger) { + log = log.withPrefix("[getSessionInfoForDevice]"); + if (deviceIdentityKey in this.sessionsInProgress && !nowait) { + log.debug(`Waiting for Olm session for ${deviceIdentityKey} to be created`); + try { + await this.sessionsInProgress[deviceIdentityKey]; + } catch (e) { + // if the session failed to be created, then just fall through and + // return an empty result + } + } + const info = []; + await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_SESSIONS], txn => { + this.cryptoStore.getEndToEndSessions(deviceIdentityKey, txn, sessions => { + const sessionIds = Object.keys(sessions).sort(); + for (const sessionId of sessionIds) { + this.unpickleSession(sessions[sessionId], sessInfo => { + info.push({ + lastReceivedMessageTs: sessInfo.lastReceivedMessageTs, + hasReceivedMessage: sessInfo.session.has_received_message(), + sessionId + }); + }); + } + }); + }, log); + return info; + } + + /** + * Encrypt an outgoing message using an existing session + * + * @param theirDeviceIdentityKey - Curve25519 identity key for the + * remote device + * @param sessionId - the id of the active session + * @param payloadString - payload to be encrypted and sent + * + * @returns ciphertext + */ + async encryptMessage(theirDeviceIdentityKey, sessionId, payloadString) { + checkPayloadLength(payloadString); + let res; + await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_SESSIONS], txn => { + this.getSession(theirDeviceIdentityKey, sessionId, txn, sessionInfo => { + const sessionDesc = sessionInfo.session.describe(); + _logger.logger.log("encryptMessage: Olm Session ID " + sessionId + " to " + theirDeviceIdentityKey + ": " + sessionDesc); + res = sessionInfo.session.encrypt(payloadString); + this.saveSession(theirDeviceIdentityKey, sessionInfo, txn); + }); + }, _logger.logger.withPrefix("[encryptMessage]")); + return res; + } + + /** + * Decrypt an incoming message using an existing session + * + * @param theirDeviceIdentityKey - Curve25519 identity key for the + * remote device + * @param sessionId - the id of the active session + * @param messageType - messageType field from the received message + * @param ciphertext - base64-encoded body from the received message + * + * @returns decrypted payload. + */ + async decryptMessage(theirDeviceIdentityKey, sessionId, messageType, ciphertext) { + let payloadString; + await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_SESSIONS], txn => { + this.getSession(theirDeviceIdentityKey, sessionId, txn, sessionInfo => { + const sessionDesc = sessionInfo.session.describe(); + _logger.logger.log("decryptMessage: Olm Session ID " + sessionId + " from " + theirDeviceIdentityKey + ": " + sessionDesc); + payloadString = sessionInfo.session.decrypt(messageType, ciphertext); + sessionInfo.lastReceivedMessageTs = Date.now(); + this.saveSession(theirDeviceIdentityKey, sessionInfo, txn); + }); + }, _logger.logger.withPrefix("[decryptMessage]")); + return payloadString; + } + + /** + * Determine if an incoming messages is a prekey message matching an existing session + * + * @param theirDeviceIdentityKey - Curve25519 identity key for the + * remote device + * @param sessionId - the id of the active session + * @param messageType - messageType field from the received message + * @param ciphertext - base64-encoded body from the received message + * + * @returns true if the received message is a prekey message which matches + * the given session. + */ + async matchesSession(theirDeviceIdentityKey, sessionId, messageType, ciphertext) { + if (messageType !== 0) { + return false; + } + let matches; + await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_SESSIONS], txn => { + this.getSession(theirDeviceIdentityKey, sessionId, txn, sessionInfo => { + matches = sessionInfo.session.matches_inbound(ciphertext); + }); + }, _logger.logger.withPrefix("[matchesSession]")); + return matches; + } + async recordSessionProblem(deviceKey, type, fixed) { + _logger.logger.info(`Recording problem on olm session with ${deviceKey} of type ${type}. Recreating: ${fixed}`); + await this.cryptoStore.storeEndToEndSessionProblem(deviceKey, type, fixed); + } + sessionMayHaveProblems(deviceKey, timestamp) { + return this.cryptoStore.getEndToEndSessionProblem(deviceKey, timestamp); + } + filterOutNotifiedErrorDevices(devices) { + return this.cryptoStore.filterOutNotifiedErrorDevices(devices); + } + + // Outbound group session + // ====================== + + /** + * store an OutboundGroupSession in outboundGroupSessionStore + * + * @internal + */ + saveOutboundGroupSession(session) { + this.outboundGroupSessionStore[session.session_id()] = session.pickle(this.pickleKey); + } + + /** + * extract an OutboundGroupSession from outboundGroupSessionStore and call the + * given function + * + * @returns result of func + * @internal + */ + getOutboundGroupSession(sessionId, func) { + const pickled = this.outboundGroupSessionStore[sessionId]; + if (pickled === undefined) { + throw new Error("Unknown outbound group session " + sessionId); + } + const session = new global.Olm.OutboundGroupSession(); + try { + session.unpickle(this.pickleKey, pickled); + return func(session); + } finally { + session.free(); + } + } + + /** + * Generate a new outbound group session + * + * @returns sessionId for the outbound session. + */ + createOutboundGroupSession() { + const session = new global.Olm.OutboundGroupSession(); + try { + session.create(); + this.saveOutboundGroupSession(session); + return session.session_id(); + } finally { + session.free(); + } + } + + /** + * Encrypt an outgoing message with an outbound group session + * + * @param sessionId - the id of the outboundgroupsession + * @param payloadString - payload to be encrypted and sent + * + * @returns ciphertext + */ + encryptGroupMessage(sessionId, payloadString) { + _logger.logger.log(`encrypting msg with megolm session ${sessionId}`); + checkPayloadLength(payloadString); + return this.getOutboundGroupSession(sessionId, session => { + const res = session.encrypt(payloadString); + this.saveOutboundGroupSession(session); + return res; + }); + } + + /** + * Get the session keys for an outbound group session + * + * @param sessionId - the id of the outbound group session + * + * @returns current chain index, and + * base64-encoded secret key. + */ + getOutboundGroupSessionKey(sessionId) { + return this.getOutboundGroupSession(sessionId, function (session) { + return { + chain_index: session.message_index(), + key: session.session_key() + }; + }); + } + + // Inbound group session + // ===================== + + /** + * Unpickle a session from a sessionData object and invoke the given function. + * The session is valid only until func returns. + * + * @param sessionData - Object describing the session. + * @param func - Invoked with the unpickled session + * @returns result of func + */ + unpickleInboundGroupSession(sessionData, func) { + const session = new global.Olm.InboundGroupSession(); + try { + session.unpickle(this.pickleKey, sessionData.session); + return func(session); + } finally { + session.free(); + } + } + + /** + * extract an InboundGroupSession from the crypto store and call the given function + * + * @param roomId - The room ID to extract the session for, or null to fetch + * sessions for any room. + * @param txn - Opaque transaction object from cryptoStore.doTxn() + * @param func - function to call. + * + * @internal + */ + getInboundGroupSession(roomId, senderKey, sessionId, txn, func) { + this.cryptoStore.getEndToEndInboundGroupSession(senderKey, sessionId, txn, (sessionData, withheld) => { + if (sessionData === null) { + func(null, null, withheld); + return; + } + + // if we were given a room ID, check that the it matches the original one for the session. This stops + // the HS pretending a message was targeting a different room. + if (roomId !== null && roomId !== sessionData.room_id) { + throw new Error("Mismatched room_id for inbound group session (expected " + sessionData.room_id + ", was " + roomId + ")"); + } + this.unpickleInboundGroupSession(sessionData, session => { + func(session, sessionData, withheld); + }); + }); + } + + /** + * Add an inbound group session to the session store + * + * @param roomId - room in which this session will be used + * @param senderKey - base64-encoded curve25519 key of the sender + * @param forwardingCurve25519KeyChain - Devices involved in forwarding + * this session to us. + * @param sessionId - session identifier + * @param sessionKey - base64-encoded secret key + * @param keysClaimed - Other keys the sender claims. + * @param exportFormat - true if the megolm keys are in export format + * (ie, they lack an ed25519 signature) + * @param extraSessionData - any other data to be include with the session + */ + async addInboundGroupSession(roomId, senderKey, forwardingCurve25519KeyChain, sessionId, sessionKey, keysClaimed, exportFormat, extraSessionData = {}) { + await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, _indexeddbCryptoStore.IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, _indexeddbCryptoStore.IndexedDBCryptoStore.STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS], txn => { + /* if we already have this session, consider updating it */ + this.getInboundGroupSession(roomId, senderKey, sessionId, txn, (existingSession, existingSessionData) => { + // new session. + const session = new global.Olm.InboundGroupSession(); + try { + if (exportFormat) { + session.import_session(sessionKey); + } else { + session.create(sessionKey); + } + if (sessionId != session.session_id()) { + throw new Error("Mismatched group session ID from senderKey: " + senderKey); + } + if (existingSession) { + _logger.logger.log(`Update for megolm session ${senderKey}|${sessionId}`); + if (existingSession.first_known_index() <= session.first_known_index()) { + if (!existingSessionData.untrusted || extraSessionData.untrusted) { + // existing session has less-than-or-equal index + // (i.e. can decrypt at least as much), and the + // new session's trust does not win over the old + // session's trust, so keep it + _logger.logger.log(`Keeping existing megolm session ${senderKey}|${sessionId}`); + return; + } + if (existingSession.first_known_index() < session.first_known_index()) { + // We want to upgrade the existing session's trust, + // but we can't just use the new session because we'll + // lose the lower index. Check that the sessions connect + // properly, and then manually set the existing session + // as trusted. + if (existingSession.export_session(session.first_known_index()) === session.export_session(session.first_known_index())) { + _logger.logger.info("Upgrading trust of existing megolm session " + `${senderKey}|${sessionId} based on newly-received trusted session`); + existingSessionData.untrusted = false; + this.cryptoStore.storeEndToEndInboundGroupSession(senderKey, sessionId, existingSessionData, txn); + } else { + _logger.logger.warn(`Newly-received megolm session ${senderKey}|$sessionId}` + " does not match existing session! Keeping existing session"); + } + return; + } + // If the sessions have the same index, go ahead and store the new trusted one. + } + } + + _logger.logger.info(`Storing megolm session ${senderKey}|${sessionId} with first index ` + session.first_known_index()); + const sessionData = Object.assign({}, extraSessionData, { + room_id: roomId, + session: session.pickle(this.pickleKey), + keysClaimed: keysClaimed, + forwardingCurve25519KeyChain: forwardingCurve25519KeyChain + }); + this.cryptoStore.storeEndToEndInboundGroupSession(senderKey, sessionId, sessionData, txn); + if (!existingSession && extraSessionData.sharedHistory) { + this.cryptoStore.addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn); + } + } finally { + session.free(); + } + }); + }, _logger.logger.withPrefix("[addInboundGroupSession]")); + } + + /** + * Record in the data store why an inbound group session was withheld. + * + * @param roomId - room that the session belongs to + * @param senderKey - base64-encoded curve25519 key of the sender + * @param sessionId - session identifier + * @param code - reason code + * @param reason - human-readable version of `code` + */ + async addInboundGroupSessionWithheld(roomId, senderKey, sessionId, code, reason) { + await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD], txn => { + this.cryptoStore.storeEndToEndInboundGroupSessionWithheld(senderKey, sessionId, { + room_id: roomId, + code: code, + reason: reason + }, txn); + }); + } + + /** + * Decrypt a received message with an inbound group session + * + * @param roomId - room in which the message was received + * @param senderKey - base64-encoded curve25519 key of the sender + * @param sessionId - session identifier + * @param body - base64-encoded body of the encrypted message + * @param eventId - ID of the event being decrypted + * @param timestamp - timestamp of the event being decrypted + * + * @returns null if the sessionId is unknown + */ + async decryptGroupMessage(roomId, senderKey, sessionId, body, eventId, timestamp) { + let result = null; + // when the localstorage crypto store is used as an indexeddb backend, + // exceptions thrown from within the inner function are not passed through + // to the top level, so we store exceptions in a variable and raise them at + // the end + let error; + await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, _indexeddbCryptoStore.IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD], txn => { + this.getInboundGroupSession(roomId, senderKey, sessionId, txn, (session, sessionData, withheld) => { + if (session === null || sessionData === null) { + if (withheld) { + error = new algorithms.DecryptionError("MEGOLM_UNKNOWN_INBOUND_SESSION_ID", calculateWithheldMessage(withheld), { + session: senderKey + "|" + sessionId + }); + } + result = null; + return; + } + let res; + try { + res = session.decrypt(body); + } catch (e) { + if (e?.message === "OLM.UNKNOWN_MESSAGE_INDEX" && withheld) { + error = new algorithms.DecryptionError("MEGOLM_UNKNOWN_INBOUND_SESSION_ID", calculateWithheldMessage(withheld), { + session: senderKey + "|" + sessionId + }); + } else { + error = e; + } + return; + } + let plaintext = res.plaintext; + if (plaintext === undefined) { + // @ts-ignore - Compatibility for older olm versions. + plaintext = res; + } else { + // Check if we have seen this message index before to detect replay attacks. + // If the event ID and timestamp are specified, and the match the event ID + // and timestamp from the last time we used this message index, then we + // don't consider it a replay attack. + const messageIndexKey = senderKey + "|" + sessionId + "|" + res.message_index; + if (messageIndexKey in this.inboundGroupSessionMessageIndexes) { + const msgInfo = this.inboundGroupSessionMessageIndexes[messageIndexKey]; + if (msgInfo.id !== eventId || msgInfo.timestamp !== timestamp) { + error = new Error("Duplicate message index, possible replay attack: " + messageIndexKey); + return; + } + } + this.inboundGroupSessionMessageIndexes[messageIndexKey] = { + id: eventId, + timestamp: timestamp + }; + } + sessionData.session = session.pickle(this.pickleKey); + this.cryptoStore.storeEndToEndInboundGroupSession(senderKey, sessionId, sessionData, txn); + result = { + result: plaintext, + keysClaimed: sessionData.keysClaimed || {}, + senderKey: senderKey, + forwardingCurve25519KeyChain: sessionData.forwardingCurve25519KeyChain || [], + untrusted: !!sessionData.untrusted + }; + }); + }, _logger.logger.withPrefix("[decryptGroupMessage]")); + if (error) { + throw error; + } + return result; + } + + /** + * Determine if we have the keys for a given megolm session + * + * @param roomId - room in which the message was received + * @param senderKey - base64-encoded curve25519 key of the sender + * @param sessionId - session identifier + * + * @returns true if we have the keys to this session + */ + async hasInboundSessionKeys(roomId, senderKey, sessionId) { + let result; + await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, _indexeddbCryptoStore.IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD], txn => { + this.cryptoStore.getEndToEndInboundGroupSession(senderKey, sessionId, txn, sessionData => { + if (sessionData === null) { + result = false; + return; + } + if (roomId !== sessionData.room_id) { + _logger.logger.warn(`requested keys for inbound group session ${senderKey}|` + `${sessionId}, with incorrect room_id ` + `(expected ${sessionData.room_id}, ` + `was ${roomId})`); + result = false; + } else { + result = true; + } + }); + }, _logger.logger.withPrefix("[hasInboundSessionKeys]")); + return result; + } + + /** + * Extract the keys to a given megolm session, for sharing + * + * @param roomId - room in which the message was received + * @param senderKey - base64-encoded curve25519 key of the sender + * @param sessionId - session identifier + * @param chainIndex - The chain index at which to export the session. + * If omitted, export at the first index we know about. + * + * @returns + * 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). + */ + async getInboundGroupSessionKey(roomId, senderKey, sessionId, chainIndex) { + let result = null; + await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, _indexeddbCryptoStore.IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD], txn => { + this.getInboundGroupSession(roomId, senderKey, sessionId, txn, (session, sessionData) => { + if (session === null || sessionData === null) { + result = null; + return; + } + if (chainIndex === undefined) { + chainIndex = session.first_known_index(); + } + const exportedSession = session.export_session(chainIndex); + const claimedKeys = sessionData.keysClaimed || {}; + const senderEd25519Key = claimedKeys.ed25519 || null; + const forwardingKeyChain = sessionData.forwardingCurve25519KeyChain || []; + // older forwarded keys didn't set the "untrusted" + // property, but can be identified by having a + // non-empty forwarding key chain. These keys should + // be marked as untrusted since we don't know that they + // can be trusted + const untrusted = "untrusted" in sessionData ? sessionData.untrusted : forwardingKeyChain.length > 0; + result = { + chain_index: chainIndex, + key: exportedSession, + forwarding_curve25519_key_chain: forwardingKeyChain, + sender_claimed_ed25519_key: senderEd25519Key, + shared_history: sessionData.sharedHistory || false, + untrusted: untrusted + }; + }); + }, _logger.logger.withPrefix("[getInboundGroupSessionKey]")); + return result; + } + + /** + * Export an inbound group session + * + * @param senderKey - base64-encoded curve25519 key of the sender + * @param sessionId - session identifier + * @param sessionData - The session object from the store + * @returns exported session data + */ + exportInboundGroupSession(senderKey, sessionId, sessionData) { + return this.unpickleInboundGroupSession(sessionData, session => { + const messageIndex = session.first_known_index(); + return { + "sender_key": senderKey, + "sender_claimed_keys": sessionData.keysClaimed, + "room_id": sessionData.room_id, + "session_id": sessionId, + "session_key": session.export_session(messageIndex), + "forwarding_curve25519_key_chain": sessionData.forwardingCurve25519KeyChain || [], + "first_known_index": session.first_known_index(), + "org.matrix.msc3061.shared_history": sessionData.sharedHistory || false + }; + }); + } + async getSharedHistoryInboundGroupSessions(roomId) { + let result; + await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS], txn => { + result = this.cryptoStore.getSharedHistoryInboundGroupSessions(roomId, txn); + }, _logger.logger.withPrefix("[getSharedHistoryInboundGroupSessionsForRoom]")); + return result; + } + + // Utilities + // ========= + + /** + * Verify an ed25519 signature. + * + * @param key - ed25519 key + * @param message - message which was signed + * @param signature - base64-encoded signature to be checked + * + * @throws Error if there is a problem with the verification. If the key was + * too small then the message will be "OLM.INVALID_BASE64". If the signature + * was invalid then the message will be "OLM.BAD_MESSAGE_MAC". + */ + verifySignature(key, message, signature) { + this.getUtility(function (util) { + util.ed25519_verify(key, message, signature); + }); + } +} +exports.OlmDevice = OlmDevice; +const WITHHELD_MESSAGES = { + "m.unverified": "The sender has disabled encrypting to unverified devices.", + "m.blacklisted": "The sender has blocked you.", + "m.unauthorised": "You are not authorised to read the message.", + "m.no_olm": "Unable to establish a secure channel." +}; + +/** + * Calculate the message to use for the exception when a session key is withheld. + * + * @param withheld - An object that describes why the key was withheld. + * + * @returns the message + * + * @internal + */ +exports.WITHHELD_MESSAGES = WITHHELD_MESSAGES; +function calculateWithheldMessage(withheld) { + if (withheld.code && withheld.code in WITHHELD_MESSAGES) { + return WITHHELD_MESSAGES[withheld.code]; + } else if (withheld.reason) { + return withheld.reason; + } else { + return "decryption key withheld"; + } +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/OutgoingRoomKeyRequestManager.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/OutgoingRoomKeyRequestManager.js new file mode 100644 index 0000000000..a9d056c5ea --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/OutgoingRoomKeyRequestManager.js @@ -0,0 +1,406 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.RoomKeyRequestState = exports.OutgoingRoomKeyRequestManager = void 0; +var _uuid = require("uuid"); +var _logger = require("../logger"); +var _event = require("../@types/event"); +var _utils = require("../utils"); +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2017 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/** + * Internal module. Management of outgoing room key requests. + * + * See https://docs.google.com/document/d/1m4gQkcnJkxNuBmb5NoFCIadIY-DyqqNAS3lloE73BlQ + * for draft documentation on what we're supposed to be implementing here. + */ + +// delay between deciding we want some keys, and sending out the request, to +// allow for (a) it turning up anyway, (b) grouping requests together +const SEND_KEY_REQUESTS_DELAY_MS = 500; + +/** + * possible states for a room key request + * + * The state machine looks like: + * ``` + * + * | (cancellation sent) + * | .-------------------------------------------------. + * | | | + * V V (cancellation requested) | + * UNSENT -----------------------------+ | + * | | | + * | | | + * | (send successful) | CANCELLATION_PENDING_AND_WILL_RESEND + * V | Λ + * SENT | | + * |-------------------------------- | --------------' + * | | (cancellation requested with intent + * | | to resend the original request) + * | | + * | (cancellation requested) | + * V | + * CANCELLATION_PENDING | + * | | + * | (cancellation sent) | + * V | + * (deleted) <---------------------------+ + * ``` + */ +let RoomKeyRequestState = /*#__PURE__*/function (RoomKeyRequestState) { + RoomKeyRequestState[RoomKeyRequestState["Unsent"] = 0] = "Unsent"; + RoomKeyRequestState[RoomKeyRequestState["Sent"] = 1] = "Sent"; + RoomKeyRequestState[RoomKeyRequestState["CancellationPending"] = 2] = "CancellationPending"; + RoomKeyRequestState[RoomKeyRequestState["CancellationPendingAndWillResend"] = 3] = "CancellationPendingAndWillResend"; + return RoomKeyRequestState; +}({}); +exports.RoomKeyRequestState = RoomKeyRequestState; +class OutgoingRoomKeyRequestManager { + constructor(baseApis, deviceId, cryptoStore) { + this.baseApis = baseApis; + this.deviceId = deviceId; + this.cryptoStore = cryptoStore; + // handle for the delayed call to sendOutgoingRoomKeyRequests. Non-null + // if the callback has been set, or if it is still running. + _defineProperty(this, "sendOutgoingRoomKeyRequestsTimer", void 0); + // sanity check to ensure that we don't end up with two concurrent runs + // of sendOutgoingRoomKeyRequests + _defineProperty(this, "sendOutgoingRoomKeyRequestsRunning", false); + _defineProperty(this, "clientRunning", true); + } + + /** + * Called when the client is stopped. Stops any running background processes. + */ + stop() { + _logger.logger.log("stopping OutgoingRoomKeyRequestManager"); + // stop the timer on the next run + this.clientRunning = false; + } + + /** + * Send any requests that have been queued + */ + sendQueuedRequests() { + this.startTimer(); + } + + /** + * Queue up a room key request, if we haven't already queued or sent one. + * + * The `requestBody` is compared (with a deep-equality check) against + * previous queued or sent requests and if it matches, no change is made. + * Otherwise, a request is added to the pending list, and a job is started + * in the background to send it. + * + * @param resend - whether to resend the key request if there is + * already one + * + * @returns resolves when the request has been added to the + * pending list (or we have established that a similar request already + * exists) + */ + async queueRoomKeyRequest(requestBody, recipients, resend = false) { + const req = await this.cryptoStore.getOutgoingRoomKeyRequest(requestBody); + if (!req) { + await this.cryptoStore.getOrAddOutgoingRoomKeyRequest({ + requestBody: requestBody, + recipients: recipients, + requestId: this.baseApis.makeTxnId(), + state: RoomKeyRequestState.Unsent + }); + } else { + switch (req.state) { + case RoomKeyRequestState.CancellationPendingAndWillResend: + case RoomKeyRequestState.Unsent: + // nothing to do here, since we're going to send a request anyways + return; + case RoomKeyRequestState.CancellationPending: + { + // existing request is about to be cancelled. If we want to + // resend, then change the state so that it resends after + // cancelling. Otherwise, just cancel the cancellation. + const state = resend ? RoomKeyRequestState.CancellationPendingAndWillResend : RoomKeyRequestState.Sent; + await this.cryptoStore.updateOutgoingRoomKeyRequest(req.requestId, RoomKeyRequestState.CancellationPending, { + state, + cancellationTxnId: this.baseApis.makeTxnId() + }); + break; + } + case RoomKeyRequestState.Sent: + { + // a request has already been sent. If we don't want to + // resend, then do nothing. If we do want to, then cancel the + // existing request and send a new one. + if (resend) { + const state = RoomKeyRequestState.CancellationPendingAndWillResend; + const updatedReq = await this.cryptoStore.updateOutgoingRoomKeyRequest(req.requestId, RoomKeyRequestState.Sent, { + state, + cancellationTxnId: this.baseApis.makeTxnId(), + // need to use a new transaction ID so that + // the request gets sent + requestTxnId: this.baseApis.makeTxnId() + }); + if (!updatedReq) { + // updateOutgoingRoomKeyRequest couldn't find the request + // in state ROOM_KEY_REQUEST_STATES.SENT, so we must have + // raced with another tab to mark the request cancelled. + // Try again, to make sure the request is resent. + return this.queueRoomKeyRequest(requestBody, recipients, resend); + } + + // We don't want to wait for the timer, so we send it + // immediately. (We might actually end up racing with the timer, + // but that's ok: even if we make the request twice, we'll do it + // with the same transaction_id, so only one message will get + // sent). + // + // (We also don't want to wait for the response from the server + // here, as it will slow down processing of received keys if we + // do.) + try { + await this.sendOutgoingRoomKeyRequestCancellation(updatedReq, true); + } catch (e) { + _logger.logger.error("Error sending room key request cancellation;" + " will retry later.", e); + } + // The request has transitioned from + // CANCELLATION_PENDING_AND_WILL_RESEND to UNSENT. We + // still need to resend the request which is now UNSENT, so + // start the timer if it isn't already started. + } + + break; + } + default: + throw new Error("unhandled state: " + req.state); + } + } + } + + /** + * Cancel room key requests, if any match the given requestBody + * + * + * @returns resolves when the request has been updated in our + * pending list. + */ + cancelRoomKeyRequest(requestBody) { + return this.cryptoStore.getOutgoingRoomKeyRequest(requestBody).then(req => { + if (!req) { + // no request was made for this key + return; + } + switch (req.state) { + case RoomKeyRequestState.CancellationPending: + case RoomKeyRequestState.CancellationPendingAndWillResend: + // nothing to do here + return; + case RoomKeyRequestState.Unsent: + // just delete it + + // FIXME: ghahah we may have attempted to send it, and + // not yet got a successful response. So the server + // may have seen it, so we still need to send a cancellation + // in that case :/ + + _logger.logger.log("deleting unnecessary room key request for " + stringifyRequestBody(requestBody)); + return this.cryptoStore.deleteOutgoingRoomKeyRequest(req.requestId, RoomKeyRequestState.Unsent); + case RoomKeyRequestState.Sent: + { + // send a cancellation. + return this.cryptoStore.updateOutgoingRoomKeyRequest(req.requestId, RoomKeyRequestState.Sent, { + state: RoomKeyRequestState.CancellationPending, + cancellationTxnId: this.baseApis.makeTxnId() + }).then(updatedReq => { + if (!updatedReq) { + // updateOutgoingRoomKeyRequest couldn't find the + // request in state ROOM_KEY_REQUEST_STATES.SENT, + // so we must have raced with another tab to mark + // the request cancelled. There is no point in + // sending another cancellation since the other tab + // will do it. + _logger.logger.log("Tried to cancel room key request for " + stringifyRequestBody(requestBody) + " but it was already cancelled in another tab"); + return; + } + + // We don't want to wait for the timer, so we send it + // immediately. (We might actually end up racing with the timer, + // but that's ok: even if we make the request twice, we'll do it + // with the same transaction_id, so only one message will get + // sent). + // + // (We also don't want to wait for the response from the server + // here, as it will slow down processing of received keys if we + // do.) + this.sendOutgoingRoomKeyRequestCancellation(updatedReq).catch(e => { + _logger.logger.error("Error sending room key request cancellation;" + " will retry later.", e); + this.startTimer(); + }); + }); + } + default: + throw new Error("unhandled state: " + req.state); + } + }); + } + + /** + * Look for room key requests by target device and state + * + * @param userId - Target user ID + * @param deviceId - Target device ID + * + * @returns resolves to a list of all the {@link OutgoingRoomKeyRequest} + */ + getOutgoingSentRoomKeyRequest(userId, deviceId) { + return this.cryptoStore.getOutgoingRoomKeyRequestsByTarget(userId, deviceId, [RoomKeyRequestState.Sent]); + } + + /** + * Find anything in `sent` state, and kick it around the loop again. + * This is intended for situations where something substantial has changed, and we + * don't really expect the other end to even care about the cancellation. + * For example, after initialization or self-verification. + * @returns An array of `queueRoomKeyRequest` outputs. + */ + async cancelAndResendAllOutgoingRequests() { + const outgoings = await this.cryptoStore.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent); + return Promise.all(outgoings.map(({ + requestBody, + recipients + }) => this.queueRoomKeyRequest(requestBody, recipients, true))); + } + + // start the background timer to send queued requests, if the timer isn't + // already running + startTimer() { + if (this.sendOutgoingRoomKeyRequestsTimer) { + return; + } + const startSendingOutgoingRoomKeyRequests = () => { + if (this.sendOutgoingRoomKeyRequestsRunning) { + throw new Error("RoomKeyRequestSend already in progress!"); + } + this.sendOutgoingRoomKeyRequestsRunning = true; + this.sendOutgoingRoomKeyRequests().finally(() => { + this.sendOutgoingRoomKeyRequestsRunning = false; + }).catch(e => { + // this should only happen if there is an indexeddb error, + // in which case we're a bit stuffed anyway. + _logger.logger.warn(`error in OutgoingRoomKeyRequestManager: ${e}`); + }); + }; + this.sendOutgoingRoomKeyRequestsTimer = setTimeout(startSendingOutgoingRoomKeyRequests, SEND_KEY_REQUESTS_DELAY_MS); + } + + // look for and send any queued requests. Runs itself recursively until + // there are no more requests, or there is an error (in which case, the + // timer will be restarted before the promise resolves). + async sendOutgoingRoomKeyRequests() { + if (!this.clientRunning) { + this.sendOutgoingRoomKeyRequestsTimer = undefined; + return; + } + const req = await this.cryptoStore.getOutgoingRoomKeyRequestByState([RoomKeyRequestState.CancellationPending, RoomKeyRequestState.CancellationPendingAndWillResend, RoomKeyRequestState.Unsent]); + if (!req) { + this.sendOutgoingRoomKeyRequestsTimer = undefined; + return; + } + try { + switch (req.state) { + case RoomKeyRequestState.Unsent: + await this.sendOutgoingRoomKeyRequest(req); + break; + case RoomKeyRequestState.CancellationPending: + await this.sendOutgoingRoomKeyRequestCancellation(req); + break; + case RoomKeyRequestState.CancellationPendingAndWillResend: + await this.sendOutgoingRoomKeyRequestCancellation(req, true); + break; + } + + // go around the loop again + return this.sendOutgoingRoomKeyRequests(); + } catch (e) { + _logger.logger.error("Error sending room key request; will retry later.", e); + this.sendOutgoingRoomKeyRequestsTimer = undefined; + } + } + + // given a RoomKeyRequest, send it and update the request record + sendOutgoingRoomKeyRequest(req) { + _logger.logger.log(`Requesting keys for ${stringifyRequestBody(req.requestBody)}` + ` from ${stringifyRecipientList(req.recipients)}` + `(id ${req.requestId})`); + const requestMessage = { + action: "request", + requesting_device_id: this.deviceId, + request_id: req.requestId, + body: req.requestBody + }; + return this.sendMessageToDevices(requestMessage, req.recipients, req.requestTxnId || req.requestId).then(() => { + return this.cryptoStore.updateOutgoingRoomKeyRequest(req.requestId, RoomKeyRequestState.Unsent, { + state: RoomKeyRequestState.Sent + }); + }); + } + + // Given a RoomKeyRequest, cancel it and delete the request record unless + // andResend is set, in which case transition to UNSENT. + sendOutgoingRoomKeyRequestCancellation(req, andResend = false) { + _logger.logger.log(`Sending cancellation for key request for ` + `${stringifyRequestBody(req.requestBody)} to ` + `${stringifyRecipientList(req.recipients)} ` + `(cancellation id ${req.cancellationTxnId})`); + const requestMessage = { + action: "request_cancellation", + requesting_device_id: this.deviceId, + request_id: req.requestId + }; + return this.sendMessageToDevices(requestMessage, req.recipients, req.cancellationTxnId).then(() => { + if (andResend) { + // We want to resend, so transition to UNSENT + return this.cryptoStore.updateOutgoingRoomKeyRequest(req.requestId, RoomKeyRequestState.CancellationPendingAndWillResend, { + state: RoomKeyRequestState.Unsent + }); + } + return this.cryptoStore.deleteOutgoingRoomKeyRequest(req.requestId, RoomKeyRequestState.CancellationPending); + }); + } + + // send a RoomKeyRequest to a list of recipients + sendMessageToDevices(message, recipients, txnId) { + const contentMap = new _utils.MapWithDefault(() => new Map()); + for (const recip of recipients) { + const userDeviceMap = contentMap.getOrCreate(recip.userId); + userDeviceMap.set(recip.deviceId, _objectSpread(_objectSpread({}, message), {}, { + [_event.ToDeviceMessageId]: (0, _uuid.v4)() + })); + } + return this.baseApis.sendToDevice(_event.EventType.RoomKeyRequest, contentMap, txnId); + } +} +exports.OutgoingRoomKeyRequestManager = OutgoingRoomKeyRequestManager; +function stringifyRequestBody(requestBody) { + // we assume that the request is for megolm keys, which are identified by + // room id and session id + return requestBody.room_id + " / " + requestBody.session_id; +} +function stringifyRecipientList(recipients) { + return `[${recipients.map(r => `${r.userId}:${r.deviceId}`).join(",")}]`; +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/RoomList.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/RoomList.js new file mode 100644 index 0000000000..24dd53ed3c --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/RoomList.js @@ -0,0 +1,60 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.RoomList = void 0; +var _indexeddbCryptoStore = require("./store/indexeddb-crypto-store"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2018 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + 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. + */ /** + * Manages the list of encrypted rooms + */ +/* eslint-disable camelcase */ + +/* eslint-enable camelcase */ + +class RoomList { + constructor(cryptoStore) { + this.cryptoStore = cryptoStore; + // Object of roomId -> room e2e info object (body of the m.room.encryption event) + _defineProperty(this, "roomEncryption", {}); + } + async init() { + await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ROOMS], txn => { + this.cryptoStore.getEndToEndRooms(txn, result => { + this.roomEncryption = result; + }); + }); + } + getRoomEncryption(roomId) { + return this.roomEncryption[roomId] || null; + } + isRoomEncrypted(roomId) { + return Boolean(this.getRoomEncryption(roomId)); + } + async setRoomEncryption(roomId, roomInfo) { + // important that this happens before calling into the store + // as it prevents the Crypto::setRoomEncryption from calling + // this twice for consecutive m.room.encryption events + this.roomEncryption[roomId] = roomInfo; + await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ROOMS], txn => { + this.cryptoStore.storeEndToEndRoom(roomId, roomInfo, txn); + }); + } +} +exports.RoomList = RoomList;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/SecretSharing.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/SecretSharing.js new file mode 100644 index 0000000000..805fd64471 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/SecretSharing.js @@ -0,0 +1,199 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.SecretSharing = void 0; +var _uuid = require("uuid"); +var _utils = require("../utils"); +var _event = require("../@types/event"); +var _logger = require("../logger"); +var olmlib = _interopRequireWildcard(require("./olmlib")); +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2019-2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +class SecretSharing { + constructor(baseApis, cryptoCallbacks) { + this.baseApis = baseApis; + this.cryptoCallbacks = cryptoCallbacks; + _defineProperty(this, "requests", new Map()); + } + + /** + * Request a secret from another device + * + * @param name - the name of the secret to request + * @param devices - the devices to request the secret from + */ + request(name, devices) { + const requestId = this.baseApis.makeTxnId(); + const deferred = (0, _utils.defer)(); + this.requests.set(requestId, { + name, + devices, + deferred + }); + const cancel = reason => { + // send cancellation event + const cancelData = { + action: "request_cancellation", + requesting_device_id: this.baseApis.deviceId, + request_id: requestId + }; + const toDevice = new Map(); + for (const device of devices) { + toDevice.set(device, cancelData); + } + this.baseApis.sendToDevice("m.secret.request", new Map([[this.baseApis.getUserId(), toDevice]])); + + // and reject the promise so that anyone waiting on it will be + // notified + deferred.reject(new Error(reason || "Cancelled")); + }; + + // send request to devices + const requestData = { + name, + action: "request", + requesting_device_id: this.baseApis.deviceId, + request_id: requestId, + [_event.ToDeviceMessageId]: (0, _uuid.v4)() + }; + const toDevice = new Map(); + for (const device of devices) { + toDevice.set(device, requestData); + } + _logger.logger.info(`Request secret ${name} from ${devices}, id ${requestId}`); + this.baseApis.sendToDevice("m.secret.request", new Map([[this.baseApis.getUserId(), toDevice]])); + return { + requestId, + promise: deferred.promise, + cancel + }; + } + async onRequestReceived(event) { + const sender = event.getSender(); + const content = event.getContent(); + if (sender !== this.baseApis.getUserId() || !(content.name && content.action && content.requesting_device_id && content.request_id)) { + // ignore requests from anyone else, for now + return; + } + const deviceId = content.requesting_device_id; + // check if it's a cancel + if (content.action === "request_cancellation") { + /* + Looks like we intended to emit events when we got cancelations, but + we never put anything in the _incomingRequests object, and the request + itself doesn't use events anyway so if we were to wire up cancellations, + they probably ought to use the same callback interface. I'm leaving them + disabled for now while converting this file to typescript. + if (this._incomingRequests[deviceId] + && this._incomingRequests[deviceId][content.request_id]) { + logger.info( + "received request cancellation for secret (" + sender + + ", " + deviceId + ", " + content.request_id + ")", + ); + this.baseApis.emit("crypto.secrets.requestCancelled", { + user_id: sender, + device_id: deviceId, + request_id: content.request_id, + }); + } + */ + } else if (content.action === "request") { + if (deviceId === this.baseApis.deviceId) { + // no point in trying to send ourself the secret + return; + } + + // check if we have the secret + _logger.logger.info("received request for secret (" + sender + ", " + deviceId + ", " + content.request_id + ")"); + if (!this.cryptoCallbacks.onSecretRequested) { + return; + } + const secret = await this.cryptoCallbacks.onSecretRequested(sender, deviceId, content.request_id, content.name, this.baseApis.checkDeviceTrust(sender, deviceId)); + if (secret) { + _logger.logger.info(`Preparing ${content.name} secret for ${deviceId}`); + const payload = { + type: "m.secret.send", + content: { + request_id: content.request_id, + secret: secret + } + }; + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.baseApis.crypto.olmDevice.deviceCurve25519Key, + ciphertext: {}, + [_event.ToDeviceMessageId]: (0, _uuid.v4)() + }; + await olmlib.ensureOlmSessionsForDevices(this.baseApis.crypto.olmDevice, this.baseApis, new Map([[sender, [this.baseApis.getStoredDevice(sender, deviceId)]]])); + await olmlib.encryptMessageForDevice(encryptedContent.ciphertext, this.baseApis.getUserId(), this.baseApis.deviceId, this.baseApis.crypto.olmDevice, sender, this.baseApis.getStoredDevice(sender, deviceId), payload); + const contentMap = new Map([[sender, new Map([[deviceId, encryptedContent]])]]); + _logger.logger.info(`Sending ${content.name} secret for ${deviceId}`); + this.baseApis.sendToDevice("m.room.encrypted", contentMap); + } else { + _logger.logger.info(`Request denied for ${content.name} secret for ${deviceId}`); + } + } + } + onSecretReceived(event) { + if (event.getSender() !== this.baseApis.getUserId()) { + // we shouldn't be receiving secrets from anyone else, so ignore + // because someone could be trying to send us bogus data + return; + } + if (!olmlib.isOlmEncrypted(event)) { + _logger.logger.error("secret event not properly encrypted"); + return; + } + const content = event.getContent(); + const senderKeyUser = this.baseApis.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, event.getSenderKey() || ""); + if (senderKeyUser !== event.getSender()) { + _logger.logger.error("sending device does not belong to the user it claims to be from"); + return; + } + _logger.logger.log("got secret share for request", content.request_id); + const requestControl = this.requests.get(content.request_id); + if (requestControl) { + // make sure that the device that sent it is one of the devices that + // we requested from + const deviceInfo = this.baseApis.crypto.deviceList.getDeviceByIdentityKey(olmlib.OLM_ALGORITHM, event.getSenderKey()); + if (!deviceInfo) { + _logger.logger.log("secret share from unknown device with key", event.getSenderKey()); + return; + } + if (!requestControl.devices.includes(deviceInfo.deviceId)) { + _logger.logger.log("unsolicited secret share from device", deviceInfo.deviceId); + return; + } + // unsure that the sender is trusted. In theory, this check is + // unnecessary since we only accept secret shares from devices that + // we requested from, but it doesn't hurt. + const deviceTrust = this.baseApis.crypto.checkDeviceInfoTrust(event.getSender(), deviceInfo); + if (!deviceTrust.isVerified()) { + _logger.logger.log("secret share from unverified device"); + return; + } + _logger.logger.log(`Successfully received secret ${requestControl.name} ` + `from ${deviceInfo.deviceId}`); + requestControl.deferred.resolve(content.secret); + } + } +} +exports.SecretSharing = SecretSharing;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/SecretStorage.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/SecretStorage.js new file mode 100644 index 0000000000..9b363f359c --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/SecretStorage.js @@ -0,0 +1,119 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.SecretStorage = void 0; +var _secretStorage = require("../secret-storage"); +var _SecretSharing = require("./SecretSharing"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/* re-exports for backwards compatibility */ + +/** + * Implements Secure Secret Storage and Sharing (MSC1946) + * + * @deprecated This is just a backwards-compatibility hack which will be removed soon. + * Use {@link SecretStorage.ServerSideSecretStorageImpl} from `../secret-storage` and/or {@link SecretSharing} from `./SecretSharing`. + */ +class SecretStorage { + // In its pure javascript days, this was relying on some proper Javascript-style + // type-abuse where sometimes we'd pass in a fake client object with just the account + // data methods implemented, which is all this class needs unless you use the secret + // sharing code, so it was fine. As a low-touch TypeScript migration, we added + // an extra, optional param for a real matrix client, so you can not pass it as long + // as you don't request any secrets. + // + // Nowadays, the whole class is scheduled for destruction, once we get rid of the legacy + // Crypto impl that exposes it. + constructor(accountDataAdapter, cryptoCallbacks, baseApis) { + _defineProperty(this, "storageImpl", void 0); + _defineProperty(this, "sharingImpl", void 0); + this.storageImpl = new _secretStorage.ServerSideSecretStorageImpl(accountDataAdapter, cryptoCallbacks); + this.sharingImpl = new _SecretSharing.SecretSharing(baseApis, cryptoCallbacks); + } + getDefaultKeyId() { + return this.storageImpl.getDefaultKeyId(); + } + setDefaultKeyId(keyId) { + return this.storageImpl.setDefaultKeyId(keyId); + } + + /** + * Add a key for encrypting secrets. + */ + addKey(algorithm, opts = {}, keyId) { + return this.storageImpl.addKey(algorithm, opts, keyId); + } + + /** + * Get the key information for a given ID. + */ + getKey(keyId) { + return this.storageImpl.getKey(keyId); + } + + /** + * Check whether we have a key with a given ID. + */ + hasKey(keyId) { + return this.storageImpl.hasKey(keyId); + } + + /** + * Check whether a key matches what we expect based on the key info + */ + checkKey(key, info) { + return this.storageImpl.checkKey(key, info); + } + + /** + * Store an encrypted secret on the server + */ + store(name, secret, keys) { + return this.storageImpl.store(name, secret, keys); + } + + /** + * Get a secret from storage. + */ + get(name) { + return this.storageImpl.get(name); + } + + /** + * Check if a secret is stored on the server. + */ + async isStored(name) { + return this.storageImpl.isStored(name); + } + + /** + * Request a secret from another device + */ + request(name, devices) { + return this.sharingImpl.request(name, devices); + } + onRequestReceived(event) { + return this.sharingImpl.onRequestReceived(event); + } + onSecretReceived(event) { + this.sharingImpl.onSecretReceived(event); + } +} +exports.SecretStorage = SecretStorage;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/aes.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/aes.js new file mode 100644 index 0000000000..e48c59446c --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/aes.js @@ -0,0 +1,127 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.calculateKeyCheck = calculateKeyCheck; +exports.decryptAES = decryptAES; +exports.encryptAES = encryptAES; +var _olmlib = require("./olmlib"); +var _crypto = require("./crypto"); +/* +Copyright 2020 - 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +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. +*/ + +// salt for HKDF, with 8 bytes of zeros +const zeroSalt = new Uint8Array(8); +/** + * encrypt a string + * + * @param data - the plaintext to encrypt + * @param key - the encryption key to use + * @param name - the name of the secret + * @param ivStr - the initialization vector to use + */ +async function encryptAES(data, key, name, ivStr) { + let iv; + if (ivStr) { + iv = (0, _olmlib.decodeBase64)(ivStr); + } else { + iv = new Uint8Array(16); + _crypto.crypto.getRandomValues(iv); + + // clear bit 63 of the IV to stop us hitting the 64-bit counter boundary + // (which would mean we wouldn't be able to decrypt on Android). The loss + // of a single bit of iv is a price we have to pay. + iv[8] &= 0x7f; + } + const [aesKey, hmacKey] = await deriveKeys(key, name); + const encodedData = new _crypto.TextEncoder().encode(data); + const ciphertext = await _crypto.subtleCrypto.encrypt({ + name: "AES-CTR", + counter: iv, + length: 64 + }, aesKey, encodedData); + const hmac = await _crypto.subtleCrypto.sign({ + name: "HMAC" + }, hmacKey, ciphertext); + return { + iv: (0, _olmlib.encodeBase64)(iv), + ciphertext: (0, _olmlib.encodeBase64)(ciphertext), + mac: (0, _olmlib.encodeBase64)(hmac) + }; +} + +/** + * decrypt a string + * + * @param data - the encrypted data + * @param key - the encryption key to use + * @param name - the name of the secret + */ +async function decryptAES(data, key, name) { + const [aesKey, hmacKey] = await deriveKeys(key, name); + const ciphertext = (0, _olmlib.decodeBase64)(data.ciphertext); + if (!(await _crypto.subtleCrypto.verify({ + name: "HMAC" + }, hmacKey, (0, _olmlib.decodeBase64)(data.mac), ciphertext))) { + throw new Error(`Error decrypting secret ${name}: bad MAC`); + } + const plaintext = await _crypto.subtleCrypto.decrypt({ + name: "AES-CTR", + counter: (0, _olmlib.decodeBase64)(data.iv), + length: 64 + }, aesKey, ciphertext); + return new TextDecoder().decode(new Uint8Array(plaintext)); +} +async function deriveKeys(key, name) { + const hkdfkey = await _crypto.subtleCrypto.importKey("raw", key, { + name: "HKDF" + }, false, ["deriveBits"]); + const keybits = await _crypto.subtleCrypto.deriveBits({ + name: "HKDF", + salt: zeroSalt, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/879 + info: new _crypto.TextEncoder().encode(name), + hash: "SHA-256" + }, hkdfkey, 512); + const aesKey = keybits.slice(0, 32); + const hmacKey = keybits.slice(32); + const aesProm = _crypto.subtleCrypto.importKey("raw", aesKey, { + name: "AES-CTR" + }, false, ["encrypt", "decrypt"]); + const hmacProm = _crypto.subtleCrypto.importKey("raw", hmacKey, { + name: "HMAC", + hash: { + name: "SHA-256" + } + }, false, ["sign", "verify"]); + return Promise.all([aesProm, hmacProm]); +} + +// string of zeroes, for calculating the key check +const ZERO_STR = "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"; + +/** Calculate the MAC for checking the key. + * + * @param key - the key to use + * @param iv - The initialization vector as a base64-encoded string. + * If omitted, a random initialization vector will be created. + * @returns An object that contains, `mac` and `iv` properties. + */ +function calculateKeyCheck(key, iv) { + return encryptAES(ZERO_STR, key, "", iv); +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/base.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/base.js new file mode 100644 index 0000000000..803b5cf8fd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/base.js @@ -0,0 +1,226 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.UnknownDeviceError = exports.EncryptionAlgorithm = exports.ENCRYPTION_CLASSES = exports.DecryptionError = exports.DecryptionAlgorithm = exports.DECRYPTION_CLASSES = void 0; +exports.registerAlgorithm = registerAlgorithm; +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } +/* +Copyright 2016 - 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Internal module. Defines the base classes of the encryption implementations + */ + +/** + * Map of registered encryption algorithm classes. A map from string to {@link EncryptionAlgorithm} class + */ +const ENCRYPTION_CLASSES = new Map(); +exports.ENCRYPTION_CLASSES = ENCRYPTION_CLASSES; +/** + * map of registered encryption algorithm classes. Map from string to {@link DecryptionAlgorithm} class + */ +const DECRYPTION_CLASSES = new Map(); +exports.DECRYPTION_CLASSES = DECRYPTION_CLASSES; +/** + * base type for encryption implementations + */ +class EncryptionAlgorithm { + /** + * @param params - parameters + */ + constructor(params) { + _defineProperty(this, "userId", void 0); + _defineProperty(this, "deviceId", void 0); + _defineProperty(this, "crypto", void 0); + _defineProperty(this, "olmDevice", void 0); + _defineProperty(this, "baseApis", void 0); + _defineProperty(this, "roomId", void 0); + this.userId = params.userId; + this.deviceId = params.deviceId; + this.crypto = params.crypto; + this.olmDevice = params.olmDevice; + this.baseApis = params.baseApis; + this.roomId = params.roomId; + } + + /** + * Perform any background tasks that can be done before a message is ready to + * send, in order to speed up sending of the message. + * + * @param room - the room the event is in + */ + prepareToEncrypt(room) {} + + /** + * Encrypt a message event + * + * @public + * + * @param content - event content + * + * @returns Promise which resolves to the new event body + */ + + /** + * Called when the membership of a member of the room changes. + * + * @param event - event causing the change + * @param member - user whose membership changed + * @param oldMembership - previous membership + * @public + */ + onRoomMembership(event, member, oldMembership) {} +} + +/** + * base type for decryption implementations + */ +exports.EncryptionAlgorithm = EncryptionAlgorithm; +class DecryptionAlgorithm { + constructor(params) { + _defineProperty(this, "userId", void 0); + _defineProperty(this, "crypto", void 0); + _defineProperty(this, "olmDevice", void 0); + _defineProperty(this, "baseApis", void 0); + _defineProperty(this, "roomId", void 0); + this.userId = params.userId; + this.crypto = params.crypto; + this.olmDevice = params.olmDevice; + this.baseApis = params.baseApis; + this.roomId = params.roomId; + } + + /** + * Decrypt an event + * + * @param event - undecrypted event + * + * @returns promise which + * resolves once we have finished decrypting. Rejects with an + * `algorithms.DecryptionError` if there is a problem decrypting the event. + */ + + /** + * Handle a key event + * + * @param params - event key event + */ + async onRoomKeyEvent(params) { + // ignore by default + } + + /** + * Import a room key + * + * @param opts - object + */ + async importRoomKey(session, opts) { + // ignore by default + } + + /** + * Determine if we have the keys necessary to respond to a room key request + * + * @returns true if we have the keys and could (theoretically) share + * them; else false. + */ + hasKeysForKeyRequest(keyRequest) { + return Promise.resolve(false); + } + + /** + * Send the response to a room key request + * + */ + shareKeysWithDevice(keyRequest) { + throw new Error("shareKeysWithDevice not supported for this DecryptionAlgorithm"); + } + + /** + * Retry decrypting all the events from a sender that haven't been + * decrypted yet. + * + * @param senderKey - the sender's key + */ + async retryDecryptionFromSender(senderKey) { + // ignore by default + return false; + } +} + +/** + * Exception thrown when decryption fails + * + * @param msg - user-visible message describing the problem + * + * @param details - key/value pairs reported in the logs but not shown + * to the user. + */ +exports.DecryptionAlgorithm = DecryptionAlgorithm; +class DecryptionError extends Error { + constructor(code, msg, details) { + super(msg); + this.code = code; + _defineProperty(this, "detailedString", void 0); + this.code = code; + this.name = "DecryptionError"; + this.detailedString = detailedStringForDecryptionError(this, details); + } +} +exports.DecryptionError = DecryptionError; +function detailedStringForDecryptionError(err, details) { + let result = err.name + "[msg: " + err.message; + if (details) { + result += ", " + Object.keys(details).map(k => k + ": " + details[k]).join(", "); + } + result += "]"; + return result; +} +class UnknownDeviceError extends Error { + /** + * Exception thrown specifically when we want to warn the user to consider + * the security of their conversation before continuing + * + * @param msg - message describing the problem + * @param devices - set of unknown devices per user we're warning about + */ + constructor(msg, devices, event) { + super(msg); + this.devices = devices; + this.event = event; + this.name = "UnknownDeviceError"; + this.devices = devices; + } +} + +/** + * Registers an encryption/decryption class for a particular algorithm + * + * @param algorithm - algorithm tag to register for + * + * @param encryptor - {@link EncryptionAlgorithm} implementation + * + * @param decryptor - {@link DecryptionAlgorithm} implementation + */ +exports.UnknownDeviceError = UnknownDeviceError; +function registerAlgorithm(algorithm, encryptor, decryptor) { + ENCRYPTION_CLASSES.set(algorithm, encryptor); + DECRYPTION_CLASSES.set(algorithm, decryptor); +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/index.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/index.js new file mode 100644 index 0000000000..c49d64cef4 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/index.js @@ -0,0 +1,18 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +require("./olm"); +require("./megolm"); +var _base = require("./base"); +Object.keys(_base).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (key in exports && exports[key] === _base[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _base[key]; + } + }); +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/megolm.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/megolm.js new file mode 100644 index 0000000000..a1f5c4fe72 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/megolm.js @@ -0,0 +1,1682 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.MegolmEncryption = exports.MegolmDecryption = void 0; +exports.isRoomSharedHistory = isRoomSharedHistory; +var _uuid = require("uuid"); +var _logger = require("../../logger"); +var olmlib = _interopRequireWildcard(require("../olmlib")); +var _base = require("./base"); +var _OlmDevice = require("../OlmDevice"); +var _event = require("../../@types/event"); +var _OutgoingRoomKeyRequestManager = require("../OutgoingRoomKeyRequestManager"); +var _utils = require("../../utils"); +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2015 - 2021, 2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ /** + * Defines m.olm encryption/decryption + */ +// determine whether the key can be shared with invitees +function isRoomSharedHistory(room) { + const visibilityEvent = room?.currentState?.getStateEvents("m.room.history_visibility", ""); + // NOTE: if the room visibility is unset, it would normally default to + // "world_readable". + // (https://spec.matrix.org/unstable/client-server-api/#server-behaviour-5) + // But we will be paranoid here, and treat it as a situation where the room + // is not shared-history + const visibility = visibilityEvent?.getContent()?.history_visibility; + return ["world_readable", "shared"].includes(visibility); +} + +// map user Id → device Id → IBlockedDevice + +/** + * Tests whether an encrypted content has a ciphertext. + * Ciphertext can be a string or object depending on the content type {@link IEncryptedContent}. + * + * @param content - Encrypted content + * @returns true: has ciphertext, else false + */ +const hasCiphertext = content => { + return typeof content.ciphertext === "string" ? !!content.ciphertext.length : !!Object.keys(content.ciphertext).length; +}; + +/** The result of parsing the an `m.room_key` or `m.forwarded_room_key` to-device event */ + +/** + * @internal + */ +class OutboundSessionInfo { + /** + * @param sharedHistory - whether the session can be freely shared with + * other group members, according to the room history visibility settings + */ + constructor(sessionId, sharedHistory = false) { + this.sessionId = sessionId; + this.sharedHistory = sharedHistory; + /** number of times this session has been used */ + _defineProperty(this, "useCount", 0); + /** when the session was created (ms since the epoch) */ + _defineProperty(this, "creationTime", void 0); + /** devices with which we have shared the session key `userId -> {deviceId -> SharedWithData}` */ + _defineProperty(this, "sharedWithDevices", new _utils.MapWithDefault(() => new Map())); + _defineProperty(this, "blockedDevicesNotified", new _utils.MapWithDefault(() => new Map())); + this.creationTime = new Date().getTime(); + } + + /** + * Check if it's time to rotate the session + */ + needsRotation(rotationPeriodMsgs, rotationPeriodMs) { + const sessionLifetime = new Date().getTime() - this.creationTime; + if (this.useCount >= rotationPeriodMsgs || sessionLifetime >= rotationPeriodMs) { + _logger.logger.log("Rotating megolm session after " + this.useCount + " messages, " + sessionLifetime + "ms"); + return true; + } + return false; + } + markSharedWithDevice(userId, deviceId, deviceKey, chainIndex) { + this.sharedWithDevices.getOrCreate(userId).set(deviceId, { + deviceKey, + messageIndex: chainIndex + }); + } + markNotifiedBlockedDevice(userId, deviceId) { + this.blockedDevicesNotified.getOrCreate(userId).set(deviceId, true); + } + + /** + * Determine if this session has been shared with devices which it shouldn't + * have been. + * + * @param devicesInRoom - `userId -> {deviceId -> object}` + * devices we should shared the session with. + * + * @returns true if we have shared the session with devices which aren't + * in devicesInRoom. + */ + sharedWithTooManyDevices(devicesInRoom) { + for (const [userId, devices] of this.sharedWithDevices) { + if (!devicesInRoom.has(userId)) { + _logger.logger.log("Starting new megolm session because we shared with " + userId); + return true; + } + for (const [deviceId] of devices) { + if (!devicesInRoom.get(userId)?.get(deviceId)) { + _logger.logger.log("Starting new megolm session because we shared with " + userId + ":" + deviceId); + return true; + } + } + } + return false; + } +} + +/** + * Megolm encryption implementation + * + * @param params - parameters, as per {@link EncryptionAlgorithm} + */ +class MegolmEncryption extends _base.EncryptionAlgorithm { + constructor(params) { + super(params); + // the most recent attempt to set up a session. This is used to serialise + // the session setups, so that we have a race-free view of which session we + // are using, and which devices we have shared the keys with. It resolves + // with an OutboundSessionInfo (or undefined, for the first message in the + // room). + _defineProperty(this, "setupPromise", Promise.resolve(null)); + // 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). + _defineProperty(this, "outboundSessions", {}); + _defineProperty(this, "sessionRotationPeriodMsgs", void 0); + _defineProperty(this, "sessionRotationPeriodMs", void 0); + _defineProperty(this, "encryptionPreparation", void 0); + _defineProperty(this, "roomId", void 0); + _defineProperty(this, "prefixedLogger", void 0); + this.roomId = params.roomId; + this.prefixedLogger = _logger.logger.withPrefix(`[${this.roomId} encryption]`); + this.sessionRotationPeriodMsgs = params.config?.rotation_period_msgs ?? 100; + this.sessionRotationPeriodMs = params.config?.rotation_period_ms ?? 7 * 24 * 3600 * 1000; + } + + /** + * @internal + * + * @param devicesInRoom - The devices in this room, indexed by user ID + * @param blocked - The devices that are blocked, indexed by user ID + * @param singleOlmCreationPhase - Only perform one round of olm + * session creation + * + * This method updates the setupPromise field of the class by chaining a new + * call on top of the existing promise, and then catching and discarding any + * errors that might happen while setting up the outbound group session. This + * is done to ensure that `setupPromise` always resolves to `null` or the + * `OutboundSessionInfo`. + * + * Using `>>=` to represent the promise chaining operation, it does the + * following: + * + * ``` + * setupPromise = previousSetupPromise >>= setup >>= discardErrors + * ``` + * + * The initial value for the `setupPromise` is a promise that resolves to + * `null`. The forceDiscardSession() resets setupPromise to this initial + * promise. + * + * @returns Promise which resolves to the + * OutboundSessionInfo when setup is complete. + */ + async ensureOutboundSession(room, devicesInRoom, blocked, singleOlmCreationPhase = false) { + // takes the previous OutboundSessionInfo, and considers whether to create + // a new one. Also shares the key with any (new) devices in the room. + // + // returns a promise which resolves once the keyshare is successful. + const setup = async oldSession => { + const sharedHistory = isRoomSharedHistory(room); + const session = await this.prepareSession(devicesInRoom, sharedHistory, oldSession); + await this.shareSession(devicesInRoom, sharedHistory, singleOlmCreationPhase, blocked, session); + return session; + }; + + // first wait for the previous share to complete + const fallible = this.setupPromise.then(setup); + + // Ensure any failures are logged for debugging and make sure that the + // promise chain remains unbroken + // + // setupPromise resolves to `null` or the `OutboundSessionInfo` whether + // or not the share succeeds + this.setupPromise = fallible.catch(e => { + this.prefixedLogger.error(`Failed to setup outbound session`, e); + return null; + }); + + // but we return a promise which only resolves if the share was successful. + return fallible; + } + async prepareSession(devicesInRoom, sharedHistory, session) { + // history visibility changed + if (session && sharedHistory !== session.sharedHistory) { + session = null; + } + + // need to make a brand new session? + if (session?.needsRotation(this.sessionRotationPeriodMsgs, this.sessionRotationPeriodMs)) { + this.prefixedLogger.log("Starting new megolm session because we need to rotate."); + session = null; + } + + // determine if we have shared with anyone we shouldn't have + if (session?.sharedWithTooManyDevices(devicesInRoom)) { + session = null; + } + if (!session) { + this.prefixedLogger.log("Starting new megolm session"); + session = await this.prepareNewSession(sharedHistory); + this.prefixedLogger.log(`Started new megolm session ${session.sessionId}`); + this.outboundSessions[session.sessionId] = session; + } + return session; + } + async shareSession(devicesInRoom, sharedHistory, singleOlmCreationPhase, blocked, session) { + // now check if we need to share with any devices + const shareMap = {}; + for (const [userId, userDevices] of devicesInRoom) { + for (const [deviceId, deviceInfo] of userDevices) { + const key = deviceInfo.getIdentityKey(); + if (key == this.olmDevice.deviceCurve25519Key) { + // don't bother sending to ourself + continue; + } + if (!session.sharedWithDevices.get(userId)?.get(deviceId)) { + shareMap[userId] = shareMap[userId] || []; + shareMap[userId].push(deviceInfo); + } + } + } + const key = this.olmDevice.getOutboundGroupSessionKey(session.sessionId); + const payload = { + type: "m.room_key", + content: { + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": this.roomId, + "session_id": session.sessionId, + "session_key": key.key, + "chain_index": key.chain_index, + "org.matrix.msc3061.shared_history": sharedHistory + } + }; + const [devicesWithoutSession, olmSessions] = await olmlib.getExistingOlmSessions(this.olmDevice, this.baseApis, shareMap); + await Promise.all([(async () => { + // share keys with devices that we already have a session for + const olmSessionList = Array.from(olmSessions.entries()).map(([userId, sessionsByUser]) => Array.from(sessionsByUser.entries()).map(([deviceId, session]) => `${userId}/${deviceId}: ${session.sessionId}`)).flat(1); + this.prefixedLogger.debug("Sharing keys with devices with existing Olm sessions:", olmSessionList); + await this.shareKeyWithOlmSessions(session, key, payload, olmSessions); + this.prefixedLogger.debug("Shared keys with existing Olm sessions"); + })(), (async () => { + const deviceList = Array.from(devicesWithoutSession.entries()).map(([userId, devicesByUser]) => devicesByUser.map(device => `${userId}/${device.deviceId}`)).flat(1); + this.prefixedLogger.debug("Sharing keys (start phase 1) with devices without existing Olm sessions:", deviceList); + const errorDevices = []; + + // meanwhile, establish olm sessions for devices that we don't + // already have a session for, and share keys with them. If + // we're doing two phases of olm session creation, use a + // shorter timeout when fetching one-time keys for the first + // phase. + const start = Date.now(); + const failedServers = []; + await this.shareKeyWithDevices(session, key, payload, devicesWithoutSession, errorDevices, singleOlmCreationPhase ? 10000 : 2000, failedServers); + this.prefixedLogger.debug("Shared keys (end phase 1) with devices without existing Olm sessions"); + if (!singleOlmCreationPhase && Date.now() - start < 10000) { + // perform the second phase of olm session creation if requested, + // and if the first phase didn't take too long + (async () => { + // Retry sending keys to devices that we were unable to establish + // an olm session for. This time, we use a longer timeout, but we + // do this in the background and don't block anything else while we + // do this. We only need to retry users from servers that didn't + // respond the first time. + const retryDevices = new _utils.MapWithDefault(() => []); + const failedServerMap = new Set(); + for (const server of failedServers) { + failedServerMap.add(server); + } + const failedDevices = []; + for (const { + userId, + deviceInfo + } of errorDevices) { + const userHS = userId.slice(userId.indexOf(":") + 1); + if (failedServerMap.has(userHS)) { + retryDevices.getOrCreate(userId).push(deviceInfo); + } else { + // if we aren't going to retry, then handle it + // as a failed device + failedDevices.push({ + userId, + deviceInfo + }); + } + } + const retryDeviceList = Array.from(retryDevices.entries()).map(([userId, devicesByUser]) => devicesByUser.map(device => `${userId}/${device.deviceId}`)).flat(1); + if (retryDeviceList.length > 0) { + this.prefixedLogger.debug("Sharing keys (start phase 2) with devices without existing Olm sessions:", retryDeviceList); + await this.shareKeyWithDevices(session, key, payload, retryDevices, failedDevices, 30000); + this.prefixedLogger.debug("Shared keys (end phase 2) with devices without existing Olm sessions"); + } + await this.notifyFailedOlmDevices(session, key, failedDevices); + })(); + } else { + await this.notifyFailedOlmDevices(session, key, errorDevices); + } + })(), (async () => { + this.prefixedLogger.debug(`There are ${blocked.size} blocked devices:`, Array.from(blocked.entries()).map(([userId, blockedByUser]) => Array.from(blockedByUser.entries()).map(([deviceId, _deviceInfo]) => `${userId}/${deviceId}`)).flat(1)); + + // also, notify newly blocked devices that they're blocked + const blockedMap = new _utils.MapWithDefault(() => new Map()); + let blockedCount = 0; + for (const [userId, userBlockedDevices] of blocked) { + for (const [deviceId, device] of userBlockedDevices) { + if (session.blockedDevicesNotified.get(userId)?.get(deviceId) === undefined) { + blockedMap.getOrCreate(userId).set(deviceId, { + device + }); + blockedCount++; + } + } + } + if (blockedCount) { + this.prefixedLogger.debug(`Notifying ${blockedCount} newly blocked devices:`, Array.from(blockedMap.entries()).map(([userId, blockedByUser]) => Object.entries(blockedByUser).map(([deviceId, _deviceInfo]) => `${userId}/${deviceId}`)).flat(1)); + await this.notifyBlockedDevices(session, blockedMap); + this.prefixedLogger.debug(`Notified ${blockedCount} newly blocked devices`); + } + })()]); + } + + /** + * @internal + * + * + * @returns session + */ + async prepareNewSession(sharedHistory) { + const sessionId = this.olmDevice.createOutboundGroupSession(); + const key = this.olmDevice.getOutboundGroupSessionKey(sessionId); + await this.olmDevice.addInboundGroupSession(this.roomId, this.olmDevice.deviceCurve25519Key, [], sessionId, key.key, { + ed25519: this.olmDevice.deviceEd25519Key + }, false, { + sharedHistory + }); + + // don't wait for it to complete + this.crypto.backupManager.backupGroupSession(this.olmDevice.deviceCurve25519Key, sessionId); + return new OutboundSessionInfo(sessionId, sharedHistory); + } + + /** + * Determines what devices in devicesByUser don't have an olm session as given + * in devicemap. + * + * @internal + * + * @param deviceMap - the devices that have olm sessions, as returned by + * olmlib.ensureOlmSessionsForDevices. + * @param devicesByUser - a map of user IDs to array of deviceInfo + * @param noOlmDevices - an array to fill with devices that don't have + * olm sessions + * + * @returns an array of devices that don't have olm sessions. If + * noOlmDevices is specified, then noOlmDevices will be returned. + */ + getDevicesWithoutSessions(deviceMap, devicesByUser, noOlmDevices = []) { + for (const [userId, devicesToShareWith] of devicesByUser) { + const sessionResults = deviceMap.get(userId); + for (const deviceInfo of devicesToShareWith) { + const deviceId = deviceInfo.deviceId; + const sessionResult = sessionResults?.get(deviceId); + if (!sessionResult?.sessionId) { + // no session with this device, probably because there + // were no one-time keys. + + noOlmDevices.push({ + userId, + deviceInfo + }); + sessionResults?.delete(deviceId); + + // ensureOlmSessionsForUsers has already done the logging, + // so just skip it. + continue; + } + } + } + return noOlmDevices; + } + + /** + * Splits the user device map into multiple chunks to reduce the number of + * devices we encrypt to per API call. + * + * @internal + * + * @param devicesByUser - map from userid to list of devices + * + * @returns the blocked devices, split into chunks + */ + splitDevices(devicesByUser) { + const maxDevicesPerRequest = 20; + + // use an array where the slices of a content map gets stored + let currentSlice = []; + const mapSlices = [currentSlice]; + for (const [userId, userDevices] of devicesByUser) { + for (const deviceInfo of userDevices.values()) { + currentSlice.push({ + userId: userId, + deviceInfo: deviceInfo.device + }); + } + + // We do this in the per-user loop as we prefer that all messages to the + // same user end up in the same API call to make it easier for the + // server (e.g. only have to send one EDU if a remote user, etc). This + // does mean that if a user has many devices we may go over the desired + // limit, but its not a hard limit so that is fine. + if (currentSlice.length > maxDevicesPerRequest) { + // the current slice is filled up. Start inserting into the next slice + currentSlice = []; + mapSlices.push(currentSlice); + } + } + if (currentSlice.length === 0) { + mapSlices.pop(); + } + return mapSlices; + } + + /** + * @internal + * + * + * @param chainIndex - current chain index + * + * @param userDeviceMap - mapping from userId to deviceInfo + * + * @param payload - fields to include in the encrypted payload + * + * @returns Promise which resolves once the key sharing + * for the given userDeviceMap is generated and has been sent. + */ + encryptAndSendKeysToDevices(session, chainIndex, devices, payload) { + return this.crypto.encryptAndSendToDevices(devices, payload).then(() => { + // store that we successfully uploaded the keys of the current slice + for (const device of devices) { + session.markSharedWithDevice(device.userId, device.deviceInfo.deviceId, device.deviceInfo.getIdentityKey(), chainIndex); + } + }).catch(error => { + this.prefixedLogger.error("failed to encryptAndSendToDevices", error); + throw error; + }); + } + + /** + * @internal + * + * + * @param userDeviceMap - list of blocked devices to notify + * + * @param payload - fields to include in the notification payload + * + * @returns Promise which resolves once the notifications + * for the given userDeviceMap is generated and has been sent. + */ + async sendBlockedNotificationsToDevices(session, userDeviceMap, payload) { + const contentMap = new _utils.MapWithDefault(() => new Map()); + for (const val of userDeviceMap) { + const userId = val.userId; + const blockedInfo = val.deviceInfo; + const deviceInfo = blockedInfo.deviceInfo; + const deviceId = deviceInfo.deviceId; + const message = _objectSpread(_objectSpread({}, payload), {}, { + code: blockedInfo.code, + reason: blockedInfo.reason, + [_event.ToDeviceMessageId]: (0, _uuid.v4)() + }); + if (message.code === "m.no_olm") { + delete message.room_id; + delete message.session_id; + } + contentMap.getOrCreate(userId).set(deviceId, message); + } + await this.baseApis.sendToDevice("m.room_key.withheld", contentMap); + + // record the fact that we notified these blocked devices + for (const [userId, userDeviceMap] of contentMap) { + for (const deviceId of userDeviceMap.keys()) { + session.markNotifiedBlockedDevice(userId, deviceId); + } + } + } + + /** + * Re-shares a megolm session key with devices if the key has already been + * sent to them. + * + * @param senderKey - The key of the originating device for the session + * @param sessionId - ID of the outbound session to share + * @param userId - ID of the user who owns the target device + * @param device - The target device + */ + async reshareKeyWithDevice(senderKey, sessionId, userId, device) { + const obSessionInfo = this.outboundSessions[sessionId]; + if (!obSessionInfo) { + this.prefixedLogger.debug(`megolm session ${senderKey}|${sessionId} not found: not re-sharing keys`); + return; + } + + // The chain index of the key we previously sent this device + if (!obSessionInfo.sharedWithDevices.has(userId)) { + this.prefixedLogger.debug(`megolm session ${senderKey}|${sessionId} never shared with user ${userId}`); + return; + } + const sessionSharedData = obSessionInfo.sharedWithDevices.get(userId)?.get(device.deviceId); + if (sessionSharedData === undefined) { + this.prefixedLogger.debug(`megolm session ${senderKey}|${sessionId} never shared with device ${userId}:${device.deviceId}`); + return; + } + if (sessionSharedData.deviceKey !== device.getIdentityKey()) { + this.prefixedLogger.warn(`Megolm session ${senderKey}|${sessionId} has been shared with device ${device.deviceId} but ` + `with identity key ${sessionSharedData.deviceKey}. Key is now ${device.getIdentityKey()}!`); + return; + } + + // get the key from the inbound session: the outbound one will already + // have been ratcheted to the next chain index. + const key = await this.olmDevice.getInboundGroupSessionKey(this.roomId, senderKey, sessionId, sessionSharedData.messageIndex); + if (!key) { + this.prefixedLogger.warn(`No inbound session key found for megolm session ${senderKey}|${sessionId}: not re-sharing keys`); + return; + } + await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, new Map([[userId, [device]]])); + const payload = { + type: "m.forwarded_room_key", + content: { + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": this.roomId, + "session_id": sessionId, + "session_key": key.key, + "chain_index": key.chain_index, + "sender_key": senderKey, + "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, + "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, + "org.matrix.msc3061.shared_history": key.shared_history || false + } + }; + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + [_event.ToDeviceMessageId]: (0, _uuid.v4)() + }; + await olmlib.encryptMessageForDevice(encryptedContent.ciphertext, this.userId, this.deviceId, this.olmDevice, userId, device, payload); + await this.baseApis.sendToDevice("m.room.encrypted", new Map([[userId, new Map([[device.deviceId, encryptedContent]])]])); + this.prefixedLogger.debug(`Re-shared key for megolm session ${senderKey}|${sessionId} with ${userId}:${device.deviceId}`); + } + + /** + * @internal + * + * + * @param key - the session key as returned by + * OlmDevice.getOutboundGroupSessionKey + * + * @param payload - the base to-device message payload for sharing keys + * + * @param devicesByUser - map from userid to list of devices + * + * @param errorDevices - array that will be populated with the devices that we can't get an + * olm session for + * + * @param otkTimeout - The timeout in milliseconds when requesting + * one-time keys for establishing new olm sessions. + * + * @param failedServers - An array to fill with remote servers that + * failed to respond to one-time-key requests. + */ + async shareKeyWithDevices(session, key, payload, devicesByUser, errorDevices, otkTimeout, failedServers) { + const devicemap = await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser, false, otkTimeout, failedServers, this.prefixedLogger); + this.getDevicesWithoutSessions(devicemap, devicesByUser, errorDevices); + await this.shareKeyWithOlmSessions(session, key, payload, devicemap); + } + async shareKeyWithOlmSessions(session, key, payload, deviceMap) { + const userDeviceMaps = this.splitDevices(deviceMap); + for (let i = 0; i < userDeviceMaps.length; i++) { + const taskDetail = `megolm keys for ${session.sessionId} (slice ${i + 1}/${userDeviceMaps.length})`; + try { + this.prefixedLogger.debug(`Sharing ${taskDetail}`, userDeviceMaps[i].map(d => `${d.userId}/${d.deviceInfo.deviceId}`)); + await this.encryptAndSendKeysToDevices(session, key.chain_index, userDeviceMaps[i], payload); + this.prefixedLogger.debug(`Shared ${taskDetail}`); + } catch (e) { + this.prefixedLogger.error(`Failed to share ${taskDetail}`); + throw e; + } + } + } + + /** + * Notify devices that we weren't able to create olm sessions. + * + * + * + * @param failedDevices - the devices that we were unable to + * create olm sessions for, as returned by shareKeyWithDevices + */ + async notifyFailedOlmDevices(session, key, failedDevices) { + this.prefixedLogger.debug(`Notifying ${failedDevices.length} devices we failed to create Olm sessions`); + + // mark the devices that failed as "handled" because we don't want to try + // to claim a one-time-key for dead devices on every message. + for (const { + userId, + deviceInfo + } of failedDevices) { + const deviceId = deviceInfo.deviceId; + session.markSharedWithDevice(userId, deviceId, deviceInfo.getIdentityKey(), key.chain_index); + } + const unnotifiedFailedDevices = await this.olmDevice.filterOutNotifiedErrorDevices(failedDevices); + this.prefixedLogger.debug(`Need to notify ${unnotifiedFailedDevices.length} failed devices which haven't been notified before`); + const blockedMap = new _utils.MapWithDefault(() => new Map()); + for (const { + userId, + deviceInfo + } of unnotifiedFailedDevices) { + // we use a similar format to what + // olmlib.ensureOlmSessionsForDevices returns, so that + // we can use the same function to split + blockedMap.getOrCreate(userId).set(deviceInfo.deviceId, { + device: { + code: "m.no_olm", + reason: _OlmDevice.WITHHELD_MESSAGES["m.no_olm"], + deviceInfo + } + }); + } + + // send the notifications + await this.notifyBlockedDevices(session, blockedMap); + this.prefixedLogger.debug(`Notified ${unnotifiedFailedDevices.length} devices we failed to create Olm sessions`); + } + + /** + * Notify blocked devices that they have been blocked. + * + * + * @param devicesByUser - map from userid to device ID to blocked data + */ + async notifyBlockedDevices(session, devicesByUser) { + const payload = { + room_id: this.roomId, + session_id: session.sessionId, + algorithm: olmlib.MEGOLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key + }; + const userDeviceMaps = this.splitDevices(devicesByUser); + for (let i = 0; i < userDeviceMaps.length; i++) { + try { + await this.sendBlockedNotificationsToDevices(session, userDeviceMaps[i], payload); + this.prefixedLogger.log(`Completed blacklist notification for ${session.sessionId} ` + `(slice ${i + 1}/${userDeviceMaps.length})`); + } catch (e) { + this.prefixedLogger.log(`blacklist notification for ${session.sessionId} ` + `(slice ${i + 1}/${userDeviceMaps.length}) failed`); + throw e; + } + } + } + + /** + * Perform any background tasks that can be done before a message is ready to + * send, in order to speed up sending of the message. + * + * @param room - the room the event is in + * @returns A function that, when called, will stop the preparation + */ + prepareToEncrypt(room) { + if (room.roomId !== this.roomId) { + throw new Error("MegolmEncryption.prepareToEncrypt called on unexpected room"); + } + if (this.encryptionPreparation != null) { + // We're already preparing something, so don't do anything else. + const elapsedTime = Date.now() - this.encryptionPreparation.startTime; + this.prefixedLogger.debug(`Already started preparing to encrypt for this room ${elapsedTime}ms ago, skipping`); + return this.encryptionPreparation.cancel; + } + this.prefixedLogger.debug("Preparing to encrypt events"); + let cancelled = false; + const isCancelled = () => cancelled; + this.encryptionPreparation = { + startTime: Date.now(), + promise: (async () => { + try { + // Attempt to enumerate the devices in room, and gracefully + // handle cancellation if it occurs. + const getDevicesResult = await this.getDevicesInRoom(room, false, isCancelled); + if (getDevicesResult === null) return; + const [devicesInRoom, blocked] = getDevicesResult; + if (this.crypto.globalErrorOnUnknownDevices) { + // Drop unknown devices for now. When the message gets sent, we'll + // throw an error, but we'll still be prepared to send to the known + // devices. + this.removeUnknownDevices(devicesInRoom); + } + this.prefixedLogger.debug("Ensuring outbound megolm session"); + await this.ensureOutboundSession(room, devicesInRoom, blocked, true); + this.prefixedLogger.debug("Ready to encrypt events"); + } catch (e) { + this.prefixedLogger.error("Failed to prepare to encrypt events", e); + } finally { + delete this.encryptionPreparation; + } + })(), + cancel: () => { + // The caller has indicated that the process should be cancelled, + // so tell the promise that we'd like to halt, and reset the preparation state. + cancelled = true; + delete this.encryptionPreparation; + } + }; + return this.encryptionPreparation.cancel; + } + + /** + * @param content - plaintext event content + * + * @returns Promise which resolves to the new event body + */ + async encryptMessage(room, eventType, content) { + this.prefixedLogger.log("Starting to encrypt event"); + if (this.encryptionPreparation != null) { + // If we started sending keys, wait for it to be done. + // FIXME: check if we need to cancel + // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) + try { + await this.encryptionPreparation.promise; + } catch (e) { + // ignore any errors -- if the preparation failed, we'll just + // restart everything here + } + } + + /** + * When using in-room messages and the room has encryption enabled, + * clients should ensure that encryption does not hinder the verification. + */ + const forceDistributeToUnverified = this.isVerificationEvent(eventType, content); + const [devicesInRoom, blocked] = await this.getDevicesInRoom(room, forceDistributeToUnverified); + + // check if any of these devices are not yet known to the user. + // if so, warn the user so they can verify or ignore. + if (this.crypto.globalErrorOnUnknownDevices) { + this.checkForUnknownDevices(devicesInRoom); + } + const session = await this.ensureOutboundSession(room, devicesInRoom, blocked); + const payloadJson = { + room_id: this.roomId, + type: eventType, + content: content + }; + const ciphertext = this.olmDevice.encryptGroupMessage(session.sessionId, JSON.stringify(payloadJson)); + const encryptedContent = { + algorithm: olmlib.MEGOLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: ciphertext, + session_id: session.sessionId, + // Include our device ID so that recipients can send us a + // m.new_device message if they don't have our session key. + // XXX: Do we still need this now that m.new_device messages + // no longer exist since #483? + device_id: this.deviceId + }; + session.useCount++; + return encryptedContent; + } + isVerificationEvent(eventType, content) { + switch (eventType) { + case _event.EventType.KeyVerificationCancel: + case _event.EventType.KeyVerificationDone: + case _event.EventType.KeyVerificationMac: + case _event.EventType.KeyVerificationStart: + case _event.EventType.KeyVerificationKey: + case _event.EventType.KeyVerificationReady: + case _event.EventType.KeyVerificationAccept: + { + return true; + } + case _event.EventType.RoomMessage: + { + return content["msgtype"] === _event.MsgType.KeyVerificationRequest; + } + default: + { + return false; + } + } + } + + /** + * 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. + */ + forceDiscardSession() { + this.setupPromise = this.setupPromise.then(() => null); + } + + /** + * Checks the devices we're about to send to and see if any are entirely + * unknown to the user. If so, warn the user, and mark them as known to + * give the user a chance to go verify them before re-sending this message. + * + * @param devicesInRoom - `userId -> {deviceId -> object}` + * devices we should shared the session with. + */ + checkForUnknownDevices(devicesInRoom) { + const unknownDevices = new _utils.MapWithDefault(() => new Map()); + for (const [userId, userDevices] of devicesInRoom) { + for (const [deviceId, device] of userDevices) { + if (device.isUnverified() && !device.isKnown()) { + unknownDevices.getOrCreate(userId).set(deviceId, device); + } + } + } + if (unknownDevices.size) { + // it'd be kind to pass unknownDevices up to the user in this error + throw new _base.UnknownDeviceError("This room contains unknown devices which have not been verified. " + "We strongly recommend you verify them before continuing.", unknownDevices); + } + } + + /** + * Remove unknown devices from a set of devices. The devicesInRoom parameter + * will be modified. + * + * @param devicesInRoom - `userId -> {deviceId -> object}` + * devices we should shared the session with. + */ + removeUnknownDevices(devicesInRoom) { + for (const [userId, userDevices] of devicesInRoom) { + for (const [deviceId, device] of userDevices) { + if (device.isUnverified() && !device.isKnown()) { + userDevices.delete(deviceId); + } + } + if (userDevices.size === 0) { + devicesInRoom.delete(userId); + } + } + } + + /** + * Get the list of unblocked devices for all users in the room + * + * @param forceDistributeToUnverified - if set to true will include the unverified devices + * even if setting is set to block them (useful for verification) + * @param isCancelled - will cause the procedure to abort early if and when it starts + * returning `true`. If omitted, cancellation won't happen. + * + * @returns Promise which resolves to `null`, or an array whose + * first element is a {@link DeviceInfoMap} indicating + * the devices that messages should be encrypted to, and whose second + * element is a map from userId to deviceId to data indicating the devices + * that are in the room but that have been blocked. + * If `isCancelled` is provided and returns `true` while processing, `null` + * will be returned. + * If `isCancelled` is not provided, the Promise will never resolve to `null`. + */ + + async getDevicesInRoom(room, forceDistributeToUnverified = false, isCancelled) { + const members = await room.getEncryptionTargetMembers(); + this.prefixedLogger.debug(`Encrypting for users (shouldEncryptForInvitedMembers: ${room.shouldEncryptForInvitedMembers()}):`, members.map(u => `${u.userId} (${u.membership})`)); + const roomMembers = members.map(function (u) { + return u.userId; + }); + + // The global value is treated as a default for when rooms don't specify a value. + let isBlacklisting = this.crypto.globalBlacklistUnverifiedDevices; + const isRoomBlacklisting = room.getBlacklistUnverifiedDevices(); + if (typeof isRoomBlacklisting === "boolean") { + isBlacklisting = isRoomBlacklisting; + } + + // We are happy to use a cached version here: we assume that if we already + // have a list of the user's devices, then we already share an e2e room + // with them, which means that they will have announced any new devices via + // device_lists in their /sync response. This cache should then be maintained + // using all the device_lists changes and left fields. + // See https://github.com/vector-im/element-web/issues/2305 for details. + const devices = await this.crypto.downloadKeys(roomMembers, false); + if (isCancelled?.() === true) { + return null; + } + const blocked = new _utils.MapWithDefault(() => new Map()); + // remove any blocked devices + for (const [userId, userDevices] of devices) { + for (const [deviceId, userDevice] of userDevices) { + // Yield prior to checking each device so that we don't block + // updating/rendering for too long. + // See https://github.com/vector-im/element-web/issues/21612 + if (isCancelled !== undefined) await (0, _utils.immediate)(); + if (isCancelled?.() === true) return null; + const deviceTrust = this.crypto.checkDeviceTrust(userId, deviceId); + if (userDevice.isBlocked() || !deviceTrust.isVerified() && isBlacklisting && !forceDistributeToUnverified) { + const blockedDevices = blocked.getOrCreate(userId); + const isBlocked = userDevice.isBlocked(); + blockedDevices.set(deviceId, { + code: isBlocked ? "m.blacklisted" : "m.unverified", + reason: _OlmDevice.WITHHELD_MESSAGES[isBlocked ? "m.blacklisted" : "m.unverified"], + deviceInfo: userDevice + }); + userDevices.delete(deviceId); + } + } + } + return [devices, blocked]; + } +} + +/** + * Megolm decryption implementation + * + * @param params - parameters, as per {@link DecryptionAlgorithm} + */ +exports.MegolmEncryption = MegolmEncryption; +class MegolmDecryption extends _base.DecryptionAlgorithm { + constructor(params) { + super(params); + // events which we couldn't decrypt due to unknown sessions / + // indexes, or which we could only decrypt with untrusted keys: + // map from senderKey|sessionId to Set of MatrixEvents + _defineProperty(this, "pendingEvents", new Map()); + // this gets stubbed out by the unit tests. + _defineProperty(this, "olmlib", olmlib); + _defineProperty(this, "roomId", void 0); + _defineProperty(this, "prefixedLogger", void 0); + this.roomId = params.roomId; + this.prefixedLogger = _logger.logger.withPrefix(`[${this.roomId} decryption]`); + } + + /** + * returns a promise which resolves to a + * {@link EventDecryptionResult} once we have finished + * decrypting, or rejects with an `algorithms.DecryptionError` if there is a + * problem decrypting the event. + */ + async decryptEvent(event) { + const content = event.getWireContent(); + if (!content.sender_key || !content.session_id || !content.ciphertext) { + throw new _base.DecryptionError("MEGOLM_MISSING_FIELDS", "Missing fields in input"); + } + + // we add the event to the pending list *before* we start decryption. + // + // then, if the key turns up while decryption is in progress (and + // decryption fails), we will schedule a retry. + // (fixes https://github.com/vector-im/element-web/issues/5001) + this.addEventToPendingList(event); + let res; + try { + res = await this.olmDevice.decryptGroupMessage(event.getRoomId(), content.sender_key, content.session_id, content.ciphertext, event.getId(), event.getTs()); + } catch (e) { + if (e.name === "DecryptionError") { + // re-throw decryption errors as-is + throw e; + } + let errorCode = "OLM_DECRYPT_GROUP_MESSAGE_ERROR"; + if (e?.message === "OLM.UNKNOWN_MESSAGE_INDEX") { + this.requestKeysForEvent(event); + errorCode = "OLM_UNKNOWN_MESSAGE_INDEX"; + } + throw new _base.DecryptionError(errorCode, e instanceof Error ? e.message : "Unknown Error: Error is undefined", { + session: content.sender_key + "|" + content.session_id + }); + } + if (res === null) { + // We've got a message for a session we don't have. + // try and get the missing key from the backup first + this.crypto.backupManager.queryKeyBackupRateLimited(event.getRoomId(), content.session_id).catch(() => {}); + + // (XXX: We might actually have received this key since we started + // decrypting, in which case we'll have scheduled a retry, and this + // request will be redundant. We could probably check to see if the + // event is still in the pending list; if not, a retry will have been + // scheduled, so we needn't send out the request here.) + this.requestKeysForEvent(event); + + // See if there was a problem with the olm session at the time the + // event was sent. Use a fuzz factor of 2 minutes. + const problem = await this.olmDevice.sessionMayHaveProblems(content.sender_key, event.getTs() - 120000); + if (problem) { + this.prefixedLogger.info(`When handling UISI from ${event.getSender()} (sender key ${content.sender_key}): ` + `recent session problem with that sender:`, problem); + let problemDescription = PROBLEM_DESCRIPTIONS[problem.type] || PROBLEM_DESCRIPTIONS.unknown; + if (problem.fixed) { + problemDescription += " Trying to create a new secure channel and re-requesting the keys."; + } + throw new _base.DecryptionError("MEGOLM_UNKNOWN_INBOUND_SESSION_ID", problemDescription, { + session: content.sender_key + "|" + content.session_id + }); + } + throw new _base.DecryptionError("MEGOLM_UNKNOWN_INBOUND_SESSION_ID", "The sender's device has not sent us the keys for this message.", { + session: content.sender_key + "|" + content.session_id + }); + } + + // Success. We can remove the event from the pending list, if + // that hasn't already happened. However, if the event was + // decrypted with an untrusted key, leave it on the pending + // list so it will be retried if we find a trusted key later. + if (!res.untrusted) { + this.removeEventFromPendingList(event); + } + const payload = JSON.parse(res.result); + + // belt-and-braces check that the room id matches that indicated by the HS + // (this is somewhat redundant, since the megolm session is scoped to the + // room, so neither the sender nor a MITM can lie about the room_id). + if (payload.room_id !== event.getRoomId()) { + throw new _base.DecryptionError("MEGOLM_BAD_ROOM", "Message intended for room " + payload.room_id); + } + return { + clearEvent: payload, + senderCurve25519Key: res.senderKey, + claimedEd25519Key: res.keysClaimed.ed25519, + forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain, + untrusted: res.untrusted + }; + } + requestKeysForEvent(event) { + const wireContent = event.getWireContent(); + const recipients = event.getKeyRequestRecipients(this.userId); + this.crypto.requestRoomKey({ + room_id: event.getRoomId(), + algorithm: wireContent.algorithm, + sender_key: wireContent.sender_key, + session_id: wireContent.session_id + }, recipients); + } + + /** + * Add an event to the list of those awaiting their session keys. + * + * @internal + * + */ + addEventToPendingList(event) { + const content = event.getWireContent(); + const senderKey = content.sender_key; + const sessionId = content.session_id; + if (!this.pendingEvents.has(senderKey)) { + this.pendingEvents.set(senderKey, new Map()); + } + const senderPendingEvents = this.pendingEvents.get(senderKey); + if (!senderPendingEvents.has(sessionId)) { + senderPendingEvents.set(sessionId, new Set()); + } + senderPendingEvents.get(sessionId)?.add(event); + } + + /** + * Remove an event from the list of those awaiting their session keys. + * + * @internal + * + */ + removeEventFromPendingList(event) { + const content = event.getWireContent(); + const senderKey = content.sender_key; + const sessionId = content.session_id; + const senderPendingEvents = this.pendingEvents.get(senderKey); + const pendingEvents = senderPendingEvents?.get(sessionId); + if (!pendingEvents) { + return; + } + pendingEvents.delete(event); + if (pendingEvents.size === 0) { + senderPendingEvents.delete(sessionId); + } + if (senderPendingEvents.size === 0) { + this.pendingEvents.delete(senderKey); + } + } + + /** + * Parse a RoomKey out of an `m.room_key` event. + * + * @param event - the event containing the room key. + * + * @returns The `RoomKey` if it could be successfully parsed out of the + * event. + * + * @internal + * + */ + roomKeyFromEvent(event) { + const senderKey = event.getSenderKey(); + const content = event.getContent(); + const extraSessionData = {}; + if (!content.room_id || !content.session_key || !content.session_id || !content.algorithm) { + this.prefixedLogger.error("key event is missing fields"); + return; + } + if (!olmlib.isOlmEncrypted(event)) { + this.prefixedLogger.error("key event not properly encrypted"); + return; + } + if (content["org.matrix.msc3061.shared_history"]) { + extraSessionData.sharedHistory = true; + } + const roomKey = { + senderKey: senderKey, + sessionId: content.session_id, + sessionKey: content.session_key, + extraSessionData, + exportFormat: false, + roomId: content.room_id, + algorithm: content.algorithm, + forwardingKeyChain: [], + keysClaimed: event.getKeysClaimed() + }; + return roomKey; + } + + /** + * Parse a RoomKey out of an `m.forwarded_room_key` event. + * + * @param event - the event containing the forwarded room key. + * + * @returns The `RoomKey` if it could be successfully parsed out of the + * event. + * + * @internal + * + */ + forwardedRoomKeyFromEvent(event) { + // the properties in m.forwarded_room_key are a superset of those in m.room_key, so + // start by parsing the m.room_key fields. + const roomKey = this.roomKeyFromEvent(event); + if (!roomKey) { + return; + } + const senderKey = event.getSenderKey(); + const content = event.getContent(); + const senderKeyUser = this.baseApis.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, senderKey); + + // We received this to-device event from event.getSenderKey(), but the original + // creator of the room key is claimed in the content. + const claimedCurve25519Key = content.sender_key; + const claimedEd25519Key = content.sender_claimed_ed25519_key; + let forwardingKeyChain = Array.isArray(content.forwarding_curve25519_key_chain) ? content.forwarding_curve25519_key_chain : []; + + // copy content before we modify it + forwardingKeyChain = forwardingKeyChain.slice(); + forwardingKeyChain.push(senderKey); + + // Check if we have all the fields we need. + if (senderKeyUser !== event.getSender()) { + this.prefixedLogger.error("sending device does not belong to the user it claims to be from"); + return; + } + if (!claimedCurve25519Key) { + this.prefixedLogger.error("forwarded_room_key event is missing sender_key field"); + return; + } + if (!claimedEd25519Key) { + this.prefixedLogger.error(`forwarded_room_key_event is missing sender_claimed_ed25519_key field`); + return; + } + const keysClaimed = { + ed25519: claimedEd25519Key + }; + + // FIXME: We're reusing the same field to track both: + // + // 1. The Olm identity we've received this room key from. + // 2. The Olm identity deduced (in the trusted case) or claiming (in the + // untrusted case) to be the original creator of this room key. + // + // We now overwrite the value tracking usage 1 with the value tracking usage 2. + roomKey.senderKey = claimedCurve25519Key; + // Replace our keysClaimed as well. + roomKey.keysClaimed = keysClaimed; + roomKey.exportFormat = true; + roomKey.forwardingKeyChain = forwardingKeyChain; + // forwarded keys are always untrusted + roomKey.extraSessionData.untrusted = true; + return roomKey; + } + + /** + * Determine if we should accept the forwarded room key that was found in the given + * event. + * + * @param event - An `m.forwarded_room_key` event. + * @param roomKey - The room key that was found in the event. + * + * @returns promise that will resolve to a boolean telling us if it's ok to + * accept the given forwarded room key. + * + * @internal + * + */ + async shouldAcceptForwardedKey(event, roomKey) { + const senderKey = event.getSenderKey(); + const sendingDevice = this.crypto.deviceList.getDeviceByIdentityKey(olmlib.OLM_ALGORITHM, senderKey) ?? undefined; + const deviceTrust = this.crypto.checkDeviceInfoTrust(event.getSender(), sendingDevice); + + // Using the plaintext sender here is fine since we checked that the + // sender matches to the user id in the device keys when this event was + // originally decrypted. This can obviously only happen if the device + // keys have been downloaded, but if they haven't the + // `deviceTrust.isVerified()` flag would be false as well. + // + // It would still be far nicer if the `sendingDevice` had a user ID + // attached to it that went through signature checks. + const fromUs = event.getSender() === this.baseApis.getUserId(); + const keyFromOurVerifiedDevice = deviceTrust.isVerified() && fromUs; + const weRequested = await this.wasRoomKeyRequested(event, roomKey); + const fromInviter = this.wasRoomKeyForwardedByInviter(event, roomKey); + const sharedAsHistory = this.wasRoomKeyForwardedAsHistory(roomKey); + return weRequested && keyFromOurVerifiedDevice || fromInviter && sharedAsHistory; + } + + /** + * Did we ever request the given room key from the event sender and its + * accompanying device. + * + * @param event - An `m.forwarded_room_key` event. + * @param roomKey - The room key that was found in the event. + * + * @internal + * + */ + async wasRoomKeyRequested(event, roomKey) { + // We send the `m.room_key_request` out as a wildcard to-device request, + // otherwise we would have to duplicate the same content for each + // device. This is why we need to pass in "*" as the device id here. + const outgoingRequests = await this.crypto.cryptoStore.getOutgoingRoomKeyRequestsByTarget(event.getSender(), "*", [_OutgoingRoomKeyRequestManager.RoomKeyRequestState.Sent]); + return outgoingRequests.some(req => req.requestBody.room_id === roomKey.roomId && req.requestBody.session_id === roomKey.sessionId); + } + wasRoomKeyForwardedByInviter(event, roomKey) { + // TODO: This is supposed to have a time limit. We should only accept + // such keys if we happen to receive them for a recently joined room. + const room = this.baseApis.getRoom(roomKey.roomId); + const senderKey = event.getSenderKey(); + if (!senderKey) { + return false; + } + const senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, senderKey); + if (!senderKeyUser) { + return false; + } + const memberEvent = room?.getMember(this.userId)?.events.member; + const fromInviter = memberEvent?.getSender() === senderKeyUser || memberEvent?.getUnsigned()?.prev_sender === senderKeyUser && memberEvent?.getPrevContent()?.membership === "invite"; + if (room && fromInviter) { + return true; + } else { + return false; + } + } + wasRoomKeyForwardedAsHistory(roomKey) { + const room = this.baseApis.getRoom(roomKey.roomId); + + // If the key is not for a known room, then something fishy is going on, + // so we reject the key out of caution. In practice, this is a bit moot + // because we'll only accept shared_history forwarded by the inviter, and + // we won't know who was the inviter for an unknown room, so we'll reject + // it anyway. + if (room && roomKey.extraSessionData.sharedHistory) { + return true; + } else { + return false; + } + } + + /** + * Check if a forwarded room key should be parked. + * + * A forwarded room key should be parked if it's a key for a room we're not + * in. We park the forwarded room key in case *this sender* invites us to + * that room later. + */ + shouldParkForwardedKey(roomKey) { + const room = this.baseApis.getRoom(roomKey.roomId); + if (!room && roomKey.extraSessionData.sharedHistory) { + return true; + } else { + return false; + } + } + + /** + * Park the given room key to our store. + * + * @param event - An `m.forwarded_room_key` event. + * @param roomKey - The room key that was found in the event. + * + * @internal + * + */ + async parkForwardedKey(event, roomKey) { + const parkedData = { + senderId: event.getSender(), + senderKey: roomKey.senderKey, + sessionId: roomKey.sessionId, + sessionKey: roomKey.sessionKey, + keysClaimed: roomKey.keysClaimed, + forwardingCurve25519KeyChain: roomKey.forwardingKeyChain + }; + await this.crypto.cryptoStore.doTxn("readwrite", ["parked_shared_history"], txn => this.crypto.cryptoStore.addParkedSharedHistory(roomKey.roomId, parkedData, txn), _logger.logger.withPrefix("[addParkedSharedHistory]")); + } + + /** + * Add the given room key to our store. + * + * @param roomKey - The room key that should be added to the store. + * + * @internal + * + */ + async addRoomKey(roomKey) { + try { + await this.olmDevice.addInboundGroupSession(roomKey.roomId, roomKey.senderKey, roomKey.forwardingKeyChain, roomKey.sessionId, roomKey.sessionKey, roomKey.keysClaimed, roomKey.exportFormat, roomKey.extraSessionData); + + // have another go at decrypting events sent with this session. + if (await this.retryDecryption(roomKey.senderKey, roomKey.sessionId, !roomKey.extraSessionData.untrusted)) { + // cancel any outstanding room key requests for this session. + // Only do this if we managed to decrypt every message in the + // session, because if we didn't, we leave the other key + // requests in the hopes that someone sends us a key that + // includes an earlier index. + this.crypto.cancelRoomKeyRequest({ + algorithm: roomKey.algorithm, + room_id: roomKey.roomId, + session_id: roomKey.sessionId, + sender_key: roomKey.senderKey + }); + } + + // don't wait for the keys to be backed up for the server + await this.crypto.backupManager.backupGroupSession(roomKey.senderKey, roomKey.sessionId); + } catch (e) { + this.prefixedLogger.error(`Error handling m.room_key_event: ${e}`); + } + } + + /** + * Handle room keys that have been forwarded to us as an + * `m.forwarded_room_key` event. + * + * Forwarded room keys need special handling since we have no way of knowing + * who the original creator of the room key was. This naturally means that + * forwarded room keys are always untrusted and should only be accepted in + * some cases. + * + * @param event - An `m.forwarded_room_key` event. + * + * @internal + * + */ + async onForwardedRoomKey(event) { + const roomKey = this.forwardedRoomKeyFromEvent(event); + if (!roomKey) { + return; + } + if (await this.shouldAcceptForwardedKey(event, roomKey)) { + await this.addRoomKey(roomKey); + } else if (this.shouldParkForwardedKey(roomKey)) { + await this.parkForwardedKey(event, roomKey); + } + } + async onRoomKeyEvent(event) { + if (event.getType() == "m.forwarded_room_key") { + await this.onForwardedRoomKey(event); + } else { + const roomKey = this.roomKeyFromEvent(event); + if (!roomKey) { + return; + } + await this.addRoomKey(roomKey); + } + } + + /** + * @param event - key event + */ + async onRoomKeyWithheldEvent(event) { + const content = event.getContent(); + const senderKey = content.sender_key; + if (content.code === "m.no_olm") { + await this.onNoOlmWithheldEvent(event); + } else if (content.code === "m.unavailable") { + // this simply means that the other device didn't have the key, which isn't very useful information. Don't + // record it in the storage + } else { + await this.olmDevice.addInboundGroupSessionWithheld(content.room_id, senderKey, content.session_id, content.code, content.reason); + } + + // Having recorded the problem, retry decryption on any affected messages. + // It's unlikely we'll be able to decrypt sucessfully now, but this will + // update the error message. + // + if (content.session_id) { + await this.retryDecryption(senderKey, content.session_id); + } else { + // no_olm messages aren't specific to a given megolm session, so + // we trigger retrying decryption for all the messages from the sender's + // key, so that we can update the error message to indicate the olm + // session problem. + await this.retryDecryptionFromSender(senderKey); + } + } + async onNoOlmWithheldEvent(event) { + const content = event.getContent(); + const senderKey = content.sender_key; + const sender = event.getSender(); + this.prefixedLogger.warn(`${sender}:${senderKey} was unable to establish an olm session with us`); + // if the sender says that they haven't been able to establish an olm + // session, let's proactively establish one + + if (await this.olmDevice.getSessionIdForDevice(senderKey)) { + // a session has already been established, so we don't need to + // create a new one. + this.prefixedLogger.debug("New session already created. Not creating a new one."); + await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true); + return; + } + let device = this.crypto.deviceList.getDeviceByIdentityKey(content.algorithm, senderKey); + if (!device) { + // if we don't know about the device, fetch the user's devices again + // and retry before giving up + await this.crypto.downloadKeys([sender], false); + device = this.crypto.deviceList.getDeviceByIdentityKey(content.algorithm, senderKey); + if (!device) { + this.prefixedLogger.info("Couldn't find device for identity key " + senderKey + ": not establishing session"); + await this.olmDevice.recordSessionProblem(senderKey, "no_olm", false); + return; + } + } + + // XXX: switch this to use encryptAndSendToDevices() rather than duplicating it? + + await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, new Map([[sender, [device]]]), false); + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + [_event.ToDeviceMessageId]: (0, _uuid.v4)() + }; + await olmlib.encryptMessageForDevice(encryptedContent.ciphertext, this.userId, undefined, this.olmDevice, sender, device, { + type: "m.dummy" + }); + await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true); + await this.baseApis.sendToDevice("m.room.encrypted", new Map([[sender, new Map([[device.deviceId, encryptedContent]])]])); + } + hasKeysForKeyRequest(keyRequest) { + const body = keyRequest.requestBody; + return this.olmDevice.hasInboundSessionKeys(body.room_id, body.sender_key, body.session_id + // TODO: ratchet index + ); + } + + shareKeysWithDevice(keyRequest) { + const userId = keyRequest.userId; + const deviceId = keyRequest.deviceId; + const deviceInfo = this.crypto.getStoredDevice(userId, deviceId); + const body = keyRequest.requestBody; + + // XXX: switch this to use encryptAndSendToDevices()? + + this.olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, new Map([[userId, [deviceInfo]]])).then(devicemap => { + const olmSessionResult = devicemap.get(userId)?.get(deviceId); + if (!olmSessionResult?.sessionId) { + // no session with this device, probably because there + // were no one-time keys. + // + // ensureOlmSessionsForUsers has already done the logging, + // so just skip it. + return null; + } + this.prefixedLogger.log("sharing keys for session " + body.sender_key + "|" + body.session_id + " with device " + userId + ":" + deviceId); + return this.buildKeyForwardingMessage(body.room_id, body.sender_key, body.session_id); + }).then(payload => { + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + [_event.ToDeviceMessageId]: (0, _uuid.v4)() + }; + return this.olmlib.encryptMessageForDevice(encryptedContent.ciphertext, this.userId, undefined, this.olmDevice, userId, deviceInfo, payload).then(() => { + // TODO: retries + return this.baseApis.sendToDevice("m.room.encrypted", new Map([[userId, new Map([[deviceId, encryptedContent]])]])); + }); + }); + } + async buildKeyForwardingMessage(roomId, senderKey, sessionId) { + const key = await this.olmDevice.getInboundGroupSessionKey(roomId, senderKey, sessionId); + return { + type: "m.forwarded_room_key", + content: { + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": roomId, + "sender_key": senderKey, + "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, + "session_id": sessionId, + "session_key": key.key, + "chain_index": key.chain_index, + "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, + "org.matrix.msc3061.shared_history": key.shared_history || false + } + }; + } + + /** + * @param untrusted - whether the key should be considered as untrusted + * @param source - where the key came from + */ + importRoomKey(session, { + untrusted, + source + } = {}) { + const extraSessionData = {}; + if (untrusted || session.untrusted) { + extraSessionData.untrusted = true; + } + if (session["org.matrix.msc3061.shared_history"]) { + extraSessionData.sharedHistory = true; + } + return this.olmDevice.addInboundGroupSession(session.room_id, session.sender_key, session.forwarding_curve25519_key_chain, session.session_id, session.session_key, session.sender_claimed_keys, true, extraSessionData).then(() => { + if (source !== "backup") { + // don't wait for it to complete + this.crypto.backupManager.backupGroupSession(session.sender_key, session.session_id).catch(e => { + // This throws if the upload failed, but this is fine + // since it will have written it to the db and will retry. + this.prefixedLogger.log("Failed to back up megolm session", e); + }); + } + // have another go at decrypting events sent with this session. + this.retryDecryption(session.sender_key, session.session_id, !extraSessionData.untrusted); + }); + } + + /** + * Have another go at decrypting events after we receive a key. Resolves once + * decryption has been re-attempted on all events. + * + * @internal + * @param forceRedecryptIfUntrusted - whether messages that were already + * successfully decrypted using untrusted keys should be re-decrypted + * + * @returns whether all messages were successfully + * decrypted with trusted keys + */ + async retryDecryption(senderKey, sessionId, forceRedecryptIfUntrusted) { + const senderPendingEvents = this.pendingEvents.get(senderKey); + if (!senderPendingEvents) { + return true; + } + const pending = senderPendingEvents.get(sessionId); + if (!pending) { + return true; + } + const pendingList = [...pending]; + this.prefixedLogger.debug("Retrying decryption on events:", pendingList.map(e => `${e.getId()}`)); + await Promise.all(pendingList.map(async ev => { + try { + await ev.attemptDecryption(this.crypto, { + isRetry: true, + forceRedecryptIfUntrusted + }); + } catch (e) { + // don't die if something goes wrong + } + })); + + // If decrypted successfully with trusted keys, they'll have + // been removed from pendingEvents + return !this.pendingEvents.get(senderKey)?.has(sessionId); + } + async retryDecryptionFromSender(senderKey) { + const senderPendingEvents = this.pendingEvents.get(senderKey); + if (!senderPendingEvents) { + return true; + } + this.pendingEvents.delete(senderKey); + await Promise.all([...senderPendingEvents].map(async ([_sessionId, pending]) => { + await Promise.all([...pending].map(async ev => { + try { + await ev.attemptDecryption(this.crypto); + } catch (e) { + // don't die if something goes wrong + } + })); + })); + return !this.pendingEvents.has(senderKey); + } + async sendSharedHistoryInboundSessions(devicesByUser) { + await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser); + const sharedHistorySessions = await this.olmDevice.getSharedHistoryInboundGroupSessions(this.roomId); + this.prefixedLogger.log(`Sharing history in with users ${Array.from(devicesByUser.keys())}`, sharedHistorySessions.map(([senderKey, sessionId]) => `${senderKey}|${sessionId}`)); + for (const [senderKey, sessionId] of sharedHistorySessions) { + const payload = await this.buildKeyForwardingMessage(this.roomId, senderKey, sessionId); + + // FIXME: use encryptAndSendToDevices() rather than duplicating it here. + const promises = []; + const contentMap = new Map(); + for (const [userId, devices] of devicesByUser) { + const deviceMessages = new Map(); + contentMap.set(userId, deviceMessages); + for (const deviceInfo of devices) { + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + [_event.ToDeviceMessageId]: (0, _uuid.v4)() + }; + deviceMessages.set(deviceInfo.deviceId, encryptedContent); + promises.push(olmlib.encryptMessageForDevice(encryptedContent.ciphertext, this.userId, undefined, this.olmDevice, userId, deviceInfo, payload)); + } + } + await Promise.all(promises); + + // prune out any devices that encryptMessageForDevice could not encrypt for, + // in which case it will have just not added anything to the ciphertext object. + // There's no point sending messages to devices if we couldn't encrypt to them, + // since that's effectively a blank message. + for (const [userId, deviceMessages] of contentMap) { + for (const [deviceId, content] of deviceMessages) { + if (!hasCiphertext(content)) { + this.prefixedLogger.log("No ciphertext for device " + userId + ":" + deviceId + ": pruning"); + deviceMessages.delete(deviceId); + } + } + // No devices left for that user? Strip that too. + if (deviceMessages.size === 0) { + this.prefixedLogger.log("Pruned all devices for user " + userId); + contentMap.delete(userId); + } + } + + // Is there anything left? + if (contentMap.size === 0) { + this.prefixedLogger.log("No users left to send to: aborting"); + return; + } + await this.baseApis.sendToDevice("m.room.encrypted", contentMap); + } + } +} +exports.MegolmDecryption = MegolmDecryption; +const PROBLEM_DESCRIPTIONS = { + no_olm: "The sender was unable to establish a secure channel.", + unknown: "The secure channel with the sender was corrupted." +}; +(0, _base.registerAlgorithm)(olmlib.MEGOLM_ALGORITHM, MegolmEncryption, MegolmDecryption);
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/olm.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/olm.js new file mode 100644 index 0000000000..6f72b95375 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/olm.js @@ -0,0 +1,276 @@ +"use strict"; + +var _logger = require("../../logger"); +var olmlib = _interopRequireWildcard(require("../olmlib")); +var _deviceinfo = require("../deviceinfo"); +var _base = require("./base"); +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2016 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ /** + * Defines m.olm encryption/decryption + */ +const DeviceVerification = _deviceinfo.DeviceInfo.DeviceVerification; +/** + * Olm encryption implementation + * + * @param params - parameters, as per {@link EncryptionAlgorithm} + */ +class OlmEncryption extends _base.EncryptionAlgorithm { + constructor(...args) { + super(...args); + _defineProperty(this, "sessionPrepared", false); + _defineProperty(this, "prepPromise", null); + } + /** + * @internal + * @param roomMembers - list of currently-joined users in the room + * @returns Promise which resolves when setup is complete + */ + ensureSession(roomMembers) { + if (this.prepPromise) { + // prep already in progress + return this.prepPromise; + } + if (this.sessionPrepared) { + // prep already done + return Promise.resolve(); + } + this.prepPromise = this.crypto.downloadKeys(roomMembers).then(() => { + return this.crypto.ensureOlmSessionsForUsers(roomMembers); + }).then(() => { + this.sessionPrepared = true; + }).finally(() => { + this.prepPromise = null; + }); + return this.prepPromise; + } + + /** + * @param content - plaintext event content + * + * @returns Promise which resolves to the new event body + */ + async encryptMessage(room, eventType, content) { + // pick the list of recipients based on the membership list. + // + // TODO: there is a race condition here! What if a new user turns up + // just as you are sending a secret message? + + const members = await room.getEncryptionTargetMembers(); + const users = members.map(function (u) { + return u.userId; + }); + await this.ensureSession(users); + const payloadFields = { + room_id: room.roomId, + type: eventType, + content: content + }; + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {} + }; + const promises = []; + for (const userId of users) { + const devices = this.crypto.getStoredDevicesForUser(userId) || []; + for (const deviceInfo of devices) { + const key = deviceInfo.getIdentityKey(); + if (key == this.olmDevice.deviceCurve25519Key) { + // don't bother sending to ourself + continue; + } + if (deviceInfo.verified == DeviceVerification.BLOCKED) { + // don't bother setting up sessions with blocked users + continue; + } + promises.push(olmlib.encryptMessageForDevice(encryptedContent.ciphertext, this.userId, this.deviceId, this.olmDevice, userId, deviceInfo, payloadFields)); + } + } + return Promise.all(promises).then(() => encryptedContent); + } +} + +/** + * Olm decryption implementation + * + * @param params - parameters, as per {@link DecryptionAlgorithm} + */ +class OlmDecryption extends _base.DecryptionAlgorithm { + /** + * returns a promise which resolves to a + * {@link EventDecryptionResult} once we have finished + * decrypting. Rejects with an `algorithms.DecryptionError` if there is a + * problem decrypting the event. + */ + async decryptEvent(event) { + const content = event.getWireContent(); + const deviceKey = content.sender_key; + const ciphertext = content.ciphertext; + if (!ciphertext) { + throw new _base.DecryptionError("OLM_MISSING_CIPHERTEXT", "Missing ciphertext"); + } + if (!(this.olmDevice.deviceCurve25519Key in ciphertext)) { + throw new _base.DecryptionError("OLM_NOT_INCLUDED_IN_RECIPIENTS", "Not included in recipients"); + } + const message = ciphertext[this.olmDevice.deviceCurve25519Key]; + let payloadString; + try { + payloadString = await this.decryptMessage(deviceKey, message); + } catch (e) { + throw new _base.DecryptionError("OLM_BAD_ENCRYPTED_MESSAGE", "Bad Encrypted Message", { + sender: deviceKey, + err: e + }); + } + const payload = JSON.parse(payloadString); + + // check that we were the intended recipient, to avoid unknown-key attack + // https://github.com/vector-im/vector-web/issues/2483 + if (payload.recipient != this.userId) { + throw new _base.DecryptionError("OLM_BAD_RECIPIENT", "Message was intented for " + payload.recipient); + } + if (payload.recipient_keys.ed25519 != this.olmDevice.deviceEd25519Key) { + throw new _base.DecryptionError("OLM_BAD_RECIPIENT_KEY", "Message not intended for this device", { + intended: payload.recipient_keys.ed25519, + our_key: this.olmDevice.deviceEd25519Key + }); + } + + // check that the device that encrypted the event belongs to the user that the event claims it's from. + // + // To do this, we need to make sure that our device list is up-to-date. If the device is unknown, we can only + // assume that the device logged out and accept it anyway. Some event handlers, such as secret sharing, may be + // more strict and reject events that come from unknown devices. + // + // This is a defence against the following scenario: + // + // * Alice has verified Bob and Mallory. + // * Mallory gets control of Alice's server, and sends a megolm session to Alice using her (Mallory's) + // senderkey, but claiming to be from Bob. + // * Mallory sends more events using that session, claiming to be from Bob. + // * Alice sees that the senderkey is verified (since she verified Mallory) so marks events those + // events as verified even though the sender is forged. + // + // In practice, it's not clear that the js-sdk would behave that way, so this may be only a defence in depth. + + await this.crypto.deviceList.downloadKeys([event.getSender()], false); + const senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, deviceKey); + if (senderKeyUser !== event.getSender() && senderKeyUser != undefined) { + throw new _base.DecryptionError("OLM_BAD_SENDER", "Message claimed to be from " + event.getSender(), { + real_sender: senderKeyUser + }); + } + + // check that the original sender matches what the homeserver told us, to + // avoid people masquerading as others. + // (this check is also provided via the sender's embedded ed25519 key, + // which is checked elsewhere). + if (payload.sender != event.getSender()) { + throw new _base.DecryptionError("OLM_FORWARDED_MESSAGE", "Message forwarded from " + payload.sender, { + reported_sender: event.getSender() + }); + } + + // Olm events intended for a room have a room_id. + if (payload.room_id !== event.getRoomId()) { + throw new _base.DecryptionError("OLM_BAD_ROOM", "Message intended for room " + payload.room_id, { + reported_room: event.getRoomId() || "ROOM_ID_UNDEFINED" + }); + } + const claimedKeys = payload.keys || {}; + return { + clearEvent: payload, + senderCurve25519Key: deviceKey, + claimedEd25519Key: claimedKeys.ed25519 || null + }; + } + + /** + * Attempt to decrypt an Olm message + * + * @param theirDeviceIdentityKey - Curve25519 identity key of the sender + * @param message - message object, with 'type' and 'body' fields + * + * @returns payload, if decrypted successfully. + */ + decryptMessage(theirDeviceIdentityKey, message) { + // This is a wrapper that serialises decryptions of prekey messages, because + // otherwise we race between deciding we have no active sessions for the message + // and creating a new one, which we can only do once because it removes the OTK. + if (message.type !== 0) { + // not a prekey message: we can safely just try & decrypt it + return this.reallyDecryptMessage(theirDeviceIdentityKey, message); + } else { + const myPromise = this.olmDevice.olmPrekeyPromise.then(() => { + return this.reallyDecryptMessage(theirDeviceIdentityKey, message); + }); + // we want the error, but don't propagate it to the next decryption + this.olmDevice.olmPrekeyPromise = myPromise.catch(() => {}); + return myPromise; + } + } + async reallyDecryptMessage(theirDeviceIdentityKey, message) { + const sessionIds = await this.olmDevice.getSessionIdsForDevice(theirDeviceIdentityKey); + + // try each session in turn. + const decryptionErrors = {}; + for (const sessionId of sessionIds) { + try { + const payload = await this.olmDevice.decryptMessage(theirDeviceIdentityKey, sessionId, message.type, message.body); + _logger.logger.log("Decrypted Olm message from " + theirDeviceIdentityKey + " with session " + sessionId); + return payload; + } catch (e) { + const foundSession = await this.olmDevice.matchesSession(theirDeviceIdentityKey, sessionId, message.type, message.body); + if (foundSession) { + // decryption failed, but it was a prekey message matching this + // session, so it should have worked. + throw new Error("Error decrypting prekey message with existing session id " + sessionId + ": " + e.message); + } + + // otherwise it's probably a message for another session; carry on, but + // keep a record of the error + decryptionErrors[sessionId] = e.message; + } + } + if (message.type !== 0) { + // not a prekey message, so it should have matched an existing session, but it + // didn't work. + + if (sessionIds.length === 0) { + throw new Error("No existing sessions"); + } + throw new Error("Error decrypting non-prekey message with existing sessions: " + JSON.stringify(decryptionErrors)); + } + + // prekey message which doesn't match any existing sessions: make a new + // session. + + let res; + try { + res = await this.olmDevice.createInboundSession(theirDeviceIdentityKey, message.type, message.body); + } catch (e) { + decryptionErrors["(new)"] = e.message; + throw new Error("Error decrypting prekey message: " + JSON.stringify(decryptionErrors)); + } + _logger.logger.log("created new inbound Olm session ID " + res.session_id + " with " + theirDeviceIdentityKey); + return res.payload; + } +} +(0, _base.registerAlgorithm)(olmlib.OLM_ALGORITHM, OlmEncryption, OlmDecryption);
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/api.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/api.js new file mode 100644 index 0000000000..aeed6bb466 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/api.js @@ -0,0 +1,12 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +Object.defineProperty(exports, "CrossSigningKey", { + enumerable: true, + get: function () { + return _cryptoApi.CrossSigningKey; + } +}); +var _cryptoApi = require("../crypto-api");
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/backup.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/backup.js new file mode 100644 index 0000000000..554563213b --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/backup.js @@ -0,0 +1,651 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.algorithmsByName = exports.DefaultAlgorithm = exports.Curve25519 = exports.BackupManager = exports.Aes256 = void 0; +var _client = require("../client"); +var _logger = require("../logger"); +var _olmlib = require("./olmlib"); +var _key_passphrase = require("./key_passphrase"); +var _utils = require("../utils"); +var _indexeddbCryptoStore = require("./store/indexeddb-crypto-store"); +var _recoverykey = require("./recoverykey"); +var _aes = require("./aes"); +var _NamespacedValue = require("../NamespacedValue"); +var _index = require("./index"); +var _crypto = require("./crypto"); +var _httpApi = require("../http-api"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ /** + * Classes for dealing with key backup. + */ +const KEY_BACKUP_KEYS_PER_REQUEST = 200; +const KEY_BACKUP_CHECK_RATE_LIMIT = 5000; // ms + +/* eslint-disable camelcase */ + +/* eslint-enable camelcase */ +/** A function used to get the secret key for a backup. + */ +/** + * Manages the key backup. + */ +class BackupManager { + // When did we last try to check the server for a given session id? + + constructor(baseApis, getKey) { + this.baseApis = baseApis; + this.getKey = getKey; + _defineProperty(this, "algorithm", void 0); + _defineProperty(this, "backupInfo", void 0); + // The info dict from /room_keys/version + _defineProperty(this, "checkedForBackup", void 0); + // Have we checked the server for a backup we can use? + _defineProperty(this, "sendingBackups", void 0); + // Are we currently sending backups? + _defineProperty(this, "sessionLastCheckAttemptedTime", {}); + this.checkedForBackup = false; + this.sendingBackups = false; + } + get version() { + return this.backupInfo && this.backupInfo.version; + } + + /** + * Performs a quick check to ensure that the backup info looks sane. + * + * Throws an error if a problem is detected. + * + * @param info - the key backup info + */ + static checkBackupVersion(info) { + const Algorithm = algorithmsByName[info.algorithm]; + if (!Algorithm) { + throw new Error("Unknown backup algorithm: " + info.algorithm); + } + if (typeof info.auth_data !== "object") { + throw new Error("Invalid backup data returned"); + } + return Algorithm.checkBackupVersion(info); + } + static makeAlgorithm(info, getKey) { + const Algorithm = algorithmsByName[info.algorithm]; + if (!Algorithm) { + throw new Error("Unknown backup algorithm"); + } + return Algorithm.init(info.auth_data, getKey); + } + async enableKeyBackup(info) { + this.backupInfo = info; + if (this.algorithm) { + this.algorithm.free(); + } + this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey); + this.baseApis.emit(_index.CryptoEvent.KeyBackupStatus, true); + + // There may be keys left over from a partially completed backup, so + // schedule a send to check. + this.scheduleKeyBackupSend(); + } + + /** + * Disable backing up of keys. + */ + disableKeyBackup() { + if (this.algorithm) { + this.algorithm.free(); + } + this.algorithm = undefined; + this.backupInfo = undefined; + this.baseApis.emit(_index.CryptoEvent.KeyBackupStatus, false); + } + getKeyBackupEnabled() { + if (!this.checkedForBackup) { + return null; + } + return Boolean(this.algorithm); + } + async prepareKeyBackupVersion(key, algorithm) { + const Algorithm = algorithm ? algorithmsByName[algorithm] : DefaultAlgorithm; + if (!Algorithm) { + throw new Error("Unknown backup algorithm"); + } + const [privateKey, authData] = await Algorithm.prepare(key); + const recoveryKey = (0, _recoverykey.encodeRecoveryKey)(privateKey); + return { + algorithm: Algorithm.algorithmName, + auth_data: authData, + recovery_key: recoveryKey, + privateKey + }; + } + async createKeyBackupVersion(info) { + this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey); + } + + /** + * 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. + */ + async checkAndStart() { + _logger.logger.log("Checking key backup status..."); + if (this.baseApis.isGuest()) { + _logger.logger.log("Skipping key backup check since user is guest"); + this.checkedForBackup = true; + return null; + } + let backupInfo; + try { + backupInfo = (await this.baseApis.getKeyBackupVersion()) ?? undefined; + } catch (e) { + _logger.logger.log("Error checking for active key backup", e); + if (e.httpStatus === 404) { + // 404 is returned when the key backup does not exist, so that + // counts as successfully checking. + this.checkedForBackup = true; + } + return null; + } + this.checkedForBackup = true; + const trustInfo = await this.isKeyBackupTrusted(backupInfo); + if (trustInfo.usable && !this.backupInfo) { + _logger.logger.log(`Found usable key backup v${backupInfo.version}: enabling key backups`); + await this.enableKeyBackup(backupInfo); + } else if (!trustInfo.usable && this.backupInfo) { + _logger.logger.log("No usable key backup: disabling key backup"); + this.disableKeyBackup(); + } else if (!trustInfo.usable && !this.backupInfo) { + _logger.logger.log("No usable key backup: not enabling key backup"); + } else if (trustInfo.usable && this.backupInfo) { + // may not be the same version: if not, we should switch + if (backupInfo.version !== this.backupInfo.version) { + _logger.logger.log(`On backup version ${this.backupInfo.version} but ` + `found version ${backupInfo.version}: switching.`); + this.disableKeyBackup(); + await this.enableKeyBackup(backupInfo); + // We're now using a new backup, so schedule all the keys we have to be + // uploaded to the new backup. This is a bit of a workaround to upload + // keys to a new backup in *most* cases, but it won't cover all cases + // because we don't remember what backup version we uploaded keys to: + // see https://github.com/vector-im/element-web/issues/14833 + await this.scheduleAllGroupSessionsForBackup(); + } else { + _logger.logger.log(`Backup version ${backupInfo.version} still current`); + } + } + return { + backupInfo, + trustInfo + }; + } + + /** + * Forces a re-check of the key backup and enables/disables it + * as appropriate. + * + * @returns Object with backup info (as returned by + * getKeyBackupVersion) in backupInfo and + * trust information (as returned by isKeyBackupTrusted) + * in trustInfo. + */ + async checkKeyBackup() { + this.checkedForBackup = false; + return this.checkAndStart(); + } + + /** + * Attempts to retrieve a session from a key backup, if enough time + * has elapsed since the last check for this session id. + */ + async queryKeyBackupRateLimited(targetRoomId, targetSessionId) { + if (!this.backupInfo) { + return; + } + const now = new Date().getTime(); + if (!this.sessionLastCheckAttemptedTime[targetSessionId] || now - this.sessionLastCheckAttemptedTime[targetSessionId] > KEY_BACKUP_CHECK_RATE_LIMIT) { + this.sessionLastCheckAttemptedTime[targetSessionId] = now; + await this.baseApis.restoreKeyBackupWithCache(targetRoomId, targetSessionId, this.backupInfo, {}); + } + } + + /** + * Check if the given backup info is trusted. + * + * @param backupInfo - key backup info dict from /room_keys/version + */ + async isKeyBackupTrusted(backupInfo) { + const ret = { + usable: false, + trusted_locally: false, + sigs: [] + }; + if (!backupInfo || !backupInfo.algorithm || !backupInfo.auth_data || !backupInfo.auth_data.signatures) { + _logger.logger.info("Key backup is absent or missing required data"); + return ret; + } + const userId = this.baseApis.getUserId(); + const privKey = await this.baseApis.crypto.getSessionBackupPrivateKey(); + if (privKey) { + let algorithm = null; + try { + algorithm = await BackupManager.makeAlgorithm(backupInfo, async () => privKey); + if (await algorithm.keyMatches(privKey)) { + _logger.logger.info("Backup is trusted locally"); + ret.trusted_locally = true; + } + } catch { + // do nothing -- if we have an error, then we don't mark it as + // locally trusted + } finally { + algorithm?.free(); + } + } + const mySigs = backupInfo.auth_data.signatures[userId] || {}; + for (const keyId of Object.keys(mySigs)) { + const keyIdParts = keyId.split(":"); + if (keyIdParts[0] !== "ed25519") { + _logger.logger.log("Ignoring unknown signature type: " + keyIdParts[0]); + continue; + } + // Could be a cross-signing master key, but just say this is the device + // ID for backwards compat + const sigInfo = { + deviceId: keyIdParts[1] + }; + + // first check to see if it's from our cross-signing key + const crossSigningId = this.baseApis.crypto.crossSigningInfo.getId(); + if (crossSigningId === sigInfo.deviceId) { + sigInfo.crossSigningId = true; + try { + await (0, _olmlib.verifySignature)(this.baseApis.crypto.olmDevice, backupInfo.auth_data, userId, sigInfo.deviceId, crossSigningId); + sigInfo.valid = true; + } catch (e) { + _logger.logger.warn("Bad signature from cross signing key " + crossSigningId, e); + sigInfo.valid = false; + } + ret.sigs.push(sigInfo); + continue; + } + + // Now look for a sig from a device + // At some point this can probably go away and we'll just support + // it being signed by the cross-signing master key + const device = this.baseApis.crypto.deviceList.getStoredDevice(userId, sigInfo.deviceId); + if (device) { + sigInfo.device = device; + sigInfo.deviceTrust = this.baseApis.checkDeviceTrust(userId, sigInfo.deviceId); + try { + await (0, _olmlib.verifySignature)(this.baseApis.crypto.olmDevice, backupInfo.auth_data, userId, device.deviceId, device.getFingerprint()); + sigInfo.valid = true; + } catch (e) { + _logger.logger.info("Bad signature from key ID " + keyId + " userID " + this.baseApis.getUserId() + " device ID " + device.deviceId + " fingerprint: " + device.getFingerprint(), backupInfo.auth_data, e); + sigInfo.valid = false; + } + } else { + sigInfo.valid = null; // Can't determine validity because we don't have the signing device + _logger.logger.info("Ignoring signature from unknown key " + keyId); + } + ret.sigs.push(sigInfo); + } + ret.usable = ret.sigs.some(s => { + return s.valid && (s.device && s.deviceTrust?.isVerified() || s.crossSigningId); + }); + return ret; + } + + /** + * Schedules sending all keys waiting to be sent to the backup, if not already + * scheduled. Retries if necessary. + * + * @param maxDelay - Maximum delay to wait in ms. 0 means no delay. + */ + async scheduleKeyBackupSend(maxDelay = 10000) { + if (this.sendingBackups) return; + this.sendingBackups = true; + try { + // wait between 0 and `maxDelay` seconds, to avoid backup + // requests from different clients hitting the server all at + // the same time when a new key is sent + const delay = Math.random() * maxDelay; + await (0, _utils.sleep)(delay); + let numFailures = 0; // number of consecutive failures + for (;;) { + if (!this.algorithm) { + return; + } + try { + const numBackedUp = await this.backupPendingKeys(KEY_BACKUP_KEYS_PER_REQUEST); + if (numBackedUp === 0) { + // no sessions left needing backup: we're done + return; + } + numFailures = 0; + } catch (err) { + numFailures++; + _logger.logger.log("Key backup request failed", err); + if (err.data) { + if (err.data.errcode == "M_NOT_FOUND" || err.data.errcode == "M_WRONG_ROOM_KEYS_VERSION") { + // Re-check key backup status on error, so we can be + // sure to present the current situation when asked. + await this.checkKeyBackup(); + // Backup version has changed or this backup version + // has been deleted + this.baseApis.crypto.emit(_index.CryptoEvent.KeyBackupFailed, err.data.errcode); + throw err; + } + } + } + if (numFailures) { + // exponential backoff if we have failures + await (0, _utils.sleep)(1000 * Math.pow(2, Math.min(numFailures - 1, 4))); + } + } + } finally { + this.sendingBackups = false; + } + } + + /** + * Take some e2e keys waiting to be backed up and send them + * to the backup. + * + * @param limit - Maximum number of keys to back up + * @returns Number of sessions backed up + */ + async backupPendingKeys(limit) { + const sessions = await this.baseApis.crypto.cryptoStore.getSessionsNeedingBackup(limit); + if (!sessions.length) { + return 0; + } + let remaining = await this.baseApis.crypto.cryptoStore.countSessionsNeedingBackup(); + this.baseApis.crypto.emit(_index.CryptoEvent.KeyBackupSessionsRemaining, remaining); + const rooms = {}; + for (const session of sessions) { + const roomId = session.sessionData.room_id; + (0, _utils.safeSet)(rooms, roomId, rooms[roomId] || { + sessions: {} + }); + const sessionData = this.baseApis.crypto.olmDevice.exportInboundGroupSession(session.senderKey, session.sessionId, session.sessionData); + sessionData.algorithm = _olmlib.MEGOLM_ALGORITHM; + const forwardedCount = (sessionData.forwarding_curve25519_key_chain || []).length; + const userId = this.baseApis.crypto.deviceList.getUserByIdentityKey(_olmlib.MEGOLM_ALGORITHM, session.senderKey); + const device = this.baseApis.crypto.deviceList.getDeviceByIdentityKey(_olmlib.MEGOLM_ALGORITHM, session.senderKey) ?? undefined; + const verified = this.baseApis.crypto.checkDeviceInfoTrust(userId, device).isVerified(); + (0, _utils.safeSet)(rooms[roomId]["sessions"], session.sessionId, { + first_message_index: sessionData.first_known_index, + forwarded_count: forwardedCount, + is_verified: verified, + session_data: await this.algorithm.encryptSession(sessionData) + }); + } + await this.baseApis.sendKeyBackup(undefined, undefined, this.backupInfo.version, { + rooms + }); + await this.baseApis.crypto.cryptoStore.unmarkSessionsNeedingBackup(sessions); + remaining = await this.baseApis.crypto.cryptoStore.countSessionsNeedingBackup(); + this.baseApis.crypto.emit(_index.CryptoEvent.KeyBackupSessionsRemaining, remaining); + return sessions.length; + } + async backupGroupSession(senderKey, sessionId) { + await this.baseApis.crypto.cryptoStore.markSessionsNeedingBackup([{ + senderKey: senderKey, + sessionId: sessionId + }]); + if (this.backupInfo) { + // don't wait for this to complete: it will delay so + // happens in the background + this.scheduleKeyBackupSend(); + } + // if this.backupInfo is not set, then the keys will be backed up when + // this.enableKeyBackup is called + } + + /** + * Marks all group sessions as needing to be backed up and schedules them to + * upload in the background as soon as possible. + */ + async scheduleAllGroupSessionsForBackup() { + await this.flagAllGroupSessionsForBackup(); + + // Schedule keys to upload in the background as soon as possible. + this.scheduleKeyBackupSend(0 /* maxDelay */); + } + + /** + * Marks all group sessions as needing to be backed up without scheduling + * them to upload in the background. + * @returns Promise which resolves to the number of sessions now requiring a backup + * (which will be equal to the number of sessions in the store). + */ + async flagAllGroupSessionsForBackup() { + await this.baseApis.crypto.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, _indexeddbCryptoStore.IndexedDBCryptoStore.STORE_BACKUP], txn => { + this.baseApis.crypto.cryptoStore.getAllEndToEndInboundGroupSessions(txn, session => { + if (session !== null) { + this.baseApis.crypto.cryptoStore.markSessionsNeedingBackup([session], txn); + } + }); + }); + const remaining = await this.baseApis.crypto.cryptoStore.countSessionsNeedingBackup(); + this.baseApis.emit(_index.CryptoEvent.KeyBackupSessionsRemaining, remaining); + return remaining; + } + + /** + * Counts the number of end to end session keys that are waiting to be backed up + * @returns Promise which resolves to the number of sessions requiring backup + */ + countSessionsNeedingBackup() { + return this.baseApis.crypto.cryptoStore.countSessionsNeedingBackup(); + } +} +exports.BackupManager = BackupManager; +class Curve25519 { + constructor(authData, publicKey, + // FIXME: PkEncryption + getKey) { + this.authData = authData; + this.publicKey = publicKey; + this.getKey = getKey; + } + static async init(authData, getKey) { + if (!authData || !("public_key" in authData)) { + throw new Error("auth_data missing required information"); + } + const publicKey = new global.Olm.PkEncryption(); + publicKey.set_recipient_key(authData.public_key); + return new Curve25519(authData, publicKey, getKey); + } + static async prepare(key) { + const decryption = new global.Olm.PkDecryption(); + try { + const authData = {}; + if (!key) { + authData.public_key = decryption.generate_key(); + } else if (key instanceof Uint8Array) { + authData.public_key = decryption.init_with_private_key(key); + } else { + const derivation = await (0, _key_passphrase.keyFromPassphrase)(key); + authData.private_key_salt = derivation.salt; + authData.private_key_iterations = derivation.iterations; + authData.public_key = decryption.init_with_private_key(derivation.key); + } + const publicKey = new global.Olm.PkEncryption(); + publicKey.set_recipient_key(authData.public_key); + return [decryption.get_private_key(), authData]; + } finally { + decryption.free(); + } + } + static checkBackupVersion(info) { + if (!("public_key" in info.auth_data)) { + throw new Error("Invalid backup data returned"); + } + } + get untrusted() { + return true; + } + async encryptSession(data) { + const plainText = Object.assign({}, data); + delete plainText.session_id; + delete plainText.room_id; + delete plainText.first_known_index; + return this.publicKey.encrypt(JSON.stringify(plainText)); + } + async decryptSessions(sessions) { + const privKey = await this.getKey(); + const decryption = new global.Olm.PkDecryption(); + try { + const backupPubKey = decryption.init_with_private_key(privKey); + if (backupPubKey !== this.authData.public_key) { + throw new _httpApi.MatrixError({ + errcode: _client.MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY + }); + } + const keys = []; + for (const [sessionId, sessionData] of Object.entries(sessions)) { + try { + const decrypted = JSON.parse(decryption.decrypt(sessionData.session_data.ephemeral, sessionData.session_data.mac, sessionData.session_data.ciphertext)); + decrypted.session_id = sessionId; + keys.push(decrypted); + } catch (e) { + _logger.logger.log("Failed to decrypt megolm session from backup", e, sessionData); + } + } + return keys; + } finally { + decryption.free(); + } + } + async keyMatches(key) { + const decryption = new global.Olm.PkDecryption(); + let pubKey; + try { + pubKey = decryption.init_with_private_key(key); + } finally { + decryption.free(); + } + return pubKey === this.authData.public_key; + } + free() { + this.publicKey.free(); + } +} +exports.Curve25519 = Curve25519; +_defineProperty(Curve25519, "algorithmName", "m.megolm_backup.v1.curve25519-aes-sha2"); +function randomBytes(size) { + const buf = new Uint8Array(size); + _crypto.crypto.getRandomValues(buf); + return buf; +} +const UNSTABLE_MSC3270_NAME = new _NamespacedValue.UnstableValue("m.megolm_backup.v1.aes-hmac-sha2", "org.matrix.msc3270.v1.aes-hmac-sha2"); +class Aes256 { + constructor(authData, key) { + this.authData = authData; + this.key = key; + } + static async init(authData, getKey) { + if (!authData) { + throw new Error("auth_data missing"); + } + const key = await getKey(); + if (authData.mac) { + const { + mac + } = await (0, _aes.calculateKeyCheck)(key, authData.iv); + if (authData.mac.replace(/=+$/g, "") !== mac.replace(/=+/g, "")) { + throw new Error("Key does not match"); + } + } + return new Aes256(authData, key); + } + static async prepare(key) { + let outKey; + const authData = {}; + if (!key) { + outKey = randomBytes(32); + } else if (key instanceof Uint8Array) { + outKey = new Uint8Array(key); + } else { + const derivation = await (0, _key_passphrase.keyFromPassphrase)(key); + authData.private_key_salt = derivation.salt; + authData.private_key_iterations = derivation.iterations; + outKey = derivation.key; + } + const { + iv, + mac + } = await (0, _aes.calculateKeyCheck)(outKey); + authData.iv = iv; + authData.mac = mac; + return [outKey, authData]; + } + static checkBackupVersion(info) { + if (!("iv" in info.auth_data && "mac" in info.auth_data)) { + throw new Error("Invalid backup data returned"); + } + } + get untrusted() { + return false; + } + encryptSession(data) { + const plainText = Object.assign({}, data); + delete plainText.session_id; + delete plainText.room_id; + delete plainText.first_known_index; + return (0, _aes.encryptAES)(JSON.stringify(plainText), this.key, data.session_id); + } + async decryptSessions(sessions) { + const keys = []; + for (const [sessionId, sessionData] of Object.entries(sessions)) { + try { + const decrypted = JSON.parse(await (0, _aes.decryptAES)(sessionData.session_data, this.key, sessionId)); + decrypted.session_id = sessionId; + keys.push(decrypted); + } catch (e) { + _logger.logger.log("Failed to decrypt megolm session from backup", e, sessionData); + } + } + return keys; + } + async keyMatches(key) { + if (this.authData.mac) { + const { + mac + } = await (0, _aes.calculateKeyCheck)(key, this.authData.iv); + return this.authData.mac.replace(/=+$/g, "") === mac.replace(/=+/g, ""); + } else { + // if we have no information, we have to assume the key is right + return true; + } + } + free() { + this.key.fill(0); + } +} +exports.Aes256 = Aes256; +_defineProperty(Aes256, "algorithmName", UNSTABLE_MSC3270_NAME.name); +const algorithmsByName = { + [Curve25519.algorithmName]: Curve25519, + [Aes256.algorithmName]: Aes256 +}; +exports.algorithmsByName = algorithmsByName; +const DefaultAlgorithm = Curve25519; +exports.DefaultAlgorithm = DefaultAlgorithm;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/crypto.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/crypto.js new file mode 100644 index 0000000000..f4a47c9ca7 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/crypto.js @@ -0,0 +1,60 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.crypto = exports.TextEncoder = void 0; +exports.setCrypto = setCrypto; +exports.setTextEncoder = setTextEncoder; +exports.subtleCrypto = void 0; +var _logger = require("../logger"); +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +let crypto = global.window?.crypto; +exports.crypto = crypto; +let subtleCrypto = global.window?.crypto?.subtle ?? global.window?.crypto?.webkitSubtle; +exports.subtleCrypto = subtleCrypto; +let TextEncoder = global.window?.TextEncoder; + +/* eslint-disable @typescript-eslint/no-var-requires */ +exports.TextEncoder = TextEncoder; +if (!crypto) { + try { + exports.crypto = crypto = require("crypto").webcrypto; + } catch (e) { + _logger.logger.error("Failed to load webcrypto", e); + } +} +if (!subtleCrypto) { + exports.subtleCrypto = subtleCrypto = crypto?.subtle; +} +if (!TextEncoder) { + try { + exports.TextEncoder = TextEncoder = require("util").TextEncoder; + } catch (e) { + _logger.logger.error("Failed to load TextEncoder util", e); + } +} +/* eslint-enable @typescript-eslint/no-var-requires */ + +function setCrypto(_crypto) { + exports.crypto = crypto = _crypto; + exports.subtleCrypto = subtleCrypto = _crypto.subtle ?? _crypto.webkitSubtle; +} +function setTextEncoder(_TextEncoder) { + exports.TextEncoder = TextEncoder = _TextEncoder; +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/dehydration.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/dehydration.js new file mode 100644 index 0000000000..8ee568ae8c --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/dehydration.js @@ -0,0 +1,237 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.DehydrationManager = exports.DEHYDRATION_ALGORITHM = void 0; +var _anotherJson = _interopRequireDefault(require("another-json")); +var _olmlib = require("./olmlib"); +var _indexeddbCryptoStore = require("../crypto/store/indexeddb-crypto-store"); +var _aes = require("./aes"); +var _logger = require("../logger"); +var _httpApi = require("../http-api"); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2020-2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +const DEHYDRATION_ALGORITHM = "org.matrix.msc2697.v1.olm.libolm_pickle"; +exports.DEHYDRATION_ALGORITHM = DEHYDRATION_ALGORITHM; +const oneweek = 7 * 24 * 60 * 60 * 1000; +class DehydrationManager { + constructor(crypto) { + this.crypto = crypto; + _defineProperty(this, "inProgress", false); + _defineProperty(this, "timeoutId", void 0); + _defineProperty(this, "key", void 0); + _defineProperty(this, "keyInfo", void 0); + _defineProperty(this, "deviceDisplayName", void 0); + this.getDehydrationKeyFromCache(); + } + getDehydrationKeyFromCache() { + return this.crypto.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => { + this.crypto.cryptoStore.getSecretStorePrivateKey(txn, async result => { + if (result) { + const { + key, + keyInfo, + deviceDisplayName, + time + } = result; + const pickleKey = Buffer.from(this.crypto.olmDevice.pickleKey); + const decrypted = await (0, _aes.decryptAES)(key, pickleKey, DEHYDRATION_ALGORITHM); + this.key = (0, _olmlib.decodeBase64)(decrypted); + this.keyInfo = keyInfo; + this.deviceDisplayName = deviceDisplayName; + const now = Date.now(); + const delay = Math.max(1, time + oneweek - now); + this.timeoutId = global.setTimeout(this.dehydrateDevice.bind(this), delay); + } + }, "dehydration"); + }); + } + + /** set the key, and queue periodic dehydration to the server in the background */ + async setKeyAndQueueDehydration(key, keyInfo = {}, deviceDisplayName) { + const matches = await this.setKey(key, keyInfo, deviceDisplayName); + if (!matches) { + // start dehydration in the background + this.dehydrateDevice(); + } + } + async setKey(key, keyInfo = {}, deviceDisplayName) { + if (!key) { + // unsetting the key -- cancel any pending dehydration task + if (this.timeoutId) { + global.clearTimeout(this.timeoutId); + this.timeoutId = undefined; + } + // clear storage + await this.crypto.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => { + this.crypto.cryptoStore.storeSecretStorePrivateKey(txn, "dehydration", null); + }); + this.key = undefined; + this.keyInfo = undefined; + return; + } + + // Check to see if it's the same key as before. If it's different, + // dehydrate a new device. If it's the same, we can keep the same + // device. (Assume that keyInfo and deviceDisplayName will be the + // same if the key is the same.) + let matches = !!this.key && key.length == this.key.length; + for (let i = 0; matches && i < key.length; i++) { + if (key[i] != this.key[i]) { + matches = false; + } + } + if (!matches) { + this.key = key; + this.keyInfo = keyInfo; + this.deviceDisplayName = deviceDisplayName; + } + return matches; + } + + /** returns the device id of the newly created dehydrated device */ + async dehydrateDevice() { + if (this.inProgress) { + _logger.logger.log("Dehydration already in progress -- not starting new dehydration"); + return; + } + this.inProgress = true; + if (this.timeoutId) { + global.clearTimeout(this.timeoutId); + this.timeoutId = undefined; + } + try { + const pickleKey = Buffer.from(this.crypto.olmDevice.pickleKey); + + // update the crypto store with the timestamp + const key = await (0, _aes.encryptAES)((0, _olmlib.encodeBase64)(this.key), pickleKey, DEHYDRATION_ALGORITHM); + await this.crypto.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => { + this.crypto.cryptoStore.storeSecretStorePrivateKey(txn, "dehydration", { + keyInfo: this.keyInfo, + key, + deviceDisplayName: this.deviceDisplayName, + time: Date.now() + }); + }); + _logger.logger.log("Attempting to dehydrate device"); + _logger.logger.log("Creating account"); + // create the account and all the necessary keys + const account = new global.Olm.Account(); + account.create(); + const e2eKeys = JSON.parse(account.identity_keys()); + const maxKeys = account.max_number_of_one_time_keys(); + // FIXME: generate in small batches? + account.generate_one_time_keys(maxKeys / 2); + account.generate_fallback_key(); + const otks = JSON.parse(account.one_time_keys()); + const fallbacks = JSON.parse(account.fallback_key()); + account.mark_keys_as_published(); + + // dehydrate the account and store it on the server + const pickledAccount = account.pickle(new Uint8Array(this.key)); + const deviceData = { + algorithm: DEHYDRATION_ALGORITHM, + account: pickledAccount + }; + if (this.keyInfo.passphrase) { + deviceData.passphrase = this.keyInfo.passphrase; + } + _logger.logger.log("Uploading account to server"); + // eslint-disable-next-line camelcase + const dehydrateResult = await this.crypto.baseApis.http.authedRequest(_httpApi.Method.Put, "/dehydrated_device", undefined, { + device_data: deviceData, + initial_device_display_name: this.deviceDisplayName + }, { + prefix: "/_matrix/client/unstable/org.matrix.msc2697.v2" + }); + + // send the keys to the server + const deviceId = dehydrateResult.device_id; + _logger.logger.log("Preparing device keys", deviceId); + const deviceKeys = { + algorithms: this.crypto.supportedAlgorithms, + device_id: deviceId, + user_id: this.crypto.userId, + keys: { + [`ed25519:${deviceId}`]: e2eKeys.ed25519, + [`curve25519:${deviceId}`]: e2eKeys.curve25519 + } + }; + const deviceSignature = account.sign(_anotherJson.default.stringify(deviceKeys)); + deviceKeys.signatures = { + [this.crypto.userId]: { + [`ed25519:${deviceId}`]: deviceSignature + } + }; + if (this.crypto.crossSigningInfo.getId("self_signing")) { + await this.crypto.crossSigningInfo.signObject(deviceKeys, "self_signing"); + } + _logger.logger.log("Preparing one-time keys"); + const oneTimeKeys = {}; + for (const [keyId, key] of Object.entries(otks.curve25519)) { + const k = { + key + }; + const signature = account.sign(_anotherJson.default.stringify(k)); + k.signatures = { + [this.crypto.userId]: { + [`ed25519:${deviceId}`]: signature + } + }; + oneTimeKeys[`signed_curve25519:${keyId}`] = k; + } + _logger.logger.log("Preparing fallback keys"); + const fallbackKeys = {}; + for (const [keyId, key] of Object.entries(fallbacks.curve25519)) { + const k = { + key, + fallback: true + }; + const signature = account.sign(_anotherJson.default.stringify(k)); + k.signatures = { + [this.crypto.userId]: { + [`ed25519:${deviceId}`]: signature + } + }; + fallbackKeys[`signed_curve25519:${keyId}`] = k; + } + _logger.logger.log("Uploading keys to server"); + await this.crypto.baseApis.http.authedRequest(_httpApi.Method.Post, "/keys/upload/" + encodeURI(deviceId), undefined, { + "device_keys": deviceKeys, + "one_time_keys": oneTimeKeys, + "org.matrix.msc2732.fallback_keys": fallbackKeys + }); + _logger.logger.log("Done dehydrating"); + + // dehydrate again in a week + this.timeoutId = global.setTimeout(this.dehydrateDevice.bind(this), oneweek); + return deviceId; + } finally { + this.inProgress = false; + } + } + stop() { + if (this.timeoutId) { + global.clearTimeout(this.timeoutId); + this.timeoutId = undefined; + } + } +} +exports.DehydrationManager = DehydrationManager;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/device-converter.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/device-converter.js new file mode 100644 index 0000000000..9a14d49d66 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/device-converter.js @@ -0,0 +1,47 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.deviceInfoToDevice = deviceInfoToDevice; +var _device = require("../models/device"); +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Convert a {@link DeviceInfo} to a {@link Device}. + * @param deviceInfo - deviceInfo to convert + * @param userId - id of the user that owns the device. + */ +function deviceInfoToDevice(deviceInfo, userId) { + const keys = new Map(Object.entries(deviceInfo.keys)); + const displayName = deviceInfo.getDisplayName() || undefined; + const signatures = new Map(); + if (deviceInfo.signatures) { + for (const userId in deviceInfo.signatures) { + signatures.set(userId, new Map(Object.entries(deviceInfo.signatures[userId]))); + } + } + return new _device.Device({ + deviceId: deviceInfo.deviceId, + userId: userId, + keys, + algorithms: deviceInfo.algorithms, + verified: deviceInfo.verified, + signatures, + displayName + }); +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/deviceinfo.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/deviceinfo.js new file mode 100644 index 0000000000..7dc2035303 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/deviceinfo.js @@ -0,0 +1,152 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.DeviceInfo = void 0; +var _device = require("../models/device"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2016 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/** + * Information about a user's device + */ +class DeviceInfo { + /** + * rehydrate a DeviceInfo from the session store + * + * @param obj - raw object from session store + * @param deviceId - id of the device + * + * @returns new DeviceInfo + */ + static fromStorage(obj, deviceId) { + const res = new DeviceInfo(deviceId); + for (const prop in obj) { + if (obj.hasOwnProperty(prop)) { + // @ts-ignore - this is messy and typescript doesn't like it + res[prop] = obj[prop]; + } + } + return res; + } + /** + * @param deviceId - id of the device + */ + constructor(deviceId) { + this.deviceId = deviceId; + /** list of algorithms supported by this device */ + _defineProperty(this, "algorithms", []); + /** a map from `<key type>:<id> -> <base64-encoded key>` */ + _defineProperty(this, "keys", {}); + /** whether the device has been verified/blocked by the user */ + _defineProperty(this, "verified", _device.DeviceVerification.Unverified); + /** + * whether the user knows of this device's existence + * (useful when warning the user that a user has added new devices) + */ + _defineProperty(this, "known", false); + /** additional data from the homeserver */ + _defineProperty(this, "unsigned", {}); + _defineProperty(this, "signatures", {}); + } + + /** + * Prepare a DeviceInfo for JSON serialisation in the session store + * + * @returns deviceinfo with non-serialised members removed + */ + toStorage() { + return { + algorithms: this.algorithms, + keys: this.keys, + verified: this.verified, + known: this.known, + unsigned: this.unsigned, + signatures: this.signatures + }; + } + + /** + * Get the fingerprint for this device (ie, the Ed25519 key) + * + * @returns base64-encoded fingerprint of this device + */ + getFingerprint() { + return this.keys["ed25519:" + this.deviceId]; + } + + /** + * Get the identity key for this device (ie, the Curve25519 key) + * + * @returns base64-encoded identity key of this device + */ + getIdentityKey() { + return this.keys["curve25519:" + this.deviceId]; + } + + /** + * Get the configured display name for this device, if any + * + * @returns displayname + */ + getDisplayName() { + return this.unsigned.device_display_name || null; + } + + /** + * Returns true if this device is blocked + * + * @returns true if blocked + */ + isBlocked() { + return this.verified == _device.DeviceVerification.Blocked; + } + + /** + * Returns true if this device is verified + * + * @returns true if verified + */ + isVerified() { + return this.verified == _device.DeviceVerification.Verified; + } + + /** + * Returns true if this device is unverified + * + * @returns true if unverified + */ + isUnverified() { + return this.verified == _device.DeviceVerification.Unverified; + } + + /** + * Returns true if the user knows about this device's existence + * + * @returns true if known + */ + isKnown() { + return this.known === true; + } +} +exports.DeviceInfo = DeviceInfo; +_defineProperty(DeviceInfo, "DeviceVerification", { + VERIFIED: _device.DeviceVerification.Verified, + UNVERIFIED: _device.DeviceVerification.Unverified, + BLOCKED: _device.DeviceVerification.Blocked +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/index.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/index.js new file mode 100644 index 0000000000..7d1a5a202c --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/index.js @@ -0,0 +1,3427 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.IncomingRoomKeyRequest = exports.CryptoEvent = exports.Crypto = void 0; +exports.fixBackupKey = fixBackupKey; +exports.isCryptoAvailable = isCryptoAvailable; +exports.verificationMethods = void 0; +var _anotherJson = _interopRequireDefault(require("another-json")); +var _uuid = require("uuid"); +var _event = require("../@types/event"); +var _ReEmitter = require("../ReEmitter"); +var _logger = require("../logger"); +var _OlmDevice = require("./OlmDevice"); +var olmlib = _interopRequireWildcard(require("./olmlib")); +var _DeviceList = require("./DeviceList"); +var _deviceinfo = require("./deviceinfo"); +var algorithms = _interopRequireWildcard(require("./algorithms")); +var _CrossSigning = require("./CrossSigning"); +var _EncryptionSetup = require("./EncryptionSetup"); +var _SecretStorage = require("./SecretStorage"); +var _api = require("./api"); +var _OutgoingRoomKeyRequestManager = require("./OutgoingRoomKeyRequestManager"); +var _indexeddbCryptoStore = require("./store/indexeddb-crypto-store"); +var _QRCode = require("./verification/QRCode"); +var _SAS = require("./verification/SAS"); +var _key_passphrase = require("./key_passphrase"); +var _recoverykey = require("./recoverykey"); +var _VerificationRequest = require("./verification/request/VerificationRequest"); +var _InRoomChannel = require("./verification/request/InRoomChannel"); +var _ToDeviceChannel = require("./verification/request/ToDeviceChannel"); +var _IllegalMethod = require("./verification/IllegalMethod"); +var _errors = require("../errors"); +var _aes = require("./aes"); +var _dehydration = require("./dehydration"); +var _backup = require("./backup"); +var _room = require("../models/room"); +var _roomMember = require("../models/room-member"); +var _event2 = require("../models/event"); +var _client = require("../client"); +var _typedEventEmitter = require("../models/typed-event-emitter"); +var _roomState = require("../models/room-state"); +var _utils = require("../utils"); +var _secretStorage = require("../secret-storage"); +var _deviceConverter = require("./device-converter"); +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2016 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018-2019 New Vector Ltd + Copyright 2019-2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/* re-exports for backwards compatibility */ + +const DeviceVerification = _deviceinfo.DeviceInfo.DeviceVerification; +const defaultVerificationMethods = { + [_QRCode.ReciprocateQRCode.NAME]: _QRCode.ReciprocateQRCode, + [_SAS.SAS.NAME]: _SAS.SAS, + // These two can't be used for actual verification, but we do + // need to be able to define them here for the verification flows + // to start. + [_QRCode.SHOW_QR_CODE_METHOD]: _IllegalMethod.IllegalMethod, + [_QRCode.SCAN_QR_CODE_METHOD]: _IllegalMethod.IllegalMethod +}; + +/** + * verification method names + */ +// legacy export identifier +const verificationMethods = { + RECIPROCATE_QR_CODE: _QRCode.ReciprocateQRCode.NAME, + SAS: _SAS.SAS.NAME +}; +exports.verificationMethods = verificationMethods; +function isCryptoAvailable() { + return Boolean(global.Olm); +} +const MIN_FORCE_SESSION_INTERVAL_MS = 60 * 60 * 1000; + +/* eslint-disable camelcase */ + +/** + * The parameters of a room key request. The details of the request may + * vary with the crypto algorithm, but the management and storage layers for + * outgoing requests expect it to have 'room_id' and 'session_id' properties. + */ + +/* eslint-enable camelcase */ + +/* eslint-disable camelcase */ + +/* eslint-enable camelcase */ +let CryptoEvent = /*#__PURE__*/function (CryptoEvent) { + CryptoEvent["DeviceVerificationChanged"] = "deviceVerificationChanged"; + CryptoEvent["UserTrustStatusChanged"] = "userTrustStatusChanged"; + CryptoEvent["UserCrossSigningUpdated"] = "userCrossSigningUpdated"; + CryptoEvent["RoomKeyRequest"] = "crypto.roomKeyRequest"; + CryptoEvent["RoomKeyRequestCancellation"] = "crypto.roomKeyRequestCancellation"; + CryptoEvent["KeyBackupStatus"] = "crypto.keyBackupStatus"; + CryptoEvent["KeyBackupFailed"] = "crypto.keyBackupFailed"; + CryptoEvent["KeyBackupSessionsRemaining"] = "crypto.keyBackupSessionsRemaining"; + CryptoEvent["KeySignatureUploadFailure"] = "crypto.keySignatureUploadFailure"; + CryptoEvent["VerificationRequest"] = "crypto.verification.request"; + CryptoEvent["Warning"] = "crypto.warning"; + CryptoEvent["WillUpdateDevices"] = "crypto.willUpdateDevices"; + CryptoEvent["DevicesUpdated"] = "crypto.devicesUpdated"; + CryptoEvent["KeysChanged"] = "crossSigning.keysChanged"; + return CryptoEvent; +}({}); +exports.CryptoEvent = CryptoEvent; +class Crypto extends _typedEventEmitter.TypedEventEmitter { + /** + * @returns The version of Olm. + */ + static getOlmVersion() { + return _OlmDevice.OlmDevice.getOlmVersion(); + } + /** + * Cryptography bits + * + * This module is internal to the js-sdk; the public API is via MatrixClient. + * + * @internal + * + * @param baseApis - base matrix api interface + * + * @param userId - The user ID for the local user + * + * @param deviceId - The identifier for this device. + * + * @param clientStore - the MatrixClient data store. + * + * @param cryptoStore - storage for the crypto layer. + * + * @param roomList - An initialised RoomList object + * + * @param verificationMethods - Array of verification methods to use. + * Each element can either be a string from MatrixClient.verificationMethods + * or a class that implements a verification method. + */ + constructor(baseApis, userId, deviceId, clientStore, cryptoStore, roomList, verificationMethods) { + super(); + this.baseApis = baseApis; + this.userId = userId; + this.deviceId = deviceId; + this.clientStore = clientStore; + this.cryptoStore = cryptoStore; + this.roomList = roomList; + _defineProperty(this, "backupManager", void 0); + _defineProperty(this, "crossSigningInfo", void 0); + _defineProperty(this, "olmDevice", void 0); + _defineProperty(this, "deviceList", void 0); + _defineProperty(this, "dehydrationManager", void 0); + _defineProperty(this, "secretStorage", void 0); + _defineProperty(this, "reEmitter", void 0); + _defineProperty(this, "verificationMethods", void 0); + _defineProperty(this, "supportedAlgorithms", void 0); + _defineProperty(this, "outgoingRoomKeyRequestManager", void 0); + _defineProperty(this, "toDeviceVerificationRequests", void 0); + _defineProperty(this, "inRoomVerificationRequests", void 0); + _defineProperty(this, "trustCrossSignedDevices", true); + // the last time we did a check for the number of one-time-keys on the server. + _defineProperty(this, "lastOneTimeKeyCheck", null); + _defineProperty(this, "oneTimeKeyCheckInProgress", false); + // EncryptionAlgorithm instance for each room + _defineProperty(this, "roomEncryptors", new Map()); + // map from algorithm to DecryptionAlgorithm instance, for each room + _defineProperty(this, "roomDecryptors", new Map()); + _defineProperty(this, "deviceKeys", {}); + // type: key + _defineProperty(this, "globalBlacklistUnverifiedDevices", false); + _defineProperty(this, "globalErrorOnUnknownDevices", true); + // list of IncomingRoomKeyRequests/IncomingRoomKeyRequestCancellations + // we received in the current sync. + _defineProperty(this, "receivedRoomKeyRequests", []); + _defineProperty(this, "receivedRoomKeyRequestCancellations", []); + // true if we are currently processing received room key requests + _defineProperty(this, "processingRoomKeyRequests", false); + // controls whether device tracking is delayed + // until calling encryptEvent or trackRoomDevices, + // or done immediately upon enabling room encryption. + _defineProperty(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. + _defineProperty(this, "roomDeviceTrackingState", {}); + // The timestamp of the last time we forced establishment + // of a new session for each device, in milliseconds. + // { + // userId: { + // deviceId: 1234567890000, + // }, + // } + // Map: user Id → device Id → timestamp + _defineProperty(this, "lastNewSessionForced", new _utils.MapWithDefault(() => new _utils.MapWithDefault(() => 0))); + // This flag will be unset whilst the client processes a sync response + // so that we don't start requesting keys until we've actually finished + // processing the response. + _defineProperty(this, "sendKeyRequestsImmediately", false); + _defineProperty(this, "oneTimeKeyCount", void 0); + _defineProperty(this, "needsNewFallback", void 0); + _defineProperty(this, "fallbackCleanup", void 0); + /* + * Event handler for DeviceList's userNewDevices event + */ + _defineProperty(this, "onDeviceListUserCrossSigningUpdated", async userId => { + if (userId === this.userId) { + // An update to our own cross-signing key. + // Get the new key first: + const newCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId); + const seenPubkey = newCrossSigning ? newCrossSigning.getId() : null; + const currentPubkey = this.crossSigningInfo.getId(); + const changed = currentPubkey !== seenPubkey; + if (currentPubkey && seenPubkey && !changed) { + // If it's not changed, just make sure everything is up to date + await this.checkOwnCrossSigningTrust(); + } else { + // We'll now be in a state where cross-signing on the account is not trusted + // because our locally stored cross-signing keys will not match the ones + // on the server for our account. So we clear our own stored cross-signing keys, + // effectively disabling cross-signing until the user gets verified by the device + // that reset the keys + this.storeTrustedSelfKeys(null); + // emit cross-signing has been disabled + this.emit(CryptoEvent.KeysChanged, {}); + // as the trust for our own user has changed, + // also emit an event for this + this.emit(CryptoEvent.UserTrustStatusChanged, this.userId, this.checkUserTrust(userId)); + } + } else { + await this.checkDeviceVerifications(userId); + + // Update verified before latch using the current state and save the new + // latch value in the device list store. + const crossSigning = this.deviceList.getStoredCrossSigningForUser(userId); + if (crossSigning) { + crossSigning.updateCrossSigningVerifiedBefore(this.checkUserTrust(userId).isCrossSigningVerified()); + this.deviceList.setRawStoredCrossSigningForUser(userId, crossSigning.toStorage()); + } + this.emit(CryptoEvent.UserTrustStatusChanged, userId, this.checkUserTrust(userId)); + } + }); + _defineProperty(this, "onMembership", (event, member, oldMembership) => { + try { + this.onRoomMembership(event, member, oldMembership); + } catch (e) { + _logger.logger.error("Error handling membership change:", e); + } + }); + _defineProperty(this, "onToDeviceEvent", event => { + try { + _logger.logger.log(`received to-device ${event.getType()} from: ` + `${event.getSender()} id: ${event.getContent()[_event.ToDeviceMessageId]}`); + if (event.getType() == "m.room_key" || event.getType() == "m.forwarded_room_key") { + this.onRoomKeyEvent(event); + } else if (event.getType() == "m.room_key_request") { + this.onRoomKeyRequestEvent(event); + } else if (event.getType() === "m.secret.request") { + this.secretStorage.onRequestReceived(event); + } else if (event.getType() === "m.secret.send") { + this.secretStorage.onSecretReceived(event); + } else if (event.getType() === "m.room_key.withheld") { + this.onRoomKeyWithheldEvent(event); + } else if (event.getContent().transaction_id) { + this.onKeyVerificationMessage(event); + } else if (event.getContent().msgtype === "m.bad.encrypted") { + this.onToDeviceBadEncrypted(event); + } else if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) { + if (!event.isBeingDecrypted()) { + event.attemptDecryption(this); + } + // once the event has been decrypted, try again + event.once(_event2.MatrixEventEvent.Decrypted, ev => { + this.onToDeviceEvent(ev); + }); + } + } catch (e) { + _logger.logger.error("Error handling toDeviceEvent:", e); + } + }); + /** + * Handle key verification requests sent as timeline events + * + * @internal + * @param event - the timeline event + * @param room - not used + * @param atStart - not used + * @param removed - not used + * @param whether - this is a live event + */ + _defineProperty(this, "onTimelineEvent", (event, room, atStart, removed, { + liveEvent = true + } = {}) => { + if (!_InRoomChannel.InRoomChannel.validateEvent(event, this.baseApis)) { + return; + } + const createRequest = event => { + const channel = new _InRoomChannel.InRoomChannel(this.baseApis, event.getRoomId()); + return new _VerificationRequest.VerificationRequest(channel, this.verificationMethods, this.baseApis); + }; + this.handleVerificationEvent(event, this.inRoomVerificationRequests, createRequest, liveEvent); + }); + this.reEmitter = new _ReEmitter.TypedReEmitter(this); + if (verificationMethods) { + this.verificationMethods = new Map(); + for (const method of verificationMethods) { + if (typeof method === "string") { + if (defaultVerificationMethods[method]) { + this.verificationMethods.set(method, defaultVerificationMethods[method]); + } + } else if (method["NAME"]) { + this.verificationMethods.set(method["NAME"], method); + } else { + _logger.logger.warn(`Excluding unknown verification method ${method}`); + } + } + } else { + this.verificationMethods = new Map(Object.entries(defaultVerificationMethods)); + } + this.backupManager = new _backup.BackupManager(baseApis, async () => { + // try to get key from cache + const cachedKey = await this.getSessionBackupPrivateKey(); + if (cachedKey) { + return cachedKey; + } + + // try to get key from secret storage + const storedKey = await this.secretStorage.get("m.megolm_backup.v1"); + if (storedKey) { + // ensure that the key is in the right format. If not, fix the key and + // store the fixed version + const fixedKey = fixBackupKey(storedKey); + if (fixedKey) { + const keys = await this.secretStorage.getKey(); + await this.secretStorage.store("m.megolm_backup.v1", fixedKey, [keys[0]]); + } + return olmlib.decodeBase64(fixedKey || storedKey); + } + + // try to get key from app + if (this.baseApis.cryptoCallbacks && this.baseApis.cryptoCallbacks.getBackupKey) { + return this.baseApis.cryptoCallbacks.getBackupKey(); + } + throw new Error("Unable to get private key"); + }); + this.olmDevice = new _OlmDevice.OlmDevice(cryptoStore); + this.deviceList = new _DeviceList.DeviceList(baseApis, cryptoStore, this.olmDevice); + + // XXX: This isn't removed at any point, but then none of the event listeners + // this class sets seem to be removed at any point... :/ + this.deviceList.on(CryptoEvent.UserCrossSigningUpdated, this.onDeviceListUserCrossSigningUpdated); + this.reEmitter.reEmit(this.deviceList, [CryptoEvent.DevicesUpdated, CryptoEvent.WillUpdateDevices]); + this.supportedAlgorithms = Array.from(algorithms.DECRYPTION_CLASSES.keys()); + this.outgoingRoomKeyRequestManager = new _OutgoingRoomKeyRequestManager.OutgoingRoomKeyRequestManager(baseApis, this.deviceId, this.cryptoStore); + this.toDeviceVerificationRequests = new _ToDeviceChannel.ToDeviceRequests(); + this.inRoomVerificationRequests = new _InRoomChannel.InRoomRequests(); + const cryptoCallbacks = this.baseApis.cryptoCallbacks || {}; + const cacheCallbacks = (0, _CrossSigning.createCryptoStoreCacheCallbacks)(cryptoStore, this.olmDevice); + this.crossSigningInfo = new _CrossSigning.CrossSigningInfo(userId, cryptoCallbacks, cacheCallbacks); + // Yes, we pass the client twice here: see SecretStorage + this.secretStorage = new _SecretStorage.SecretStorage(baseApis, cryptoCallbacks, baseApis); + this.dehydrationManager = new _dehydration.DehydrationManager(this); + + // Assuming no app-supplied callback, default to getting from SSSS. + if (!cryptoCallbacks.getCrossSigningKey && cryptoCallbacks.getSecretStorageKey) { + cryptoCallbacks.getCrossSigningKey = async type => { + return _CrossSigning.CrossSigningInfo.getFromSecretStorage(type, this.secretStorage); + }; + } + } + + /** + * Initialise the crypto module so that it is ready for use + * + * Returns a promise which resolves once the crypto module is ready for use. + * + * @param exportedOlmDevice - (Optional) data from exported device + * that must be re-created. + */ + async init({ + exportedOlmDevice, + pickleKey + } = {}) { + _logger.logger.log("Crypto: initialising Olm..."); + await global.Olm.init(); + _logger.logger.log(exportedOlmDevice ? "Crypto: initialising Olm device from exported device..." : "Crypto: initialising Olm device..."); + await this.olmDevice.init({ + fromExportedDevice: exportedOlmDevice, + pickleKey + }); + _logger.logger.log("Crypto: loading device list..."); + await this.deviceList.load(); + + // build our device keys: these will later be uploaded + this.deviceKeys["ed25519:" + this.deviceId] = this.olmDevice.deviceEd25519Key; + this.deviceKeys["curve25519:" + this.deviceId] = this.olmDevice.deviceCurve25519Key; + _logger.logger.log("Crypto: fetching own devices..."); + let myDevices = this.deviceList.getRawStoredDevicesForUser(this.userId); + if (!myDevices) { + myDevices = {}; + } + if (!myDevices[this.deviceId]) { + // add our own deviceinfo to the cryptoStore + _logger.logger.log("Crypto: adding this device to the store..."); + const deviceInfo = { + keys: this.deviceKeys, + algorithms: this.supportedAlgorithms, + verified: DeviceVerification.VERIFIED, + known: true + }; + myDevices[this.deviceId] = deviceInfo; + this.deviceList.storeDevicesForUser(this.userId, myDevices); + this.deviceList.saveIfDirty(); + } + await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => { + this.cryptoStore.getCrossSigningKeys(txn, keys => { + // can be an empty object after resetting cross-signing keys, see storeTrustedSelfKeys + if (keys && Object.keys(keys).length !== 0) { + _logger.logger.log("Loaded cross-signing public keys from crypto store"); + this.crossSigningInfo.setKeys(keys); + } + }); + }); + // make sure we are keeping track of our own devices + // (this is important for key backups & things) + this.deviceList.startTrackingDeviceList(this.userId); + _logger.logger.log("Crypto: checking for key backup..."); + this.backupManager.checkAndStart(); + } + + /** + * Whether to trust a others users signatures of their devices. + * If false, devices will only be considered 'verified' if we have + * verified that device individually (effectively disabling cross-signing). + * + * Default: true + * + * @returns True if trusting cross-signed devices + */ + getTrustCrossSignedDevices() { + return this.trustCrossSignedDevices; + } + + /** + * @deprecated Use {@link Crypto.CryptoApi#getTrustCrossSignedDevices}. + */ + getCryptoTrustCrossSignedDevices() { + return this.trustCrossSignedDevices; + } + + /** + * See getCryptoTrustCrossSignedDevices + * + * @param val - True to trust cross-signed devices + */ + setTrustCrossSignedDevices(val) { + this.trustCrossSignedDevices = val; + for (const userId of this.deviceList.getKnownUserIds()) { + const devices = this.deviceList.getRawStoredDevicesForUser(userId); + for (const deviceId of Object.keys(devices)) { + const deviceTrust = this.checkDeviceTrust(userId, deviceId); + // If the device is locally verified then isVerified() is always true, + // so this will only have caused the value to change if the device is + // cross-signing verified but not locally verified + if (!deviceTrust.isLocallyVerified() && deviceTrust.isCrossSigningVerified()) { + const deviceObj = this.deviceList.getStoredDevice(userId, deviceId); + this.emit(CryptoEvent.DeviceVerificationChanged, userId, deviceId, deviceObj); + } + } + } + } + + /** + * @deprecated Use {@link Crypto.CryptoApi#setTrustCrossSignedDevices}. + */ + setCryptoTrustCrossSignedDevices(val) { + this.setTrustCrossSignedDevices(val); + } + + /** + * Create a recovery key from a user-supplied passphrase. + * + * @param password - Passphrase string that can be entered by the user + * when restoring the backup as an alternative to entering the recovery key. + * Optional. + * @returns Object with public key metadata, encoded private + * recovery key which should be disposed of after displaying to the user, + * and raw private key to avoid round tripping if needed. + */ + async createRecoveryKeyFromPassphrase(password) { + const decryption = new global.Olm.PkDecryption(); + try { + const keyInfo = {}; + if (password) { + const derivation = await (0, _key_passphrase.keyFromPassphrase)(password); + keyInfo.passphrase = { + algorithm: "m.pbkdf2", + iterations: derivation.iterations, + salt: derivation.salt + }; + keyInfo.pubkey = decryption.init_with_private_key(derivation.key); + } else { + keyInfo.pubkey = decryption.generate_key(); + } + const privateKey = decryption.get_private_key(); + const encodedPrivateKey = (0, _recoverykey.encodeRecoveryKey)(privateKey); + return { + keyInfo: keyInfo, + encodedPrivateKey, + privateKey + }; + } finally { + decryption?.free(); + } + } + + /** + * Checks if the user has previously published cross-signing keys + * + * This means downloading the devicelist for the user and checking if the list includes + * the cross-signing pseudo-device. + * + * @internal + */ + async userHasCrossSigningKeys() { + await this.downloadKeys([this.userId]); + return this.deviceList.getStoredCrossSigningForUser(this.userId) !== null; + } + + /** + * Checks whether cross signing: + * - is enabled on this account and trusted by this device + * - has private keys either cached locally or stored in secret storage + * + * If this function returns false, bootstrapCrossSigning() can be used + * to fix things such that it returns true. That is to say, after + * bootstrapCrossSigning() completes successfully, this function should + * return true. + * + * The cross-signing API is currently UNSTABLE and may change without notice. + * + * @returns True if cross-signing is ready to be used on this device + */ + async isCrossSigningReady() { + const publicKeysOnDevice = this.crossSigningInfo.getId(); + const privateKeysExistSomewhere = (await this.crossSigningInfo.isStoredInKeyCache()) || (await this.crossSigningInfo.isStoredInSecretStorage(this.secretStorage)); + return !!(publicKeysOnDevice && privateKeysExistSomewhere); + } + + /** + * Checks whether secret storage: + * - is enabled on this account + * - is storing cross-signing private keys + * - is storing session backup key (if enabled) + * + * If this function returns false, bootstrapSecretStorage() can be used + * to fix things such that it returns true. That is to say, after + * bootstrapSecretStorage() completes successfully, this function should + * return true. + * + * The Secure Secret Storage API is currently UNSTABLE and may change without notice. + * + * @returns True if secret storage is ready to be used on this device + */ + async isSecretStorageReady() { + const secretStorageKeyInAccount = await this.secretStorage.hasKey(); + const privateKeysInStorage = await this.crossSigningInfo.isStoredInSecretStorage(this.secretStorage); + const sessionBackupInStorage = !this.backupManager.getKeyBackupEnabled() || (await this.baseApis.isKeyBackupKeyStored()); + return !!(secretStorageKeyInAccount && privateKeysInStorage && sessionBackupInStorage); + } + + /** + * Bootstrap cross-signing by creating keys if needed. If everything is already + * set up, then no changes are made, so this is safe to run to ensure + * cross-signing is ready for use. + * + * This function: + * - creates new cross-signing keys if they are not found locally cached nor in + * secret storage (if it has been setup) + * + * The cross-signing API is currently UNSTABLE and may change without notice. + * + * @param authUploadDeviceSigningKeys - Function + * called to await an interactive auth flow when uploading device signing keys. + * @param setupNewCrossSigning - Optional. Reset even if keys + * already exist. + * Args: + * A function that makes the request requiring auth. Receives the + * auth data as an object. Can be called multiple times, first with an empty + * authDict, to obtain the flows. + */ + async bootstrapCrossSigning({ + authUploadDeviceSigningKeys, + setupNewCrossSigning + } = {}) { + _logger.logger.log("Bootstrapping cross-signing"); + const delegateCryptoCallbacks = this.baseApis.cryptoCallbacks; + const builder = new _EncryptionSetup.EncryptionSetupBuilder(this.baseApis.store.accountData, delegateCryptoCallbacks); + const crossSigningInfo = new _CrossSigning.CrossSigningInfo(this.userId, builder.crossSigningCallbacks, builder.crossSigningCallbacks); + + // Reset the cross-signing keys + const resetCrossSigning = async () => { + crossSigningInfo.resetKeys(); + // Sign master key with device key + await this.signObject(crossSigningInfo.keys.master); + + // Store auth flow helper function, as we need to call it when uploading + // to ensure we handle auth errors properly. + builder.addCrossSigningKeys(authUploadDeviceSigningKeys, crossSigningInfo.keys); + + // Cross-sign own device + const device = this.deviceList.getStoredDevice(this.userId, this.deviceId); + const deviceSignature = await crossSigningInfo.signDevice(this.userId, device); + builder.addKeySignature(this.userId, this.deviceId, deviceSignature); + + // Sign message key backup with cross-signing master key + if (this.backupManager.backupInfo) { + await crossSigningInfo.signObject(this.backupManager.backupInfo.auth_data, "master"); + builder.addSessionBackup(this.backupManager.backupInfo); + } + }; + const publicKeysOnDevice = this.crossSigningInfo.getId(); + const privateKeysInCache = await this.crossSigningInfo.isStoredInKeyCache(); + const privateKeysInStorage = await this.crossSigningInfo.isStoredInSecretStorage(this.secretStorage); + const privateKeysExistSomewhere = privateKeysInCache || privateKeysInStorage; + + // Log all relevant state for easier parsing of debug logs. + _logger.logger.log({ + setupNewCrossSigning, + publicKeysOnDevice, + privateKeysInCache, + privateKeysInStorage, + privateKeysExistSomewhere + }); + if (!privateKeysExistSomewhere || setupNewCrossSigning) { + _logger.logger.log("Cross-signing private keys not found locally or in secret storage, " + "creating new keys"); + // If a user has multiple devices, it important to only call bootstrap + // as part of some UI flow (and not silently during startup), as they + // may have setup cross-signing on a platform which has not saved keys + // to secret storage, and this would reset them. In such a case, you + // should prompt the user to verify any existing devices first (and + // request private keys from those devices) before calling bootstrap. + await resetCrossSigning(); + } else if (publicKeysOnDevice && privateKeysInCache) { + _logger.logger.log("Cross-signing public keys trusted and private keys found locally"); + } else if (privateKeysInStorage) { + _logger.logger.log("Cross-signing private keys not found locally, but they are available " + "in secret storage, reading storage and caching locally"); + await this.checkOwnCrossSigningTrust({ + allowPrivateKeyRequests: true + }); + } + + // Assuming no app-supplied callback, default to storing new private keys in + // secret storage if it exists. If it does not, it is assumed this will be + // done as part of setting up secret storage later. + const crossSigningPrivateKeys = builder.crossSigningCallbacks.privateKeys; + if (crossSigningPrivateKeys.size && !this.baseApis.cryptoCallbacks.saveCrossSigningKeys) { + const secretStorage = new _secretStorage.ServerSideSecretStorageImpl(builder.accountDataClientAdapter, builder.ssssCryptoCallbacks); + if (await secretStorage.hasKey()) { + _logger.logger.log("Storing new cross-signing private keys in secret storage"); + // This is writing to in-memory account data in + // builder.accountDataClientAdapter so won't fail + await _CrossSigning.CrossSigningInfo.storeInSecretStorage(crossSigningPrivateKeys, secretStorage); + } + } + const operation = builder.buildOperation(); + await operation.apply(this); + // This persists private keys and public keys as trusted, + // only do this if apply succeeded for now as retry isn't in place yet + await builder.persist(this); + _logger.logger.log("Cross-signing ready"); + } + + /** + * Bootstrap Secure Secret Storage if needed by creating a default key. If everything is + * already set up, then no changes are made, so this is safe to run to ensure secret + * storage is ready for use. + * + * This function + * - creates a new Secure Secret Storage key if no default key exists + * - if a key backup exists, it is migrated to store the key in the Secret + * Storage + * - creates a backup if none exists, and one is requested + * - migrates Secure Secret Storage to use the latest algorithm, if an outdated + * algorithm is found + * + * The Secure Secret Storage API is currently UNSTABLE and may change without notice. + * + * @param createSecretStorageKey - Optional. Function + * called to await a secret storage key creation flow. + * Returns a Promise which resolves to an object with public key metadata, encoded private + * recovery key which should be disposed of after displaying to the user, + * and raw private key to avoid round tripping if needed. + * @param keyBackupInfo - The current key backup object. If passed, + * the passphrase and recovery key from this backup will be used. + * @param setupNewKeyBackup - If true, a new key backup version will be + * created and the private key stored in the new SSSS store. Ignored if keyBackupInfo + * is supplied. + * @param setupNewSecretStorage - Optional. Reset even if keys already exist. + * @param getKeyBackupPassphrase - Optional. Function called to get the user's + * current key backup passphrase. Should return a promise that resolves with a Buffer + * containing the key, or rejects if the key cannot be obtained. + * Returns: + * A promise which resolves to key creation data for + * SecretStorage#addKey: an object with `passphrase` etc fields. + */ + // TODO this does not resolve with what it says it does + async bootstrapSecretStorage({ + createSecretStorageKey = async () => ({}), + keyBackupInfo, + setupNewKeyBackup, + setupNewSecretStorage, + getKeyBackupPassphrase + } = {}) { + _logger.logger.log("Bootstrapping Secure Secret Storage"); + const delegateCryptoCallbacks = this.baseApis.cryptoCallbacks; + const builder = new _EncryptionSetup.EncryptionSetupBuilder(this.baseApis.store.accountData, delegateCryptoCallbacks); + const secretStorage = new _secretStorage.ServerSideSecretStorageImpl(builder.accountDataClientAdapter, builder.ssssCryptoCallbacks); + + // the ID of the new SSSS key, if we create one + let newKeyId = null; + + // create a new SSSS key and set it as default + const createSSSS = async (opts, privateKey) => { + if (privateKey) { + opts.key = privateKey; + } + const { + keyId, + keyInfo + } = await secretStorage.addKey(_secretStorage.SECRET_STORAGE_ALGORITHM_V1_AES, opts); + if (privateKey) { + // make the private key available to encrypt 4S secrets + builder.ssssCryptoCallbacks.addPrivateKey(keyId, keyInfo, privateKey); + } + await secretStorage.setDefaultKeyId(keyId); + return keyId; + }; + const ensureCanCheckPassphrase = async (keyId, keyInfo) => { + if (!keyInfo.mac) { + const key = await this.baseApis.cryptoCallbacks.getSecretStorageKey?.({ + keys: { + [keyId]: keyInfo + } + }, ""); + if (key) { + const privateKey = key[1]; + builder.ssssCryptoCallbacks.addPrivateKey(keyId, keyInfo, privateKey); + const { + iv, + mac + } = await (0, _aes.calculateKeyCheck)(privateKey); + keyInfo.iv = iv; + keyInfo.mac = mac; + await builder.setAccountData(`m.secret_storage.key.${keyId}`, keyInfo); + } + } + }; + const signKeyBackupWithCrossSigning = async keyBackupAuthData => { + if (this.crossSigningInfo.getId() && (await this.crossSigningInfo.isStoredInKeyCache("master"))) { + try { + _logger.logger.log("Adding cross-signing signature to key backup"); + await this.crossSigningInfo.signObject(keyBackupAuthData, "master"); + } catch (e) { + // This step is not critical (just helpful), so we catch here + // and continue if it fails. + _logger.logger.error("Signing key backup with cross-signing keys failed", e); + } + } else { + _logger.logger.warn("Cross-signing keys not available, skipping signature on key backup"); + } + }; + const oldSSSSKey = await this.secretStorage.getKey(); + const [oldKeyId, oldKeyInfo] = oldSSSSKey || [null, null]; + const storageExists = !setupNewSecretStorage && oldKeyInfo && oldKeyInfo.algorithm === _secretStorage.SECRET_STORAGE_ALGORITHM_V1_AES; + + // Log all relevant state for easier parsing of debug logs. + _logger.logger.log({ + keyBackupInfo, + setupNewKeyBackup, + setupNewSecretStorage, + storageExists, + oldKeyInfo + }); + if (!storageExists && !keyBackupInfo) { + // either we don't have anything, or we've been asked to restart + // from scratch + _logger.logger.log("Secret storage does not exist, creating new storage key"); + + // if we already have a usable default SSSS key and aren't resetting + // SSSS just use it. otherwise, create a new one + // Note: we leave the old SSSS key in place: there could be other + // secrets using it, in theory. We could move them to the new key but a) + // that would mean we'd need to prompt for the old passphrase, and b) + // it's not clear that would be the right thing to do anyway. + const { + keyInfo = {}, + privateKey + } = await createSecretStorageKey(); + newKeyId = await createSSSS(keyInfo, privateKey); + } else if (!storageExists && keyBackupInfo) { + // we have an existing backup, but no SSSS + _logger.logger.log("Secret storage does not exist, using key backup key"); + + // if we have the backup key already cached, use it; otherwise use the + // callback to prompt for the key + const backupKey = (await this.getSessionBackupPrivateKey()) || (await getKeyBackupPassphrase?.()); + + // create a new SSSS key and use the backup key as the new SSSS key + const opts = {}; + if (keyBackupInfo.auth_data.private_key_salt && keyBackupInfo.auth_data.private_key_iterations) { + // FIXME: ??? + opts.passphrase = { + algorithm: "m.pbkdf2", + iterations: keyBackupInfo.auth_data.private_key_iterations, + salt: keyBackupInfo.auth_data.private_key_salt, + bits: 256 + }; + } + newKeyId = await createSSSS(opts, backupKey); + + // store the backup key in secret storage + await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(backupKey), [newKeyId]); + + // The backup is trusted because the user provided the private key. + // Sign the backup with the cross-signing key so the key backup can + // be trusted via cross-signing. + await signKeyBackupWithCrossSigning(keyBackupInfo.auth_data); + builder.addSessionBackup(keyBackupInfo); + } else { + // 4S is already set up + _logger.logger.log("Secret storage exists"); + if (oldKeyInfo && oldKeyInfo.algorithm === _secretStorage.SECRET_STORAGE_ALGORITHM_V1_AES) { + // make sure that the default key has the information needed to + // check the passphrase + await ensureCanCheckPassphrase(oldKeyId, oldKeyInfo); + } + } + + // If we have cross-signing private keys cached, store them in secret + // storage if they are not there already. + if (!this.baseApis.cryptoCallbacks.saveCrossSigningKeys && (await this.isCrossSigningReady()) && (newKeyId || !(await this.crossSigningInfo.isStoredInSecretStorage(secretStorage)))) { + _logger.logger.log("Copying cross-signing private keys from cache to secret storage"); + const crossSigningPrivateKeys = await this.crossSigningInfo.getCrossSigningKeysFromCache(); + // This is writing to in-memory account data in + // builder.accountDataClientAdapter so won't fail + await _CrossSigning.CrossSigningInfo.storeInSecretStorage(crossSigningPrivateKeys, secretStorage); + } + if (setupNewKeyBackup && !keyBackupInfo) { + _logger.logger.log("Creating new message key backup version"); + const info = await this.baseApis.prepareKeyBackupVersion(null /* random key */, + // don't write to secret storage, as it will write to this.secretStorage. + // Here, we want to capture all the side-effects of bootstrapping, + // and want to write to the local secretStorage object + { + secureSecretStorage: false + }); + // write the key ourselves to 4S + const privateKey = (0, _recoverykey.decodeRecoveryKey)(info.recovery_key); + await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(privateKey)); + + // create keyBackupInfo object to add to builder + const data = { + algorithm: info.algorithm, + auth_data: info.auth_data + }; + + // Sign with cross-signing master key + await signKeyBackupWithCrossSigning(data.auth_data); + + // sign with the device fingerprint + await this.signObject(data.auth_data); + builder.addSessionBackup(data); + } + + // Cache the session backup key + const sessionBackupKey = await secretStorage.get("m.megolm_backup.v1"); + if (sessionBackupKey) { + _logger.logger.info("Got session backup key from secret storage: caching"); + // fix up the backup key if it's in the wrong format, and replace + // in secret storage + const fixedBackupKey = fixBackupKey(sessionBackupKey); + if (fixedBackupKey) { + const keyId = newKeyId || oldKeyId; + await secretStorage.store("m.megolm_backup.v1", fixedBackupKey, keyId ? [keyId] : null); + } + const decodedBackupKey = new Uint8Array(olmlib.decodeBase64(fixedBackupKey || sessionBackupKey)); + builder.addSessionBackupPrivateKeyToCache(decodedBackupKey); + } else if (this.backupManager.getKeyBackupEnabled()) { + // key backup is enabled but we don't have a session backup key in SSSS: see if we have one in + // the cache or the user can provide one, and if so, write it to SSSS + const backupKey = (await this.getSessionBackupPrivateKey()) || (await getKeyBackupPassphrase?.()); + if (!backupKey) { + // This will require user intervention to recover from since we don't have the key + // backup key anywhere. The user should probably just set up a new key backup and + // the key for the new backup will be stored. If we hit this scenario in the wild + // with any frequency, we should do more than just log an error. + _logger.logger.error("Key backup is enabled but couldn't get key backup key!"); + return; + } + _logger.logger.info("Got session backup key from cache/user that wasn't in SSSS: saving to SSSS"); + await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(backupKey)); + } + const operation = builder.buildOperation(); + await operation.apply(this); + // this persists private keys and public keys as trusted, + // only do this if apply succeeded for now as retry isn't in place yet + await builder.persist(this); + _logger.logger.log("Secure Secret Storage ready"); + } + + /** + * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#addKey}. + */ + addSecretStorageKey(algorithm, opts, keyID) { + return this.secretStorage.addKey(algorithm, opts, keyID); + } + + /** + * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#hasKey}. + */ + hasSecretStorageKey(keyID) { + return this.secretStorage.hasKey(keyID); + } + + /** + * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#getKey}. + */ + getSecretStorageKey(keyID) { + return this.secretStorage.getKey(keyID); + } + + /** + * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#store}. + */ + storeSecret(name, secret, keys) { + return this.secretStorage.store(name, secret, keys); + } + + /** + * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#get}. + */ + getSecret(name) { + return this.secretStorage.get(name); + } + + /** + * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#isStored}. + */ + isSecretStored(name) { + return this.secretStorage.isStored(name); + } + requestSecret(name, devices) { + if (!devices) { + devices = Object.keys(this.deviceList.getRawStoredDevicesForUser(this.userId)); + } + return this.secretStorage.request(name, devices); + } + + /** + * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#getDefaultKeyId}. + */ + getDefaultSecretStorageKeyId() { + return this.secretStorage.getDefaultKeyId(); + } + + /** + * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#setDefaultKeyId}. + */ + setDefaultSecretStorageKeyId(k) { + return this.secretStorage.setDefaultKeyId(k); + } + + /** + * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#checkKey}. + */ + checkSecretStorageKey(key, info) { + return this.secretStorage.checkKey(key, info); + } + + /** + * Checks that a given secret storage private key matches a given public key. + * This can be used by the getSecretStorageKey callback to verify that the + * private key it is about to supply is the one that was requested. + * + * @param privateKey - The private key + * @param expectedPublicKey - The public key + * @returns true if the key matches, otherwise false + */ + checkSecretStoragePrivateKey(privateKey, expectedPublicKey) { + let decryption = null; + try { + decryption = new global.Olm.PkDecryption(); + const gotPubkey = decryption.init_with_private_key(privateKey); + // make sure it agrees with the given pubkey + return gotPubkey === expectedPublicKey; + } finally { + decryption?.free(); + } + } + + /** + * Fetches the backup private key, if cached + * @returns the key, if any, or null + */ + async getSessionBackupPrivateKey() { + let key = await new Promise(resolve => { + // TODO types + this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => { + this.cryptoStore.getSecretStorePrivateKey(txn, resolve, "m.megolm_backup.v1"); + }); + }); + + // make sure we have a Uint8Array, rather than a string + if (key && typeof key === "string") { + key = new Uint8Array(olmlib.decodeBase64(fixBackupKey(key) || key)); + await this.storeSessionBackupPrivateKey(key); + } + if (key && key.ciphertext) { + const pickleKey = Buffer.from(this.olmDevice.pickleKey); + const decrypted = await (0, _aes.decryptAES)(key, pickleKey, "m.megolm_backup.v1"); + key = olmlib.decodeBase64(decrypted); + } + return key; + } + + /** + * Stores the session backup key to the cache + * @param key - the private key + * @returns a promise so you can catch failures + */ + async storeSessionBackupPrivateKey(key) { + if (!(key instanceof Uint8Array)) { + // eslint-disable-next-line @typescript-eslint/no-base-to-string + throw new Error(`storeSessionBackupPrivateKey expects Uint8Array, got ${key}`); + } + const pickleKey = Buffer.from(this.olmDevice.pickleKey); + const encryptedKey = await (0, _aes.encryptAES)(olmlib.encodeBase64(key), pickleKey, "m.megolm_backup.v1"); + return this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => { + this.cryptoStore.storeSecretStorePrivateKey(txn, "m.megolm_backup.v1", encryptedKey); + }); + } + + /** + * Checks that a given cross-signing private key matches a given public key. + * This can be used by the getCrossSigningKey callback to verify that the + * private key it is about to supply is the one that was requested. + * + * @param privateKey - The private key + * @param expectedPublicKey - The public key + * @returns true if the key matches, otherwise false + */ + checkCrossSigningPrivateKey(privateKey, expectedPublicKey) { + let signing = null; + try { + signing = new global.Olm.PkSigning(); + const gotPubkey = signing.init_with_seed(privateKey); + // make sure it agrees with the given pubkey + return gotPubkey === expectedPublicKey; + } finally { + signing?.free(); + } + } + + /** + * Run various follow-up actions after cross-signing keys have changed locally + * (either by resetting the keys for the account or by getting them from secret + * storage), such as signing the current device, upgrading device + * verifications, etc. + */ + async afterCrossSigningLocalKeyChange() { + _logger.logger.info("Starting cross-signing key change post-processing"); + + // sign the current device with the new key, and upload to the server + const device = this.deviceList.getStoredDevice(this.userId, this.deviceId); + const signedDevice = await this.crossSigningInfo.signDevice(this.userId, device); + _logger.logger.info(`Starting background key sig upload for ${this.deviceId}`); + const upload = ({ + shouldEmit = false + }) => { + return this.baseApis.uploadKeySignatures({ + [this.userId]: { + [this.deviceId]: signedDevice + } + }).then(response => { + const { + failures + } = response || {}; + if (Object.keys(failures || []).length > 0) { + if (shouldEmit) { + this.baseApis.emit(CryptoEvent.KeySignatureUploadFailure, failures, "afterCrossSigningLocalKeyChange", upload // continuation + ); + } + + throw new _errors.KeySignatureUploadError("Key upload failed", { + failures + }); + } + _logger.logger.info(`Finished background key sig upload for ${this.deviceId}`); + }).catch(e => { + _logger.logger.error(`Error during background key sig upload for ${this.deviceId}`, e); + }); + }; + upload({ + shouldEmit: true + }); + const shouldUpgradeCb = this.baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications; + if (shouldUpgradeCb) { + _logger.logger.info("Starting device verification upgrade"); + + // Check all users for signatures if upgrade callback present + // FIXME: do this in batches + const users = {}; + for (const [userId, crossSigningInfo] of Object.entries(this.deviceList.crossSigningInfo)) { + const upgradeInfo = await this.checkForDeviceVerificationUpgrade(userId, _CrossSigning.CrossSigningInfo.fromStorage(crossSigningInfo, userId)); + if (upgradeInfo) { + users[userId] = upgradeInfo; + } + } + if (Object.keys(users).length > 0) { + _logger.logger.info(`Found ${Object.keys(users).length} verif users to upgrade`); + try { + const usersToUpgrade = await shouldUpgradeCb({ + users: users + }); + if (usersToUpgrade) { + for (const userId of usersToUpgrade) { + if (userId in users) { + await this.baseApis.setDeviceVerified(userId, users[userId].crossSigningInfo.getId()); + } + } + } + } catch (e) { + _logger.logger.log("shouldUpgradeDeviceVerifications threw an error: not upgrading", e); + } + } + _logger.logger.info("Finished device verification upgrade"); + } + _logger.logger.info("Finished cross-signing key change post-processing"); + } + + /** + * Check if a user's cross-signing key is a candidate for upgrading from device + * verification. + * + * @param userId - the user whose cross-signing information is to be checked + * @param crossSigningInfo - the cross-signing information to check + */ + async checkForDeviceVerificationUpgrade(userId, crossSigningInfo) { + // only upgrade if this is the first cross-signing key that we've seen for + // them, and if their cross-signing key isn't already verified + const trustLevel = this.crossSigningInfo.checkUserTrust(crossSigningInfo); + if (crossSigningInfo.firstUse && !trustLevel.isVerified()) { + const devices = this.deviceList.getRawStoredDevicesForUser(userId); + const deviceIds = await this.checkForValidDeviceSignature(userId, crossSigningInfo.keys.master, devices); + if (deviceIds.length) { + return { + devices: deviceIds.map(deviceId => _deviceinfo.DeviceInfo.fromStorage(devices[deviceId], deviceId)), + crossSigningInfo + }; + } + } + } + + /** + * Check if the cross-signing key is signed by a verified device. + * + * @param userId - the user ID whose key is being checked + * @param key - the key that is being checked + * @param devices - the user's devices. Should be a map from device ID + * to device info + */ + async checkForValidDeviceSignature(userId, key, devices) { + const deviceIds = []; + if (devices && key.signatures && key.signatures[userId]) { + for (const signame of Object.keys(key.signatures[userId])) { + const [, deviceId] = signame.split(":", 2); + if (deviceId in devices && devices[deviceId].verified === DeviceVerification.VERIFIED) { + try { + await olmlib.verifySignature(this.olmDevice, key, userId, deviceId, devices[deviceId].keys[signame]); + deviceIds.push(deviceId); + } catch (e) {} + } + } + } + return deviceIds; + } + + /** + * Get the user's cross-signing key ID. + * + * @param type - The type of key to get the ID of. One of + * "master", "self_signing", or "user_signing". Defaults to "master". + * + * @returns the key ID + */ + getCrossSigningKeyId(type = _api.CrossSigningKey.Master) { + return Promise.resolve(this.getCrossSigningId(type)); + } + + // old name, for backwards compatibility + getCrossSigningId(type) { + return this.crossSigningInfo.getId(type); + } + + /** + * Get the cross signing information for a given user. + * + * @param userId - the user ID to get the cross-signing info for. + * + * @returns the cross signing information for the user. + */ + getStoredCrossSigningForUser(userId) { + return this.deviceList.getStoredCrossSigningForUser(userId); + } + + /** + * Check whether a given user is trusted. + * + * @param userId - The ID of the user to check. + * + * @returns + */ + checkUserTrust(userId) { + const userCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId); + if (!userCrossSigning) { + return new _CrossSigning.UserTrustLevel(false, false, false); + } + return this.crossSigningInfo.checkUserTrust(userCrossSigning); + } + + /** + * Check whether a given device is trusted. + * + * @param userId - The ID of the user whose device is to be checked. + * @param deviceId - The ID of the device to check + */ + async getDeviceVerificationStatus(userId, deviceId) { + const device = this.deviceList.getStoredDevice(userId, deviceId); + if (!device) { + return null; + } + return this.checkDeviceInfoTrust(userId, device); + } + + /** + * @deprecated Use {@link Crypto.CryptoApi.getDeviceVerificationStatus}. + */ + checkDeviceTrust(userId, deviceId) { + const device = this.deviceList.getStoredDevice(userId, deviceId); + return this.checkDeviceInfoTrust(userId, device); + } + + /** + * Check whether a given deviceinfo is trusted. + * + * @param userId - The ID of the user whose devices is to be checked. + * @param device - The device info object to check + * + * @deprecated Use {@link Crypto.CryptoApi.getDeviceVerificationStatus}. + */ + checkDeviceInfoTrust(userId, device) { + const trustedLocally = !!device?.isVerified(); + const userCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId); + if (device && userCrossSigning) { + // The trustCrossSignedDevices only affects trust of other people's cross-signing + // signatures + const trustCrossSig = this.trustCrossSignedDevices || userId === this.userId; + return this.crossSigningInfo.checkDeviceTrust(userCrossSigning, device, trustedLocally, trustCrossSig); + } else { + return new _CrossSigning.DeviceTrustLevel(false, false, trustedLocally, false); + } + } + + /** + * Check whether one of our own devices is cross-signed by our + * user's stored keys, regardless of whether we trust those keys yet. + * + * @param deviceId - The ID of the device to check + * + * @returns true if the device is cross-signed + */ + checkIfOwnDeviceCrossSigned(deviceId) { + const device = this.deviceList.getStoredDevice(this.userId, deviceId); + if (!device) return false; + const userCrossSigning = this.deviceList.getStoredCrossSigningForUser(this.userId); + return userCrossSigning?.checkDeviceTrust(userCrossSigning, device, false, true).isCrossSigningVerified() ?? false; + } + /** + * Check the copy of our cross-signing key that we have in the device list and + * see if we can get the private key. If so, mark it as trusted. + */ + async checkOwnCrossSigningTrust({ + allowPrivateKeyRequests = false + } = {}) { + const userId = this.userId; + + // Before proceeding, ensure our cross-signing public keys have been + // downloaded via the device list. + await this.downloadKeys([this.userId]); + + // Also check which private keys are locally cached. + const crossSigningPrivateKeys = await this.crossSigningInfo.getCrossSigningKeysFromCache(); + + // If we see an update to our own master key, check it against the master + // key we have and, if it matches, mark it as verified + + // First, get the new cross-signing info + const newCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId); + if (!newCrossSigning) { + _logger.logger.error("Got cross-signing update event for user " + userId + " but no new cross-signing information found!"); + return; + } + const seenPubkey = newCrossSigning.getId(); + const masterChanged = this.crossSigningInfo.getId() !== seenPubkey; + const masterExistsNotLocallyCached = newCrossSigning.getId() && !crossSigningPrivateKeys.has("master"); + if (masterChanged) { + _logger.logger.info("Got new master public key", seenPubkey); + } + if (allowPrivateKeyRequests && (masterChanged || masterExistsNotLocallyCached)) { + _logger.logger.info("Attempting to retrieve cross-signing master private key"); + let signing = null; + // It's important for control flow that we leave any errors alone for + // higher levels to handle so that e.g. cancelling access properly + // aborts any larger operation as well. + try { + const ret = await this.crossSigningInfo.getCrossSigningKey("master", seenPubkey); + signing = ret[1]; + _logger.logger.info("Got cross-signing master private key"); + } finally { + signing?.free(); + } + } + const oldSelfSigningId = this.crossSigningInfo.getId("self_signing"); + const oldUserSigningId = this.crossSigningInfo.getId("user_signing"); + + // Update the version of our keys in our cross-signing object and the local store + this.storeTrustedSelfKeys(newCrossSigning.keys); + const selfSigningChanged = oldSelfSigningId !== newCrossSigning.getId("self_signing"); + const userSigningChanged = oldUserSigningId !== newCrossSigning.getId("user_signing"); + const selfSigningExistsNotLocallyCached = newCrossSigning.getId("self_signing") && !crossSigningPrivateKeys.has("self_signing"); + const userSigningExistsNotLocallyCached = newCrossSigning.getId("user_signing") && !crossSigningPrivateKeys.has("user_signing"); + const keySignatures = {}; + if (selfSigningChanged) { + _logger.logger.info("Got new self-signing key", newCrossSigning.getId("self_signing")); + } + if (allowPrivateKeyRequests && (selfSigningChanged || selfSigningExistsNotLocallyCached)) { + _logger.logger.info("Attempting to retrieve cross-signing self-signing private key"); + let signing = null; + try { + const ret = await this.crossSigningInfo.getCrossSigningKey("self_signing", newCrossSigning.getId("self_signing")); + signing = ret[1]; + _logger.logger.info("Got cross-signing self-signing private key"); + } finally { + signing?.free(); + } + const device = this.deviceList.getStoredDevice(this.userId, this.deviceId); + const signedDevice = await this.crossSigningInfo.signDevice(this.userId, device); + keySignatures[this.deviceId] = signedDevice; + } + if (userSigningChanged) { + _logger.logger.info("Got new user-signing key", newCrossSigning.getId("user_signing")); + } + if (allowPrivateKeyRequests && (userSigningChanged || userSigningExistsNotLocallyCached)) { + _logger.logger.info("Attempting to retrieve cross-signing user-signing private key"); + let signing = null; + try { + const ret = await this.crossSigningInfo.getCrossSigningKey("user_signing", newCrossSigning.getId("user_signing")); + signing = ret[1]; + _logger.logger.info("Got cross-signing user-signing private key"); + } finally { + signing?.free(); + } + } + if (masterChanged) { + const masterKey = this.crossSigningInfo.keys.master; + await this.signObject(masterKey); + const deviceSig = masterKey.signatures[this.userId]["ed25519:" + this.deviceId]; + // Include only the _new_ device signature in the upload. + // We may have existing signatures from deleted devices, which will cause + // the entire upload to fail. + keySignatures[this.crossSigningInfo.getId()] = Object.assign({}, masterKey, { + signatures: { + [this.userId]: { + ["ed25519:" + this.deviceId]: deviceSig + } + } + }); + } + const keysToUpload = Object.keys(keySignatures); + if (keysToUpload.length) { + const upload = ({ + shouldEmit = false + }) => { + _logger.logger.info(`Starting background key sig upload for ${keysToUpload}`); + return this.baseApis.uploadKeySignatures({ + [this.userId]: keySignatures + }).then(response => { + const { + failures + } = response || {}; + _logger.logger.info(`Finished background key sig upload for ${keysToUpload}`); + if (Object.keys(failures || []).length > 0) { + if (shouldEmit) { + this.baseApis.emit(CryptoEvent.KeySignatureUploadFailure, failures, "checkOwnCrossSigningTrust", upload); + } + throw new _errors.KeySignatureUploadError("Key upload failed", { + failures + }); + } + }).catch(e => { + _logger.logger.error(`Error during background key sig upload for ${keysToUpload}`, e); + }); + }; + upload({ + shouldEmit: true + }); + } + this.emit(CryptoEvent.UserTrustStatusChanged, userId, this.checkUserTrust(userId)); + if (masterChanged) { + this.emit(CryptoEvent.KeysChanged, {}); + await this.afterCrossSigningLocalKeyChange(); + } + + // Now we may be able to trust our key backup + await this.backupManager.checkKeyBackup(); + // FIXME: if we previously trusted the backup, should we automatically sign + // the backup with the new key (if not already signed)? + } + + /** + * Store a set of keys as our own, trusted, cross-signing keys. + * + * @param keys - The new trusted set of keys + */ + async storeTrustedSelfKeys(keys) { + if (keys) { + this.crossSigningInfo.setKeys(keys); + } else { + this.crossSigningInfo.clearKeys(); + } + await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => { + this.cryptoStore.storeCrossSigningKeys(txn, this.crossSigningInfo.keys); + }); + } + + /** + * Check if the master key is signed by a verified device, and if so, prompt + * the application to mark it as verified. + * + * @param userId - the user ID whose key should be checked + */ + async checkDeviceVerifications(userId) { + const shouldUpgradeCb = this.baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications; + if (!shouldUpgradeCb) { + // Upgrading skipped when callback is not present. + return; + } + _logger.logger.info(`Starting device verification upgrade for ${userId}`); + if (this.crossSigningInfo.keys.user_signing) { + const crossSigningInfo = this.deviceList.getStoredCrossSigningForUser(userId); + if (crossSigningInfo) { + const upgradeInfo = await this.checkForDeviceVerificationUpgrade(userId, crossSigningInfo); + if (upgradeInfo) { + const usersToUpgrade = await shouldUpgradeCb({ + users: { + [userId]: upgradeInfo + } + }); + if (usersToUpgrade.includes(userId)) { + await this.baseApis.setDeviceVerified(userId, crossSigningInfo.getId()); + } + } + } + } + _logger.logger.info(`Finished device verification upgrade for ${userId}`); + } + + /** + */ + enableLazyLoading() { + this.lazyLoadMembers = true; + } + + /** + * Tell the crypto module to register for MatrixClient events which it needs to + * listen for + * + * @param eventEmitter - event source where we can register + * for event notifications + */ + registerEventHandlers(eventEmitter) { + eventEmitter.on(_roomMember.RoomMemberEvent.Membership, this.onMembership); + eventEmitter.on(_client.ClientEvent.ToDeviceEvent, this.onToDeviceEvent); + eventEmitter.on(_room.RoomEvent.Timeline, this.onTimelineEvent); + eventEmitter.on(_event2.MatrixEventEvent.Decrypted, this.onTimelineEvent); + } + + /** + * @deprecated this does nothing and will be removed in a future version + */ + start() { + _logger.logger.warn("MatrixClient.crypto.start() is deprecated"); + } + + /** Stop background processes related to crypto */ + stop() { + this.outgoingRoomKeyRequestManager.stop(); + this.deviceList.stop(); + this.dehydrationManager.stop(); + } + + /** + * Get the Ed25519 key for this device + * + * @returns base64-encoded ed25519 key. + */ + getDeviceEd25519Key() { + return this.olmDevice.deviceEd25519Key; + } + + /** + * Get the Curve25519 key for this device + * + * @returns base64-encoded curve25519 key. + */ + getDeviceCurve25519Key() { + return this.olmDevice.deviceCurve25519Key; + } + + /** + * Set the global override for whether the client should ever send encrypted + * messages to unverified devices. This provides the default for rooms which + * do not specify a value. + * + * @param value - whether to blacklist all unverified devices by default + * + * @deprecated Set {@link Crypto.CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly. + */ + setGlobalBlacklistUnverifiedDevices(value) { + this.globalBlacklistUnverifiedDevices = value; + } + + /** + * @returns whether to blacklist all unverified devices by default + * + * @deprecated Reference {@link Crypto.CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly. + */ + getGlobalBlacklistUnverifiedDevices() { + return this.globalBlacklistUnverifiedDevices; + } + + /** + * Upload the device keys to the homeserver. + * @returns A promise that will resolve when the keys are uploaded. + */ + uploadDeviceKeys() { + const deviceKeys = { + algorithms: this.supportedAlgorithms, + device_id: this.deviceId, + keys: this.deviceKeys, + user_id: this.userId + }; + return this.signObject(deviceKeys).then(() => { + return this.baseApis.uploadKeysRequest({ + device_keys: deviceKeys + }); + }); + } + getNeedsNewFallback() { + return !!this.needsNewFallback; + } + + // check if it's time to upload one-time keys, and do so if so. + maybeUploadOneTimeKeys() { + // frequency with which to check & upload one-time keys + const uploadPeriod = 1000 * 60; // one minute + + // max number of keys to upload at once + // Creating keys can be an expensive operation so we limit the + // number we generate in one go to avoid blocking the application + // for too long. + const maxKeysPerCycle = 5; + if (this.oneTimeKeyCheckInProgress) { + return; + } + const now = Date.now(); + if (this.lastOneTimeKeyCheck !== null && now - this.lastOneTimeKeyCheck < uploadPeriod) { + // we've done a key upload recently. + return; + } + this.lastOneTimeKeyCheck = now; + + // We need to keep a pool of one time public keys on the server so that + // other devices can start conversations with us. But we can only store + // a finite number of private keys in the olm Account object. + // To complicate things further then can be a delay between a device + // claiming a public one time key from the server and it sending us a + // message. We need to keep the corresponding private key locally until + // we receive the message. + // But that message might never arrive leaving us stuck with duff + // private keys clogging up our local storage. + // So we need some kind of engineering compromise to balance all of + // these factors. + + // Check how many keys we can store in the Account object. + const maxOneTimeKeys = this.olmDevice.maxNumberOfOneTimeKeys(); + // Try to keep at most half that number on the server. This leaves the + // rest of the slots free to hold keys that have been claimed from the + // server but we haven't received a message for. + // If we run out of slots when generating new keys then olm will + // discard the oldest private keys first. This will eventually clean + // out stale private keys that won't receive a message. + const keyLimit = Math.floor(maxOneTimeKeys / 2); + const uploadLoop = async keyCount => { + while (keyLimit > keyCount || this.getNeedsNewFallback()) { + // Ask olm to generate new one time keys, then upload them to synapse. + if (keyLimit > keyCount) { + _logger.logger.info("generating oneTimeKeys"); + const keysThisLoop = Math.min(keyLimit - keyCount, maxKeysPerCycle); + await this.olmDevice.generateOneTimeKeys(keysThisLoop); + } + if (this.getNeedsNewFallback()) { + const fallbackKeys = await this.olmDevice.getFallbackKey(); + // if fallbackKeys is non-empty, we've already generated a + // fallback key, but it hasn't been published yet, so we + // can use that instead of generating a new one + if (!fallbackKeys.curve25519 || Object.keys(fallbackKeys.curve25519).length == 0) { + _logger.logger.info("generating fallback key"); + if (this.fallbackCleanup) { + // cancel any pending fallback cleanup because generating + // a new fallback key will already drop the old fallback + // that would have been dropped, and we don't want to kill + // the current key + clearTimeout(this.fallbackCleanup); + delete this.fallbackCleanup; + } + await this.olmDevice.generateFallbackKey(); + } + } + _logger.logger.info("calling uploadOneTimeKeys"); + const res = await this.uploadOneTimeKeys(); + if (res.one_time_key_counts && res.one_time_key_counts.signed_curve25519) { + // if the response contains a more up to date value use this + // for the next loop + keyCount = res.one_time_key_counts.signed_curve25519; + } else { + throw new Error("response for uploading keys does not contain " + "one_time_key_counts.signed_curve25519"); + } + } + }; + this.oneTimeKeyCheckInProgress = true; + Promise.resolve().then(() => { + if (this.oneTimeKeyCount !== undefined) { + // We already have the current one_time_key count from a /sync response. + // Use this value instead of asking the server for the current key count. + return Promise.resolve(this.oneTimeKeyCount); + } + // ask the server how many keys we have + return this.baseApis.uploadKeysRequest({}).then(res => { + return res.one_time_key_counts.signed_curve25519 || 0; + }); + }).then(keyCount => { + // Start the uploadLoop with the current keyCount. The function checks if + // we need to upload new keys or not. + // If there are too many keys on the server then we don't need to + // create any more keys. + return uploadLoop(keyCount); + }).catch(e => { + _logger.logger.error("Error uploading one-time keys", e.stack || e); + }).finally(() => { + // reset oneTimeKeyCount to prevent start uploading based on old data. + // it will be set again on the next /sync-response + this.oneTimeKeyCount = undefined; + this.oneTimeKeyCheckInProgress = false; + }); + } + + // returns a promise which resolves to the response + async uploadOneTimeKeys() { + const promises = []; + let fallbackJson; + if (this.getNeedsNewFallback()) { + fallbackJson = {}; + const fallbackKeys = await this.olmDevice.getFallbackKey(); + for (const [keyId, key] of Object.entries(fallbackKeys.curve25519)) { + const k = { + key, + fallback: true + }; + fallbackJson["signed_curve25519:" + keyId] = k; + promises.push(this.signObject(k)); + } + this.needsNewFallback = false; + } + const oneTimeKeys = await this.olmDevice.getOneTimeKeys(); + const oneTimeJson = {}; + for (const keyId in oneTimeKeys.curve25519) { + if (oneTimeKeys.curve25519.hasOwnProperty(keyId)) { + const k = { + key: oneTimeKeys.curve25519[keyId] + }; + oneTimeJson["signed_curve25519:" + keyId] = k; + promises.push(this.signObject(k)); + } + } + await Promise.all(promises); + const requestBody = { + one_time_keys: oneTimeJson + }; + if (fallbackJson) { + requestBody["org.matrix.msc2732.fallback_keys"] = fallbackJson; + requestBody["fallback_keys"] = fallbackJson; + } + const res = await this.baseApis.uploadKeysRequest(requestBody); + if (fallbackJson) { + this.fallbackCleanup = setTimeout(() => { + delete this.fallbackCleanup; + this.olmDevice.forgetOldFallbackKey(); + }, 60 * 60 * 1000); + } + await this.olmDevice.markKeysAsPublished(); + return res; + } + + /** + * Download the keys for a list of users and stores the keys in the session + * store. + * @param userIds - The users to fetch. + * @param forceDownload - Always download the keys even if cached. + * + * @returns A promise which resolves to a map `userId->deviceId->{@link DeviceInfo}`. + */ + downloadKeys(userIds, forceDownload) { + return this.deviceList.downloadKeys(userIds, !!forceDownload); + } + + /** + * Get the stored device keys for a user id + * + * @param userId - the user to list keys for. + * + * @returns list of devices, or null if we haven't + * managed to get a list of devices for this user yet. + */ + getStoredDevicesForUser(userId) { + return this.deviceList.getStoredDevicesForUser(userId); + } + + /** + * Get the device information for the given list of users. + * + * @param userIds - The users to fetch. + * @param downloadUncached - If true, download the device list for users whose device list we are not + * currently tracking. Defaults to false, in which case such users will not appear at all in the result map. + * + * @returns A map `{@link DeviceMap}`. + */ + async getUserDeviceInfo(userIds, downloadUncached = false) { + const deviceMapByUserId = new Map(); + // Keep the users without device to download theirs keys + const usersWithoutDeviceInfo = []; + for (const userId of userIds) { + const deviceInfos = await this.getStoredDevicesForUser(userId); + // If there are device infos for a userId, we transform it into a map + // Else, the keys will be downloaded after + if (deviceInfos) { + const deviceMap = new Map( + // Convert DeviceInfo to Device + deviceInfos.map(deviceInfo => [deviceInfo.deviceId, (0, _deviceConverter.deviceInfoToDevice)(deviceInfo, userId)])); + deviceMapByUserId.set(userId, deviceMap); + } else { + usersWithoutDeviceInfo.push(userId); + } + } + + // Download device info for users without device infos + if (downloadUncached && usersWithoutDeviceInfo.length > 0) { + const newDeviceInfoMap = await this.downloadKeys(usersWithoutDeviceInfo); + newDeviceInfoMap.forEach((deviceInfoMap, userId) => { + const deviceMap = new Map(); + // Convert DeviceInfo to Device + deviceInfoMap.forEach((deviceInfo, deviceId) => deviceMap.set(deviceId, (0, _deviceConverter.deviceInfoToDevice)(deviceInfo, userId))); + + // Put the new device infos into the returned map + deviceMapByUserId.set(userId, deviceMap); + }); + } + return deviceMapByUserId; + } + + /** + * Get the stored keys for a single device + * + * + * @returns device, or undefined + * if we don't know about this device + */ + getStoredDevice(userId, deviceId) { + return this.deviceList.getStoredDevice(userId, deviceId); + } + + /** + * Save the device list, if necessary + * + * @param delay - Time in ms before which the save actually happens. + * By default, the save is delayed for a short period in order to batch + * multiple writes, but this behaviour can be disabled by passing 0. + * + * @returns true if the data was saved, false if + * it was not (eg. because no changes were pending). The promise + * will only resolve once the data is saved, so may take some time + * to resolve. + */ + saveDeviceList(delay) { + return this.deviceList.saveIfDirty(delay); + } + + /** + * Update the blocked/verified state of the given device + * + * @param userId - owner of the device + * @param deviceId - unique identifier for the device or user's + * cross-signing public key ID. + * + * @param verified - whether to mark the device as verified. Null to + * leave unchanged. + * + * @param blocked - whether to mark the device as blocked. Null to + * leave unchanged. + * + * @param known - whether to mark that the user has been made aware of + * the existence of this device. Null to leave unchanged + * + * @param keys - The list of keys that was present + * during the device verification. This will be double checked with the list + * of keys the given device has currently. + * + * @returns updated DeviceInfo + */ + async setDeviceVerification(userId, deviceId, verified = null, blocked = null, known = null, keys) { + // Check if the 'device' is actually a cross signing key + // The js-sdk's verification treats cross-signing keys as devices + // and so uses this method to mark them verified. + const xsk = this.deviceList.getStoredCrossSigningForUser(userId); + if (xsk && xsk.getId() === deviceId) { + if (blocked !== null || known !== null) { + throw new Error("Cannot set blocked or known for a cross-signing key"); + } + if (!verified) { + throw new Error("Cannot set a cross-signing key as unverified"); + } + const gotKeyId = keys ? Object.values(keys)[0] : null; + if (keys && (Object.values(keys).length !== 1 || gotKeyId !== xsk.getId())) { + throw new Error(`Key did not match expected value: expected ${xsk.getId()}, got ${gotKeyId}`); + } + if (!this.crossSigningInfo.getId() && userId === this.crossSigningInfo.userId) { + this.storeTrustedSelfKeys(xsk.keys); + // This will cause our own user trust to change, so emit the event + this.emit(CryptoEvent.UserTrustStatusChanged, this.userId, this.checkUserTrust(userId)); + } + + // Now sign the master key with our user signing key (unless it's ourself) + if (userId !== this.userId) { + _logger.logger.info("Master key " + xsk.getId() + " for " + userId + " marked verified. Signing..."); + const device = await this.crossSigningInfo.signUser(xsk); + if (device) { + const upload = async ({ + shouldEmit = false + }) => { + _logger.logger.info("Uploading signature for " + userId + "..."); + const response = await this.baseApis.uploadKeySignatures({ + [userId]: { + [deviceId]: device + } + }); + const { + failures + } = response || {}; + if (Object.keys(failures || []).length > 0) { + if (shouldEmit) { + this.baseApis.emit(CryptoEvent.KeySignatureUploadFailure, failures, "setDeviceVerification", upload); + } + /* Throwing here causes the process to be cancelled and the other + * user to be notified */ + throw new _errors.KeySignatureUploadError("Key upload failed", { + failures + }); + } + }; + await upload({ + shouldEmit: true + }); + + // This will emit events when it comes back down the sync + // (we could do local echo to speed things up) + } + + return device; // TODO types + } else { + return xsk; + } + } + const devices = this.deviceList.getRawStoredDevicesForUser(userId); + if (!devices || !devices[deviceId]) { + throw new Error("Unknown device " + userId + ":" + deviceId); + } + const dev = devices[deviceId]; + let verificationStatus = dev.verified; + if (verified) { + if (keys) { + for (const [keyId, key] of Object.entries(keys)) { + if (dev.keys[keyId] !== key) { + throw new Error(`Key did not match expected value: expected ${key}, got ${dev.keys[keyId]}`); + } + } + } + verificationStatus = DeviceVerification.VERIFIED; + } else if (verified !== null && verificationStatus == DeviceVerification.VERIFIED) { + verificationStatus = DeviceVerification.UNVERIFIED; + } + if (blocked) { + verificationStatus = DeviceVerification.BLOCKED; + } else if (blocked !== null && verificationStatus == DeviceVerification.BLOCKED) { + verificationStatus = DeviceVerification.UNVERIFIED; + } + let knownStatus = dev.known; + if (known !== null) { + knownStatus = known; + } + if (dev.verified !== verificationStatus || dev.known !== knownStatus) { + dev.verified = verificationStatus; + dev.known = knownStatus; + this.deviceList.storeDevicesForUser(userId, devices); + this.deviceList.saveIfDirty(); + } + + // do cross-signing + if (verified && userId === this.userId) { + _logger.logger.info("Own device " + deviceId + " marked verified: signing"); + + // Signing only needed if other device not already signed + let device; + const deviceTrust = this.checkDeviceTrust(userId, deviceId); + if (deviceTrust.isCrossSigningVerified()) { + _logger.logger.log(`Own device ${deviceId} already cross-signing verified`); + } else { + device = await this.crossSigningInfo.signDevice(userId, _deviceinfo.DeviceInfo.fromStorage(dev, deviceId)); + } + if (device) { + const upload = async ({ + shouldEmit = false + }) => { + _logger.logger.info("Uploading signature for " + deviceId); + const response = await this.baseApis.uploadKeySignatures({ + [userId]: { + [deviceId]: device + } + }); + const { + failures + } = response || {}; + if (Object.keys(failures || []).length > 0) { + if (shouldEmit) { + this.baseApis.emit(CryptoEvent.KeySignatureUploadFailure, failures, "setDeviceVerification", upload // continuation + ); + } + + throw new _errors.KeySignatureUploadError("Key upload failed", { + failures + }); + } + }; + await upload({ + shouldEmit: true + }); + // XXX: we'll need to wait for the device list to be updated + } + } + + const deviceObj = _deviceinfo.DeviceInfo.fromStorage(dev, deviceId); + this.emit(CryptoEvent.DeviceVerificationChanged, userId, deviceId, deviceObj); + return deviceObj; + } + findVerificationRequestDMInProgress(roomId) { + return this.inRoomVerificationRequests.findRequestInProgress(roomId); + } + getVerificationRequestsToDeviceInProgress(userId) { + return this.toDeviceVerificationRequests.getRequestsInProgress(userId); + } + requestVerificationDM(userId, roomId) { + const existingRequest = this.inRoomVerificationRequests.findRequestInProgress(roomId); + if (existingRequest) { + return Promise.resolve(existingRequest); + } + const channel = new _InRoomChannel.InRoomChannel(this.baseApis, roomId, userId); + return this.requestVerificationWithChannel(userId, channel, this.inRoomVerificationRequests); + } + requestVerification(userId, devices) { + if (!devices) { + devices = Object.keys(this.deviceList.getRawStoredDevicesForUser(userId)); + } + const existingRequest = this.toDeviceVerificationRequests.findRequestInProgress(userId, devices); + if (existingRequest) { + return Promise.resolve(existingRequest); + } + const channel = new _ToDeviceChannel.ToDeviceChannel(this.baseApis, userId, devices, _ToDeviceChannel.ToDeviceChannel.makeTransactionId()); + return this.requestVerificationWithChannel(userId, channel, this.toDeviceVerificationRequests); + } + async requestVerificationWithChannel(userId, channel, requestsMap) { + let request = new _VerificationRequest.VerificationRequest(channel, this.verificationMethods, this.baseApis); + // if transaction id is already known, add request + if (channel.transactionId) { + requestsMap.setRequestByChannel(channel, request); + } + await request.sendRequest(); + // don't replace the request created by a racing remote echo + const racingRequest = requestsMap.getRequestByChannel(channel); + if (racingRequest) { + request = racingRequest; + } else { + _logger.logger.log(`Crypto: adding new request to ` + `requestsByTxnId with id ${channel.transactionId} ${channel.roomId}`); + requestsMap.setRequestByChannel(channel, request); + } + return request; + } + beginKeyVerification(method, userId, deviceId, transactionId = null) { + let request; + if (transactionId) { + request = this.toDeviceVerificationRequests.getRequestBySenderAndTxnId(userId, transactionId); + if (!request) { + throw new Error(`No request found for user ${userId} with ` + `transactionId ${transactionId}`); + } + } else { + transactionId = _ToDeviceChannel.ToDeviceChannel.makeTransactionId(); + const channel = new _ToDeviceChannel.ToDeviceChannel(this.baseApis, userId, [deviceId], transactionId, deviceId); + request = new _VerificationRequest.VerificationRequest(channel, this.verificationMethods, this.baseApis); + this.toDeviceVerificationRequests.setRequestBySenderAndTxnId(userId, transactionId, request); + } + return request.beginKeyVerification(method, { + userId, + deviceId + }); + } + async legacyDeviceVerification(userId, deviceId, method) { + const transactionId = _ToDeviceChannel.ToDeviceChannel.makeTransactionId(); + const channel = new _ToDeviceChannel.ToDeviceChannel(this.baseApis, userId, [deviceId], transactionId, deviceId); + const request = new _VerificationRequest.VerificationRequest(channel, this.verificationMethods, this.baseApis); + this.toDeviceVerificationRequests.setRequestBySenderAndTxnId(userId, transactionId, request); + const verifier = request.beginKeyVerification(method, { + userId, + deviceId + }); + // either reject by an error from verify() while sending .start + // or resolve when the request receives the + // local (fake remote) echo for sending the .start event + await Promise.race([verifier.verify(), request.waitFor(r => r.started)]); + return request; + } + + /** + * Get information on the active olm sessions with a user + * <p> + * Returns a map from device id to an object with keys 'deviceIdKey' (the + * device's curve25519 identity key) and 'sessions' (an array of objects in the + * same format as that returned by + * {@link OlmDevice#getSessionInfoForDevice}). + * <p> + * This method is provided for debugging purposes. + * + * @param userId - id of user to inspect + */ + async getOlmSessionsForUser(userId) { + const devices = this.getStoredDevicesForUser(userId) || []; + const result = {}; + for (const device of devices) { + const deviceKey = device.getIdentityKey(); + const sessions = await this.olmDevice.getSessionInfoForDevice(deviceKey); + result[device.deviceId] = { + deviceIdKey: deviceKey, + sessions: sessions + }; + } + return result; + } + + /** + * Get the device which sent an event + * + * @param event - event to be checked + */ + getEventSenderDeviceInfo(event) { + const senderKey = event.getSenderKey(); + const algorithm = event.getWireContent().algorithm; + if (!senderKey || !algorithm) { + return null; + } + if (event.isKeySourceUntrusted()) { + // we got the key for this event from a source that we consider untrusted + return null; + } + + // senderKey is the Curve25519 identity key of the device which the event + // was sent from. In the case of Megolm, it's actually the Curve25519 + // identity key of the device which set up the Megolm session. + + const device = this.deviceList.getDeviceByIdentityKey(algorithm, senderKey); + if (device === null) { + // we haven't downloaded the details of this device yet. + return null; + } + + // so far so good, but now we need to check that the sender of this event + // hadn't advertised someone else's Curve25519 key as their own. We do that + // by checking the Ed25519 claimed by the event (or, in the case of megolm, + // the event which set up the megolm session), to check that it matches the + // fingerprint of the purported sending device. + // + // (see https://github.com/vector-im/vector-web/issues/2215) + + const claimedKey = event.getClaimedEd25519Key(); + if (!claimedKey) { + _logger.logger.warn("Event " + event.getId() + " claims no ed25519 key: " + "cannot verify sending device"); + return null; + } + if (claimedKey !== device.getFingerprint()) { + _logger.logger.warn("Event " + event.getId() + " claims ed25519 key " + claimedKey + " but sender device has key " + device.getFingerprint()); + return null; + } + return device; + } + + /** + * Get information about the encryption of an event + * + * @param event - event to be checked + * + * @returns An object with the fields: + * - encrypted: whether the event is encrypted (if not encrypted, some of the + * other properties may not be set) + * - senderKey: the sender's key + * - algorithm: the algorithm used to encrypt the event + * - authenticated: whether we can be sure that the owner of the senderKey + * sent the event + * - sender: the sender's device information, if available + * - mismatchedSender: if the event's ed25519 and curve25519 keys don't match + * (only meaningful if `sender` is set) + */ + getEventEncryptionInfo(event) { + const ret = {}; + ret.senderKey = event.getSenderKey() ?? undefined; + ret.algorithm = event.getWireContent().algorithm; + if (!ret.senderKey || !ret.algorithm) { + ret.encrypted = false; + return ret; + } + ret.encrypted = true; + if (event.isKeySourceUntrusted()) { + // we got the key this event from somewhere else + // TODO: check if we can trust the forwarders. + ret.authenticated = false; + } else { + ret.authenticated = true; + } + + // senderKey is the Curve25519 identity key of the device which the event + // was sent from. In the case of Megolm, it's actually the Curve25519 + // identity key of the device which set up the Megolm session. + + ret.sender = this.deviceList.getDeviceByIdentityKey(ret.algorithm, ret.senderKey) ?? undefined; + + // so far so good, but now we need to check that the sender of this event + // hadn't advertised someone else's Curve25519 key as their own. We do that + // by checking the Ed25519 claimed by the event (or, in the case of megolm, + // the event which set up the megolm session), to check that it matches the + // fingerprint of the purported sending device. + // + // (see https://github.com/vector-im/vector-web/issues/2215) + + const claimedKey = event.getClaimedEd25519Key(); + if (!claimedKey) { + _logger.logger.warn("Event " + event.getId() + " claims no ed25519 key: " + "cannot verify sending device"); + ret.mismatchedSender = true; + } + if (ret.sender && claimedKey !== ret.sender.getFingerprint()) { + _logger.logger.warn("Event " + event.getId() + " claims ed25519 key " + claimedKey + "but sender device has key " + ret.sender.getFingerprint()); + ret.mismatchedSender = true; + } + return ret; + } + + /** + * Forces the current outbound group session to be discarded such + * that another one will be created next time an event is sent. + * + * @param roomId - The ID of the room to discard the session for + * + * This should not normally be necessary. + */ + forceDiscardSession(roomId) { + const alg = this.roomEncryptors.get(roomId); + if (alg === undefined) throw new Error("Room not encrypted"); + if (alg.forceDiscardSession === undefined) { + throw new Error("Room encryption algorithm doesn't support session discarding"); + } + alg.forceDiscardSession(); + return Promise.resolve(); + } + + /** + * Configure a room to use encryption (ie, save a flag in the cryptoStore). + * + * @param roomId - The room ID to enable encryption in. + * + * @param config - The encryption config for the room. + * + * @param inhibitDeviceQuery - true to suppress device list query for + * users in the room (for now). In case lazy loading is enabled, + * the device query is always inhibited as the members are not tracked. + * + * @deprecated It is normally incorrect to call this method directly. Encryption + * is enabled by receiving an `m.room.encryption` event (which we may have sent + * previously). + */ + async setRoomEncryption(roomId, config, inhibitDeviceQuery) { + const room = this.clientStore.getRoom(roomId); + if (!room) { + throw new Error(`Unable to enable encryption tracking devices in unknown room ${roomId}`); + } + await this.setRoomEncryptionImpl(room, config); + if (!this.lazyLoadMembers && !inhibitDeviceQuery) { + this.deviceList.refreshOutdatedDeviceLists(); + } + } + + /** + * Set up encryption for a room. + * + * This is called when an <tt>m.room.encryption</tt> event is received. It saves a flag + * for the room in the cryptoStore (if it wasn't already set), sets up an "encryptor" for + * the room, and enables device-list tracking for the room. + * + * It does <em>not</em> initiate a device list query for the room. That is normally + * done once we finish processing the sync, in onSyncCompleted. + * + * @param room - The room to enable encryption in. + * @param config - The encryption config for the room. + */ + async setRoomEncryptionImpl(room, config) { + const roomId = room.roomId; + + // ignore crypto events with no algorithm defined + // This will happen if a crypto event is redacted before we fetch the room state + // It would otherwise just throw later as an unknown algorithm would, but we may + // as well catch this here + if (!config.algorithm) { + _logger.logger.log("Ignoring setRoomEncryption with no algorithm"); + return; + } + + // if state is being replayed from storage, we might already have a configuration + // for this room as they are persisted as well. + // We just need to make sure the algorithm is initialized in this case. + // However, if the new config is different, + // we should bail out as room encryption can't be changed once set. + const existingConfig = this.roomList.getRoomEncryption(roomId); + if (existingConfig) { + if (JSON.stringify(existingConfig) != JSON.stringify(config)) { + _logger.logger.error("Ignoring m.room.encryption event which requests " + "a change of config in " + roomId); + return; + } + } + // if we already have encryption in this room, we should ignore this event, + // as it would reset the encryption algorithm. + // This is at least expected to be called twice, as sync calls onCryptoEvent + // for both the timeline and state sections in the /sync response, + // the encryption event would appear in both. + // If it's called more than twice though, + // it signals a bug on client or server. + const existingAlg = this.roomEncryptors.get(roomId); + if (existingAlg) { + return; + } + + // _roomList.getRoomEncryption will not race with _roomList.setRoomEncryption + // because it first stores in memory. We should await the promise only + // after all the in-memory state (roomEncryptors and _roomList) has been updated + // to avoid races when calling this method multiple times. Hence keep a hold of the promise. + let storeConfigPromise = null; + if (!existingConfig) { + storeConfigPromise = this.roomList.setRoomEncryption(roomId, config); + } + const AlgClass = algorithms.ENCRYPTION_CLASSES.get(config.algorithm); + if (!AlgClass) { + throw new Error("Unable to encrypt with " + config.algorithm); + } + const alg = new AlgClass({ + userId: this.userId, + deviceId: this.deviceId, + crypto: this, + olmDevice: this.olmDevice, + baseApis: this.baseApis, + roomId, + config + }); + this.roomEncryptors.set(roomId, alg); + if (storeConfigPromise) { + await storeConfigPromise; + } + _logger.logger.log(`Enabling encryption in ${roomId}`); + + // we don't want to force a download of the full membership list of this room, but as soon as we have that + // list we can start tracking the device list. + if (room.membersLoaded()) { + await this.trackRoomDevicesImpl(room); + } else { + // wait for the membership list to be loaded + const onState = _state => { + room.off(_roomState.RoomStateEvent.Update, onState); + if (room.membersLoaded()) { + this.trackRoomDevicesImpl(room).catch(e => { + _logger.logger.error(`Error enabling device tracking in ${roomId}`, e); + }); + } + }; + room.on(_roomState.RoomStateEvent.Update, onState); + } + } + + /** + * Make sure we are tracking the device lists for all users in this room. + * + * @param roomId - The room ID to start tracking devices in. + * @returns when all devices for the room have been fetched and marked to track + * @deprecated there's normally no need to call this function: device list tracking + * will be enabled as soon as we have the full membership list. + */ + trackRoomDevices(roomId) { + const room = this.clientStore.getRoom(roomId); + if (!room) { + throw new Error(`Unable to start tracking devices in unknown room ${roomId}`); + } + return this.trackRoomDevicesImpl(room); + } + + /** + * Make sure we are tracking the device lists for all users in this room. + * + * This is normally called when we are about to send an encrypted event, to make sure + * we have all the devices in the room; but it is also called when processing an + * m.room.encryption state event (if lazy-loading is disabled), or when members are + * loaded (if lazy-loading is enabled), to prepare the device list. + * + * @param room - Room to enable device-list tracking in + */ + trackRoomDevicesImpl(room) { + const roomId = room.roomId; + const trackMembers = async () => { + // not an encrypted room + if (!this.roomEncryptors.has(roomId)) { + return; + } + _logger.logger.log(`Starting to track devices for room ${roomId} ...`); + const members = await room.getEncryptionTargetMembers(); + members.forEach(m => { + this.deviceList.startTrackingDeviceList(m.userId); + }); + }; + let promise = this.roomDeviceTrackingState[roomId]; + if (!promise) { + promise = trackMembers(); + this.roomDeviceTrackingState[roomId] = promise.catch(err => { + delete this.roomDeviceTrackingState[roomId]; + throw err; + }); + } + return promise; + } + + /** + * Try to make sure we have established olm sessions for all known devices for + * the given users. + * + * @param users - list of user ids + * @param force - If true, force a new Olm session to be created. Default false. + * + * @returns resolves once the sessions are complete, to + * an Object mapping from userId to deviceId to + * `IOlmSessionResult` + */ + ensureOlmSessionsForUsers(users, force) { + // map user Id → DeviceInfo[] + const devicesByUser = new Map(); + for (const userId of users) { + const userDevices = []; + devicesByUser.set(userId, userDevices); + const devices = this.getStoredDevicesForUser(userId) || []; + for (const deviceInfo of devices) { + const key = deviceInfo.getIdentityKey(); + if (key == this.olmDevice.deviceCurve25519Key) { + // don't bother setting up session to ourself + continue; + } + if (deviceInfo.verified == DeviceVerification.BLOCKED) { + // don't bother setting up sessions with blocked users + continue; + } + userDevices.push(deviceInfo); + } + } + return olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser, force); + } + + /** + * Get a list containing all of the room keys + * + * @returns a list of session export objects + */ + async exportRoomKeys() { + const exportedSessions = []; + await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], txn => { + this.cryptoStore.getAllEndToEndInboundGroupSessions(txn, s => { + if (s === null) return; + const sess = this.olmDevice.exportInboundGroupSession(s.senderKey, s.sessionId, s.sessionData); + delete sess.first_known_index; + sess.algorithm = olmlib.MEGOLM_ALGORITHM; + exportedSessions.push(sess); + }); + }); + return exportedSessions; + } + + /** + * Import a list of room keys previously exported by exportRoomKeys + * + * @param keys - a list of session export objects + * @returns a promise which resolves once the keys have been imported + */ + importRoomKeys(keys, opts = {}) { + let successes = 0; + let failures = 0; + const total = keys.length; + function updateProgress() { + opts.progressCallback?.({ + stage: "load_keys", + successes, + failures, + total + }); + } + return Promise.all(keys.map(key => { + if (!key.room_id || !key.algorithm) { + _logger.logger.warn("ignoring room key entry with missing fields", key); + failures++; + if (opts.progressCallback) { + updateProgress(); + } + return null; + } + const alg = this.getRoomDecryptor(key.room_id, key.algorithm); + return alg.importRoomKey(key, opts).finally(() => { + successes++; + if (opts.progressCallback) { + updateProgress(); + } + }); + })).then(); + } + + /** + * Counts the number of end to end session keys that are waiting to be backed up + * @returns Promise which resolves to the number of sessions requiring backup + */ + countSessionsNeedingBackup() { + return this.backupManager.countSessionsNeedingBackup(); + } + + /** + * Perform any background tasks that can be done before a message is ready to + * send, in order to speed up sending of the message. + * + * @param room - the room the event is in + */ + prepareToEncrypt(room) { + const alg = this.roomEncryptors.get(room.roomId); + if (alg) { + alg.prepareToEncrypt(room); + } + } + + /** + * Encrypt an event according to the configuration of the room. + * + * @param event - event to be sent + * + * @param room - destination room. + * + * @returns Promise which resolves when the event has been + * encrypted, or null if nothing was needed + */ + async encryptEvent(event, room) { + const roomId = event.getRoomId(); + const alg = this.roomEncryptors.get(roomId); + if (!alg) { + // MatrixClient has already checked that this room should be encrypted, + // so this is an unexpected situation. + throw new Error("Room " + roomId + " was previously configured to use encryption, but is " + "no longer. Perhaps the homeserver is hiding the " + "configuration event."); + } + + // wait for all the room devices to be loaded + await this.trackRoomDevicesImpl(room); + let content = event.getContent(); + // If event has an m.relates_to then we need + // to put this on the wrapping event instead + const mRelatesTo = content["m.relates_to"]; + if (mRelatesTo) { + // Clone content here so we don't remove `m.relates_to` from the local-echo + content = Object.assign({}, content); + delete content["m.relates_to"]; + } + + // Treat element's performance metrics the same as `m.relates_to` (when present) + const elementPerfMetrics = content["io.element.performance_metrics"]; + if (elementPerfMetrics) { + content = Object.assign({}, content); + delete content["io.element.performance_metrics"]; + } + const encryptedContent = await alg.encryptMessage(room, event.getType(), content); + if (mRelatesTo) { + encryptedContent["m.relates_to"] = mRelatesTo; + } + if (elementPerfMetrics) { + encryptedContent["io.element.performance_metrics"] = elementPerfMetrics; + } + event.makeEncrypted("m.room.encrypted", encryptedContent, this.olmDevice.deviceCurve25519Key, this.olmDevice.deviceEd25519Key); + } + + /** + * Decrypt a received event + * + * + * @returns resolves once we have + * finished decrypting. Rejects with an `algorithms.DecryptionError` if there + * is a problem decrypting the event. + */ + async decryptEvent(event) { + if (event.isRedacted()) { + // Try to decrypt the redaction event, to support encrypted + // redaction reasons. If we can't decrypt, just fall back to using + // the original redacted_because. + const redactionEvent = new _event2.MatrixEvent(_objectSpread({ + room_id: event.getRoomId() + }, event.getUnsigned().redacted_because)); + let redactedBecause = event.getUnsigned().redacted_because; + if (redactionEvent.isEncrypted()) { + try { + const decryptedEvent = await this.decryptEvent(redactionEvent); + redactedBecause = decryptedEvent.clearEvent; + } catch (e) { + _logger.logger.warn("Decryption of redaction failed. Falling back to unencrypted event.", e); + } + } + return { + clearEvent: { + room_id: event.getRoomId(), + type: "m.room.message", + content: {}, + unsigned: { + redacted_because: redactedBecause + } + } + }; + } else { + const content = event.getWireContent(); + const alg = this.getRoomDecryptor(event.getRoomId(), content.algorithm); + return alg.decryptEvent(event); + } + } + + /** + * Handle the notification from /sync that device lists have + * been changed. + * + * @param deviceLists - device_lists field from /sync + */ + async processDeviceLists(deviceLists) { + // Here, we're relying on the fact that we only ever save the sync data after + // sucessfully saving the device list data, so we're guaranteed that the device + // list store is at least as fresh as the sync token from the sync store, ie. + // any device changes received in sync tokens prior to the 'next' token here + // have been processed and are reflected in the current device list. + // If we didn't make this assumption, we'd have to use the /keys/changes API + // to get key changes between the sync token in the device list and the 'old' + // sync token used here to make sure we didn't miss any. + await this.evalDeviceListChanges(deviceLists); + } + + /** + * Send a request for some room keys, if we have not already done so + * + * @param resend - whether to resend the key request if there is + * already one + * + * @returns a promise that resolves when the key request is queued + */ + requestRoomKey(requestBody, recipients, resend = false) { + return this.outgoingRoomKeyRequestManager.queueRoomKeyRequest(requestBody, recipients, resend).then(() => { + if (this.sendKeyRequestsImmediately) { + this.outgoingRoomKeyRequestManager.sendQueuedRequests(); + } + }).catch(e => { + // this normally means we couldn't talk to the store + _logger.logger.error("Error requesting key for event", e); + }); + } + + /** + * Cancel any earlier room key request + * + * @param requestBody - parameters to match for cancellation + */ + cancelRoomKeyRequest(requestBody) { + this.outgoingRoomKeyRequestManager.cancelRoomKeyRequest(requestBody).catch(e => { + _logger.logger.warn("Error clearing pending room key requests", e); + }); + } + + /** + * Re-send any outgoing key requests, eg after verification + * @returns + */ + async cancelAndResendAllOutgoingKeyRequests() { + await this.outgoingRoomKeyRequestManager.cancelAndResendAllOutgoingRequests(); + } + + /** + * handle an m.room.encryption event + * + * @param room - in which the event was received + * @param event - encryption event to be processed + */ + async onCryptoEvent(room, event) { + const content = event.getContent(); + await this.setRoomEncryptionImpl(room, content); + } + + /** + * Called before the result of a sync is processed + * + * @param syncData - the data from the 'MatrixClient.sync' event + */ + async onSyncWillProcess(syncData) { + if (!syncData.oldSyncToken) { + // If there is no old sync token, we start all our tracking from + // scratch, so mark everything as untracked. onCryptoEvent will + // be called for all e2e rooms during the processing of the sync, + // at which point we'll start tracking all the users of that room. + _logger.logger.log("Initial sync performed - resetting device tracking state"); + this.deviceList.stopTrackingAllDeviceLists(); + // we always track our own device list (for key backups etc) + this.deviceList.startTrackingDeviceList(this.userId); + this.roomDeviceTrackingState = {}; + } + this.sendKeyRequestsImmediately = false; + } + + /** + * handle the completion of a /sync + * + * This is called after the processing of each successful /sync response. + * It is an opportunity to do a batch process on the information received. + * + * @param syncData - the data from the 'MatrixClient.sync' event + */ + async onSyncCompleted(syncData) { + this.deviceList.setSyncToken(syncData.nextSyncToken ?? null); + this.deviceList.saveIfDirty(); + + // we always track our own device list (for key backups etc) + this.deviceList.startTrackingDeviceList(this.userId); + this.deviceList.refreshOutdatedDeviceLists(); + + // we don't start uploading one-time keys until we've caught up with + // to-device messages, to help us avoid throwing away one-time-keys that we + // are about to receive messages for + // (https://github.com/vector-im/element-web/issues/2782). + if (!syncData.catchingUp) { + this.maybeUploadOneTimeKeys(); + this.processReceivedRoomKeyRequests(); + + // likewise don't start requesting keys until we've caught up + // on to_device messages, otherwise we'll request keys that we're + // just about to get. + this.outgoingRoomKeyRequestManager.sendQueuedRequests(); + + // Sync has finished so send key requests straight away. + this.sendKeyRequestsImmediately = true; + } + } + + /** + * Trigger the appropriate invalidations and removes for a given + * device list + * + * @param deviceLists - device_lists field from /sync, or response from + * /keys/changes + */ + async evalDeviceListChanges(deviceLists) { + if (Array.isArray(deviceLists?.changed)) { + deviceLists.changed.forEach(u => { + this.deviceList.invalidateUserDeviceList(u); + }); + } + if (Array.isArray(deviceLists?.left) && deviceLists.left.length) { + // Check we really don't share any rooms with these users + // any more: the server isn't required to give us the + // exact correct set. + const e2eUserIds = new Set(await this.getTrackedE2eUsers()); + deviceLists.left.forEach(u => { + if (!e2eUserIds.has(u)) { + this.deviceList.stopTrackingDeviceList(u); + } + }); + } + } + + /** + * Get a list of all the IDs of users we share an e2e room with + * for which we are tracking devices already + * + * @returns List of user IDs + */ + async getTrackedE2eUsers() { + const e2eUserIds = []; + for (const room of this.getTrackedE2eRooms()) { + const members = await room.getEncryptionTargetMembers(); + for (const member of members) { + e2eUserIds.push(member.userId); + } + } + return e2eUserIds; + } + + /** + * Get a list of the e2e-enabled rooms we are members of, + * and for which we are already tracking the devices + * + * @returns + */ + getTrackedE2eRooms() { + return this.clientStore.getRooms().filter(room => { + // check for rooms with encryption enabled + const alg = this.roomEncryptors.get(room.roomId); + if (!alg) { + return false; + } + if (!this.roomDeviceTrackingState[room.roomId]) { + return false; + } + + // ignore any rooms which we have left + const myMembership = room.getMyMembership(); + return myMembership === "join" || myMembership === "invite"; + }); + } + + /** + * Encrypts and sends a given object via Olm to-device messages to a given + * set of devices. + * @param userDeviceInfoArr - the devices to send to + * @param payload - fields to include in the encrypted payload + * @returns Promise which + * resolves once the message has been encrypted and sent to the given + * userDeviceMap, and returns the `{ contentMap, deviceInfoByDeviceId }` + * of the successfully sent messages. + */ + async encryptAndSendToDevices(userDeviceInfoArr, payload) { + const toDeviceBatch = { + eventType: _event.EventType.RoomMessageEncrypted, + batch: [] + }; + try { + await Promise.all(userDeviceInfoArr.map(async ({ + userId, + deviceInfo + }) => { + const deviceId = deviceInfo.deviceId; + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + [_event.ToDeviceMessageId]: (0, _uuid.v4)() + }; + toDeviceBatch.batch.push({ + userId, + deviceId, + payload: encryptedContent + }); + await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, new Map([[userId, [deviceInfo]]])); + await olmlib.encryptMessageForDevice(encryptedContent.ciphertext, this.userId, this.deviceId, this.olmDevice, userId, deviceInfo, payload); + })); + + // prune out any devices that encryptMessageForDevice could not encrypt for, + // in which case it will have just not added anything to the ciphertext object. + // There's no point sending messages to devices if we couldn't encrypt to them, + // since that's effectively a blank message. + toDeviceBatch.batch = toDeviceBatch.batch.filter(msg => { + if (Object.keys(msg.payload.ciphertext).length > 0) { + return true; + } else { + _logger.logger.log(`No ciphertext for device ${msg.userId}:${msg.deviceId}: pruning`); + return false; + } + }); + try { + await this.baseApis.queueToDevice(toDeviceBatch); + } catch (e) { + _logger.logger.error("sendToDevice failed", e); + throw e; + } + } catch (e) { + _logger.logger.error("encryptAndSendToDevices promises failed", e); + throw e; + } + } + async preprocessToDeviceMessages(events) { + // all we do here is filter out encrypted to-device messages with the wrong algorithm. Decryption + // happens later in decryptEvent, via the EventMapper + return events.filter(toDevice => { + if (toDevice.type === _event.EventType.RoomMessageEncrypted && !["m.olm.v1.curve25519-aes-sha2"].includes(toDevice.content?.algorithm)) { + _logger.logger.log("Ignoring invalid encrypted to-device event from " + toDevice.sender); + return false; + } + return true; + }); + } + + /** + * Stores the current one_time_key count which will be handled later (in a call of + * onSyncCompleted). + * + * @param currentCount - The current count of one_time_keys to be stored + */ + updateOneTimeKeyCount(currentCount) { + if (isFinite(currentCount)) { + this.oneTimeKeyCount = currentCount; + } else { + throw new TypeError("Parameter for updateOneTimeKeyCount has to be a number"); + } + } + processKeyCounts(oneTimeKeysCounts, unusedFallbackKeys) { + if (oneTimeKeysCounts !== undefined) { + this.updateOneTimeKeyCount(oneTimeKeysCounts["signed_curve25519"] || 0); + } + if (unusedFallbackKeys !== undefined) { + // If `unusedFallbackKeys` is defined, that means `device_unused_fallback_key_types` + // is present in the sync response, which indicates that the server supports fallback keys. + // + // If there's no unused signed_curve25519 fallback key, we need a new one. + this.needsNewFallback = !unusedFallbackKeys.includes("signed_curve25519"); + } + return Promise.resolve(); + } + /** + * Handle a key event + * + * @internal + * @param event - key event + */ + onRoomKeyEvent(event) { + const content = event.getContent(); + if (!content.room_id || !content.algorithm) { + _logger.logger.error("key event is missing fields"); + return; + } + if (!this.backupManager.checkedForBackup) { + // don't bother awaiting on this - the important thing is that we retry if we + // haven't managed to check before + this.backupManager.checkAndStart(); + } + const alg = this.getRoomDecryptor(content.room_id, content.algorithm); + alg.onRoomKeyEvent(event); + } + + /** + * Handle a key withheld event + * + * @internal + * @param event - key withheld event + */ + onRoomKeyWithheldEvent(event) { + const content = event.getContent(); + if (content.code !== "m.no_olm" && (!content.room_id || !content.session_id) || !content.algorithm || !content.sender_key) { + _logger.logger.error("key withheld event is missing fields"); + return; + } + _logger.logger.info(`Got room key withheld event from ${event.getSender()} ` + `for ${content.algorithm} session ${content.sender_key}|${content.session_id} ` + `in room ${content.room_id} with code ${content.code} (${content.reason})`); + const alg = this.getRoomDecryptor(content.room_id, content.algorithm); + if (alg.onRoomKeyWithheldEvent) { + alg.onRoomKeyWithheldEvent(event); + } + if (!content.room_id) { + // retry decryption for all events sent by the sender_key. This will + // update the events to show a message indicating that the olm session was + // wedged. + const roomDecryptors = this.getRoomDecryptors(content.algorithm); + for (const decryptor of roomDecryptors) { + decryptor.retryDecryptionFromSender(content.sender_key); + } + } + } + + /** + * Handle a general key verification event. + * + * @internal + * @param event - verification start event + */ + onKeyVerificationMessage(event) { + if (!_ToDeviceChannel.ToDeviceChannel.validateEvent(event, this.baseApis)) { + return; + } + const createRequest = event => { + if (!_ToDeviceChannel.ToDeviceChannel.canCreateRequest(_ToDeviceChannel.ToDeviceChannel.getEventType(event))) { + return; + } + const content = event.getContent(); + const deviceId = content && content.from_device; + if (!deviceId) { + return; + } + const userId = event.getSender(); + const channel = new _ToDeviceChannel.ToDeviceChannel(this.baseApis, userId, [deviceId]); + return new _VerificationRequest.VerificationRequest(channel, this.verificationMethods, this.baseApis); + }; + this.handleVerificationEvent(event, this.toDeviceVerificationRequests, createRequest); + } + async handleVerificationEvent(event, requestsMap, createRequest, isLiveEvent = true) { + // Wait for event to get its final ID with pendingEventOrdering: "chronological", since DM channels depend on it. + if (event.isSending() && event.status != _event2.EventStatus.SENT) { + let eventIdListener; + let statusListener; + try { + await new Promise((resolve, reject) => { + eventIdListener = resolve; + statusListener = () => { + if (event.status == _event2.EventStatus.CANCELLED) { + reject(new Error("Event status set to CANCELLED.")); + } + }; + event.once(_event2.MatrixEventEvent.LocalEventIdReplaced, eventIdListener); + event.on(_event2.MatrixEventEvent.Status, statusListener); + }); + } catch (err) { + _logger.logger.error("error while waiting for the verification event to be sent: ", err); + return; + } finally { + event.removeListener(_event2.MatrixEventEvent.LocalEventIdReplaced, eventIdListener); + event.removeListener(_event2.MatrixEventEvent.Status, statusListener); + } + } + let request = requestsMap.getRequest(event); + let isNewRequest = false; + if (!request) { + request = createRequest(event); + // a request could not be made from this event, so ignore event + if (!request) { + _logger.logger.log(`Crypto: could not find VerificationRequest for ` + `${event.getType()}, and could not create one, so ignoring.`); + return; + } + isNewRequest = true; + requestsMap.setRequest(event, request); + } + event.setVerificationRequest(request); + try { + await request.channel.handleEvent(event, request, isLiveEvent); + } catch (err) { + _logger.logger.error("error while handling verification event", err); + } + const shouldEmit = isNewRequest && !request.initiatedByMe && !request.invalid && + // check it has enough events to pass the UNSENT stage + !request.observeOnly; + if (shouldEmit) { + this.baseApis.emit(CryptoEvent.VerificationRequest, request); + } + } + + /** + * Handle a toDevice event that couldn't be decrypted + * + * @internal + * @param event - undecryptable event + */ + async onToDeviceBadEncrypted(event) { + const content = event.getWireContent(); + const sender = event.getSender(); + const algorithm = content.algorithm; + const deviceKey = content.sender_key; + this.baseApis.emit(_client.ClientEvent.UndecryptableToDeviceEvent, event); + + // retry decryption for all events sent by the sender_key. This will + // update the events to show a message indicating that the olm session was + // wedged. + const retryDecryption = () => { + const roomDecryptors = this.getRoomDecryptors(olmlib.MEGOLM_ALGORITHM); + for (const decryptor of roomDecryptors) { + decryptor.retryDecryptionFromSender(deviceKey); + } + }; + if (sender === undefined || deviceKey === undefined || deviceKey === undefined) { + return; + } + + // check when we last forced a new session with this device: if we've already done so + // recently, don't do it again. + const lastNewSessionDevices = this.lastNewSessionForced.getOrCreate(sender); + const lastNewSessionForced = lastNewSessionDevices.getOrCreate(deviceKey); + if (lastNewSessionForced + MIN_FORCE_SESSION_INTERVAL_MS > Date.now()) { + _logger.logger.debug("New session already forced with device " + sender + ":" + deviceKey + " at " + lastNewSessionForced + ": not forcing another"); + await this.olmDevice.recordSessionProblem(deviceKey, "wedged", true); + retryDecryption(); + return; + } + + // establish a new olm session with this device since we're failing to decrypt messages + // on a current session. + // Note that an undecryptable message from another device could easily be spoofed - + // is there anything we can do to mitigate this? + let device = this.deviceList.getDeviceByIdentityKey(algorithm, deviceKey); + if (!device) { + // if we don't know about the device, fetch the user's devices again + // and retry before giving up + await this.downloadKeys([sender], false); + device = this.deviceList.getDeviceByIdentityKey(algorithm, deviceKey); + if (!device) { + _logger.logger.info("Couldn't find device for identity key " + deviceKey + ": not re-establishing session"); + await this.olmDevice.recordSessionProblem(deviceKey, "wedged", false); + retryDecryption(); + return; + } + } + const devicesByUser = new Map([[sender, [device]]]); + await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser, true); + lastNewSessionDevices.set(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: {}, + [_event.ToDeviceMessageId]: (0, _uuid.v4)() + }; + await olmlib.encryptMessageForDevice(encryptedContent.ciphertext, this.userId, this.deviceId, this.olmDevice, sender, device, { + type: "m.dummy" + }); + await this.olmDevice.recordSessionProblem(deviceKey, "wedged", true); + retryDecryption(); + await this.baseApis.sendToDevice("m.room.encrypted", new Map([[sender, new Map([[device.deviceId, encryptedContent]])]])); + + // Most of the time this probably won't be necessary since we'll have queued up a key request when + // we failed to decrypt the message and will be waiting a bit for the key to arrive before sending + // it. This won't always be the case though so we need to re-send any that have already been sent + // to avoid races. + const requestsToResend = await this.outgoingRoomKeyRequestManager.getOutgoingSentRoomKeyRequest(sender, device.deviceId); + for (const keyReq of requestsToResend) { + this.requestRoomKey(keyReq.requestBody, keyReq.recipients, true); + } + } + + /** + * Handle a change in the membership state of a member of a room + * + * @internal + * @param event - event causing the change + * @param member - user whose membership changed + * @param oldMembership - previous membership + */ + onRoomMembership(event, member, oldMembership) { + // this event handler is registered on the *client* (as opposed to the room + // member itself), which means it is only called on changes to the *live* + // membership state (ie, it is not called when we back-paginate, nor when + // we load the state in the initialsync). + // + // Further, it is automatically registered and called when new members + // arrive in the room. + + const roomId = member.roomId; + const alg = this.roomEncryptors.get(roomId); + if (!alg) { + // not encrypting in this room + return; + } + // only mark users in this room as tracked if we already started tracking in this room + // this way we don't start device queries after sync on behalf of this room which we won't use + // the result of anyway, as we'll need to do a query again once all the members are fetched + // by calling _trackRoomDevices + if (roomId in this.roomDeviceTrackingState) { + if (member.membership == "join") { + _logger.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.logger.log("Invite event for " + member.userId + " in " + roomId); + this.deviceList.startTrackingDeviceList(member.userId); + } + } + alg.onRoomMembership(event, member, oldMembership); + } + + /** + * Called when we get an m.room_key_request event. + * + * @internal + * @param event - key request event + */ + onRoomKeyRequestEvent(event) { + const content = event.getContent(); + if (content.action === "request") { + // Queue it up for now, because they tend to arrive before the room state + // events at initial sync, and we want to see if we know anything about the + // room before passing them on to the app. + const req = new IncomingRoomKeyRequest(event); + this.receivedRoomKeyRequests.push(req); + } else if (content.action === "request_cancellation") { + const req = new IncomingRoomKeyRequestCancellation(event); + this.receivedRoomKeyRequestCancellations.push(req); + } + } + + /** + * Process any m.room_key_request events which were queued up during the + * current sync. + * + * @internal + */ + async processReceivedRoomKeyRequests() { + if (this.processingRoomKeyRequests) { + // we're still processing last time's requests; keep queuing new ones + // up for now. + return; + } + this.processingRoomKeyRequests = true; + try { + // we need to grab and clear the queues in the synchronous bit of this method, + // so that we don't end up racing with the next /sync. + const requests = this.receivedRoomKeyRequests; + this.receivedRoomKeyRequests = []; + const cancellations = this.receivedRoomKeyRequestCancellations; + this.receivedRoomKeyRequestCancellations = []; + + // Process all of the requests, *then* all of the cancellations. + // + // This makes sure that if we get a request and its cancellation in the + // same /sync result, then we process the request before the + // cancellation (and end up with a cancelled request), rather than the + // cancellation before the request (and end up with an outstanding + // request which should have been cancelled.) + await Promise.all(requests.map(req => this.processReceivedRoomKeyRequest(req))); + await Promise.all(cancellations.map(cancellation => this.processReceivedRoomKeyRequestCancellation(cancellation))); + } catch (e) { + _logger.logger.error(`Error processing room key requsts: ${e}`); + } finally { + this.processingRoomKeyRequests = false; + } + } + + /** + * Helper for processReceivedRoomKeyRequests + * + */ + async processReceivedRoomKeyRequest(req) { + const userId = req.userId; + const deviceId = req.deviceId; + const body = req.requestBody; + const roomId = body.room_id; + const alg = body.algorithm; + _logger.logger.log(`m.room_key_request from ${userId}:${deviceId}` + ` for ${roomId} / ${body.session_id} (id ${req.requestId})`); + if (userId !== this.userId) { + if (!this.roomEncryptors.get(roomId)) { + _logger.logger.debug(`room key request for unencrypted room ${roomId}`); + return; + } + const encryptor = this.roomEncryptors.get(roomId); + const device = this.deviceList.getStoredDevice(userId, deviceId); + if (!device) { + _logger.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.logger.warn("Failed to re-share keys for session " + body.session_id + " with device " + userId + ":" + device.deviceId, e); + } + return; + } + if (deviceId === this.deviceId) { + // We'll always get these because we send room key requests to + // '*' (ie. 'all devices') which includes the sending device, + // so ignore requests from ourself because apart from it being + // very silly, it won't work because an Olm session cannot send + // messages to itself. + // The log here is probably superfluous since we know this will + // always happen, but let's log anyway for now just in case it + // causes issues. + _logger.logger.log("Ignoring room key request from ourselves"); + return; + } + + // todo: should we queue up requests we don't yet have keys for, + // in case they turn up later? + + // if we don't have a decryptor for this room/alg, we don't have + // the keys for the requested events, and can drop the requests. + if (!this.roomDecryptors.has(roomId)) { + _logger.logger.log(`room key request for unencrypted room ${roomId}`); + return; + } + const decryptor = this.roomDecryptors.get(roomId).get(alg); + if (!decryptor) { + _logger.logger.log(`room key request for unknown alg ${alg} in room ${roomId}`); + return; + } + if (!(await decryptor.hasKeysForKeyRequest(req))) { + _logger.logger.log(`room key request for unknown session ${roomId} / ` + body.session_id); + return; + } + req.share = () => { + decryptor.shareKeysWithDevice(req); + }; + + // if the device is verified already, share the keys + if (this.checkDeviceTrust(userId, deviceId).isVerified()) { + _logger.logger.log("device is already verified: sharing keys"); + req.share(); + return; + } + this.emit(CryptoEvent.RoomKeyRequest, req); + } + + /** + * Helper for processReceivedRoomKeyRequests + * + */ + async processReceivedRoomKeyRequestCancellation(cancellation) { + _logger.logger.log(`m.room_key_request cancellation for ${cancellation.userId}:` + `${cancellation.deviceId} (id ${cancellation.requestId})`); + + // we should probably only notify the app of cancellations we told it + // about, but we don't currently have a record of that, so we just pass + // everything through. + this.emit(CryptoEvent.RoomKeyRequestCancellation, cancellation); + } + + /** + * Get a decryptor for a given room and algorithm. + * + * If we already have a decryptor for the given room and algorithm, return + * it. Otherwise try to instantiate it. + * + * @internal + * + * @param roomId - room id for decryptor. If undefined, a temporary + * decryptor is instantiated. + * + * @param algorithm - crypto algorithm + * + * @throws `DecryptionError` if the algorithm is unknown + */ + getRoomDecryptor(roomId, algorithm) { + let decryptors; + let alg; + if (roomId) { + decryptors = this.roomDecryptors.get(roomId); + if (!decryptors) { + decryptors = new Map(); + this.roomDecryptors.set(roomId, decryptors); + } + alg = decryptors.get(algorithm); + if (alg) { + return alg; + } + } + const AlgClass = algorithms.DECRYPTION_CLASSES.get(algorithm); + if (!AlgClass) { + throw new algorithms.DecryptionError("UNKNOWN_ENCRYPTION_ALGORITHM", 'Unknown encryption algorithm "' + algorithm + '".'); + } + alg = new AlgClass({ + userId: this.userId, + crypto: this, + olmDevice: this.olmDevice, + baseApis: this.baseApis, + roomId: roomId ?? undefined + }); + if (decryptors) { + decryptors.set(algorithm, alg); + } + return alg; + } + + /** + * Get all the room decryptors for a given encryption algorithm. + * + * @param algorithm - The encryption algorithm + * + * @returns An array of room decryptors + */ + getRoomDecryptors(algorithm) { + const decryptors = []; + for (const d of this.roomDecryptors.values()) { + if (d.has(algorithm)) { + decryptors.push(d.get(algorithm)); + } + } + return decryptors; + } + + /** + * sign the given object with our ed25519 key + * + * @param obj - Object to which we will add a 'signatures' property + */ + async signObject(obj) { + const sigs = new Map(Object.entries(obj.signatures || {})); + const unsigned = obj.unsigned; + delete obj.signatures; + delete obj.unsigned; + const userSignatures = sigs.get(this.userId) || {}; + sigs.set(this.userId, userSignatures); + userSignatures["ed25519:" + this.deviceId] = await this.olmDevice.sign(_anotherJson.default.stringify(obj)); + obj.signatures = (0, _utils.recursiveMapToObject)(sigs); + if (unsigned !== undefined) obj.unsigned = unsigned; + } +} + +/** + * Fix up the backup key, that may be in the wrong format due to a bug in a + * migration step. Some backup keys were stored as a comma-separated list of + * integers, rather than a base64-encoded byte array. If this function is + * passed a string that looks like a list of integers rather than a base64 + * string, it will attempt to convert it to the right format. + * + * @param key - the key to check + * @returns If the key is in the wrong format, then the fixed + * key will be returned. Otherwise null will be returned. + * + */ +exports.Crypto = Crypto; +function fixBackupKey(key) { + if (typeof key !== "string" || key.indexOf(",") < 0) { + return null; + } + const fixedKey = Uint8Array.from(key.split(","), x => parseInt(x)); + return olmlib.encodeBase64(fixedKey); +} + +/** + * Represents a received m.room_key_request event + */ +class IncomingRoomKeyRequest { + constructor(event) { + /** user requesting the key */ + _defineProperty(this, "userId", void 0); + /** device requesting the key */ + _defineProperty(this, "deviceId", void 0); + /** unique id for the request */ + _defineProperty(this, "requestId", void 0); + _defineProperty(this, "requestBody", void 0); + /** + * callback which, when called, will ask + * the relevant crypto algorithm implementation to share the keys for + * this request. + */ + _defineProperty(this, "share", void 0); + const content = event.getContent(); + this.userId = event.getSender(); + this.deviceId = content.requesting_device_id; + this.requestId = content.request_id; + this.requestBody = content.body || {}; + this.share = () => { + throw new Error("don't know how to share keys for this request yet"); + }; + } +} + +/** + * Represents a received m.room_key_request cancellation + */ +exports.IncomingRoomKeyRequest = IncomingRoomKeyRequest; +class IncomingRoomKeyRequestCancellation { + constructor(event) { + /** user requesting the cancellation */ + _defineProperty(this, "userId", void 0); + /** device requesting the cancellation */ + _defineProperty(this, "deviceId", void 0); + /** unique id for the request to be cancelled */ + _defineProperty(this, "requestId", void 0); + const content = event.getContent(); + this.userId = event.getSender(); + this.deviceId = content.requesting_device_id; + this.requestId = content.request_id; + } +} + +// a number of types are re-exported for backwards compatibility, in case any applications are referencing it.
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/key_passphrase.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/key_passphrase.js new file mode 100644 index 0000000000..3f4d3bbcba --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/key_passphrase.js @@ -0,0 +1,69 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.deriveKey = deriveKey; +exports.keyFromAuthData = keyFromAuthData; +exports.keyFromPassphrase = keyFromPassphrase; +var _randomstring = require("../randomstring"); +var _crypto = require("./crypto"); +/* +Copyright 2018 - 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +const DEFAULT_ITERATIONS = 500000; +const DEFAULT_BITSIZE = 256; + +/* eslint-disable camelcase */ + +/* eslint-enable camelcase */ + +function keyFromAuthData(authData, password) { + if (!global.Olm) { + throw new Error("Olm is not available"); + } + if (!authData.private_key_salt || !authData.private_key_iterations) { + throw new Error("Salt and/or iterations not found: " + "this backup cannot be restored with a passphrase"); + } + return deriveKey(password, authData.private_key_salt, authData.private_key_iterations, authData.private_key_bits || DEFAULT_BITSIZE); +} +async function keyFromPassphrase(password) { + if (!global.Olm) { + throw new Error("Olm is not available"); + } + const salt = (0, _randomstring.randomString)(32); + const key = await deriveKey(password, salt, DEFAULT_ITERATIONS, DEFAULT_BITSIZE); + return { + key, + salt, + iterations: DEFAULT_ITERATIONS + }; +} +async function deriveKey(password, salt, iterations, numBits = DEFAULT_BITSIZE) { + if (!_crypto.subtleCrypto || !_crypto.TextEncoder) { + throw new Error("Password-based backup is not available on this platform"); + } + const key = await _crypto.subtleCrypto.importKey("raw", new _crypto.TextEncoder().encode(password), { + name: "PBKDF2" + }, false, ["deriveBits"]); + const keybits = await _crypto.subtleCrypto.deriveBits({ + name: "PBKDF2", + salt: new _crypto.TextEncoder().encode(salt), + iterations: iterations, + hash: "SHA-512" + }, key, numBits); + return new Uint8Array(keybits); +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/keybackup.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/keybackup.js new file mode 100644 index 0000000000..430afc16cd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/keybackup.js @@ -0,0 +1,5 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/olmlib.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/olmlib.js new file mode 100644 index 0000000000..ea397f0c0e --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/olmlib.js @@ -0,0 +1,480 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.OLM_ALGORITHM = exports.MEGOLM_BACKUP_ALGORITHM = exports.MEGOLM_ALGORITHM = void 0; +exports.decodeBase64 = decodeBase64; +exports.encodeBase64 = encodeBase64; +exports.encodeUnpaddedBase64 = encodeUnpaddedBase64; +exports.encryptMessageForDevice = encryptMessageForDevice; +exports.ensureOlmSessionsForDevices = ensureOlmSessionsForDevices; +exports.getExistingOlmSessions = getExistingOlmSessions; +exports.isOlmEncrypted = isOlmEncrypted; +exports.pkSign = pkSign; +exports.pkVerify = pkVerify; +exports.verifySignature = verifySignature; +var _anotherJson = _interopRequireDefault(require("another-json")); +var _logger = require("../logger"); +var _event = require("../@types/event"); +var _utils = require("../utils"); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2016 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ /** + * Utilities common to olm encryption algorithms + */ +var Algorithm = /*#__PURE__*/function (Algorithm) { + Algorithm["Olm"] = "m.olm.v1.curve25519-aes-sha2"; + Algorithm["Megolm"] = "m.megolm.v1.aes-sha2"; + Algorithm["MegolmBackup"] = "m.megolm_backup.v1.curve25519-aes-sha2"; + return Algorithm; +}(Algorithm || {}); +/** + * matrix algorithm tag for olm + */ +const OLM_ALGORITHM = Algorithm.Olm; + +/** + * matrix algorithm tag for megolm + */ +exports.OLM_ALGORITHM = OLM_ALGORITHM; +const MEGOLM_ALGORITHM = Algorithm.Megolm; + +/** + * matrix algorithm tag for megolm backups + */ +exports.MEGOLM_ALGORITHM = MEGOLM_ALGORITHM; +const MEGOLM_BACKUP_ALGORITHM = Algorithm.MegolmBackup; +exports.MEGOLM_BACKUP_ALGORITHM = MEGOLM_BACKUP_ALGORITHM; +/** + * Encrypt an event payload for an Olm device + * + * @param resultsObject - The `ciphertext` property + * of the m.room.encrypted event to which to add our result + * + * @param olmDevice - olm.js wrapper + * @param payloadFields - fields to include in the encrypted payload + * + * Returns a promise which resolves (to undefined) when the payload + * has been encrypted into `resultsObject` + */ +async function encryptMessageForDevice(resultsObject, ourUserId, ourDeviceId, olmDevice, recipientUserId, recipientDevice, payloadFields) { + const deviceKey = recipientDevice.getIdentityKey(); + const sessionId = await olmDevice.getSessionIdForDevice(deviceKey); + if (sessionId === null) { + // If we don't have a session for a device then + // we can't encrypt a message for it. + _logger.logger.log(`[olmlib.encryptMessageForDevice] Unable to find Olm session for device ` + `${recipientUserId}:${recipientDevice.deviceId}`); + return; + } + _logger.logger.log(`[olmlib.encryptMessageForDevice] Using Olm session ${sessionId} for device ` + `${recipientUserId}:${recipientDevice.deviceId}`); + const payload = _objectSpread({ + sender: ourUserId, + // TODO this appears to no longer be used whatsoever + sender_device: ourDeviceId, + // Include the Ed25519 key so that the recipient knows what + // device this message came from. + // We don't need to include the curve25519 key since the + // recipient will already know this from the olm headers. + // When combined with the device keys retrieved from the + // homeserver signed by the ed25519 key this proves that + // the curve25519 key and the ed25519 key are owned by + // the same device. + keys: { + ed25519: olmDevice.deviceEd25519Key + }, + // include the recipient device details in the payload, + // to avoid unknown key attacks, per + // https://github.com/vector-im/vector-web/issues/2483 + recipient: recipientUserId, + recipient_keys: { + ed25519: recipientDevice.getFingerprint() + } + }, payloadFields); + + // TODO: technically, a bunch of that stuff only needs to be included for + // pre-key messages: after that, both sides know exactly which devices are + // involved in the session. If we're looking to reduce data transfer in the + // future, we could elide them for subsequent messages. + + resultsObject[deviceKey] = await olmDevice.encryptMessage(deviceKey, sessionId, JSON.stringify(payload)); +} +/** + * Get the existing olm sessions for the given devices, and the devices that + * don't have olm sessions. + * + * + * + * @param devicesByUser - map from userid to list of devices to ensure sessions for + * + * @returns resolves to an array. The first element of the array is a + * a map of user IDs to arrays of deviceInfo, representing the devices that + * don't have established olm sessions. The second element of the array is + * a map from userId to deviceId to {@link OlmSessionResult} + */ +async function getExistingOlmSessions(olmDevice, baseApis, devicesByUser) { + // map user Id → DeviceInfo[] + const devicesWithoutSession = new _utils.MapWithDefault(() => []); + // map user Id → device Id → IExistingOlmSession + const sessions = new _utils.MapWithDefault(() => new Map()); + const promises = []; + for (const [userId, devices] of Object.entries(devicesByUser)) { + for (const deviceInfo of devices) { + const deviceId = deviceInfo.deviceId; + const key = deviceInfo.getIdentityKey(); + promises.push((async () => { + const sessionId = await olmDevice.getSessionIdForDevice(key, true); + if (sessionId === null) { + devicesWithoutSession.getOrCreate(userId).push(deviceInfo); + } else { + sessions.getOrCreate(userId).set(deviceId, { + device: deviceInfo, + sessionId: sessionId + }); + } + })()); + } + } + await Promise.all(promises); + return [devicesWithoutSession, sessions]; +} + +/** + * Try to make sure we have established olm sessions for the given devices. + * + * @param devicesByUser - map from userid to list of devices to ensure sessions for + * + * @param force - If true, establish a new session even if one + * already exists. + * + * @param otkTimeout - The timeout in milliseconds when requesting + * one-time keys for establishing new olm sessions. + * + * @param failedServers - An array to fill with remote servers that + * failed to respond to one-time-key requests. + * + * @param log - A possibly customised log + * + * @returns resolves once the sessions are complete, to + * an Object mapping from userId to deviceId to + * {@link OlmSessionResult} + */ +async function ensureOlmSessionsForDevices(olmDevice, baseApis, devicesByUser, force = false, otkTimeout, failedServers, log = _logger.logger) { + const devicesWithoutSession = [ + // [userId, deviceId], ... + ]; + // map user Id → device Id → IExistingOlmSession + const result = new Map(); + // map device key → resolve session fn + const resolveSession = new Map(); + + // Mark all sessions this task intends to update as in progress. It is + // important to do this for all devices this task cares about in a single + // synchronous operation, as otherwise it is possible to have deadlocks + // where multiple tasks wait indefinitely on another task to update some set + // of common devices. + for (const devices of devicesByUser.values()) { + for (const deviceInfo of devices) { + const key = deviceInfo.getIdentityKey(); + if (key === olmDevice.deviceCurve25519Key) { + // We don't start sessions with ourself, so there's no need to + // mark it in progress. + continue; + } + if (!olmDevice.sessionsInProgress[key]) { + // pre-emptively mark the session as in-progress to avoid race + // conditions. If we find that we already have a session, then + // we'll resolve + olmDevice.sessionsInProgress[key] = new Promise(resolve => { + resolveSession.set(key, v => { + delete olmDevice.sessionsInProgress[key]; + resolve(v); + }); + }); + } + } + } + for (const [userId, devices] of devicesByUser) { + const resultDevices = new Map(); + result.set(userId, resultDevices); + for (const deviceInfo of devices) { + const deviceId = deviceInfo.deviceId; + const key = deviceInfo.getIdentityKey(); + if (key === olmDevice.deviceCurve25519Key) { + // We should never be trying to start a session with ourself. + // Apart from talking to yourself being the first sign of madness, + // olm sessions can't do this because they get confused when + // they get a message and see that the 'other side' has started a + // new chain when this side has an active sender chain. + // If you see this message being logged in the wild, we should find + // the thing that is trying to send Olm messages to itself and fix it. + log.info("Attempted to start session with ourself! Ignoring"); + // We must fill in the section in the return value though, as callers + // expect it to be there. + resultDevices.set(deviceId, { + device: deviceInfo, + sessionId: null + }); + continue; + } + const forWhom = `for ${key} (${userId}:${deviceId})`; + const sessionId = await olmDevice.getSessionIdForDevice(key, !!resolveSession.get(key), log); + const resolveSessionFn = resolveSession.get(key); + if (sessionId !== null && resolveSessionFn) { + // we found a session, but we had marked the session as + // in-progress, so resolve it now, which will unmark it and + // unblock anything that was waiting + resolveSessionFn(); + } + if (sessionId === null || force) { + if (force) { + log.info(`Forcing new Olm session ${forWhom}`); + } else { + log.info(`Making new Olm session ${forWhom}`); + } + devicesWithoutSession.push([userId, deviceId]); + } + resultDevices.set(deviceId, { + device: deviceInfo, + sessionId: sessionId + }); + } + } + if (devicesWithoutSession.length === 0) { + return result; + } + const oneTimeKeyAlgorithm = "signed_curve25519"; + let res; + let taskDetail = `one-time keys for ${devicesWithoutSession.length} devices`; + try { + log.debug(`Claiming ${taskDetail}`); + res = await baseApis.claimOneTimeKeys(devicesWithoutSession, oneTimeKeyAlgorithm, otkTimeout); + log.debug(`Claimed ${taskDetail}`); + } catch (e) { + for (const resolver of resolveSession.values()) { + resolver(); + } + log.log(`Failed to claim ${taskDetail}`, e, devicesWithoutSession); + throw e; + } + if (failedServers && "failures" in res) { + failedServers.push(...Object.keys(res.failures)); + } + const otkResult = res.one_time_keys || {}; + const promises = []; + for (const [userId, devices] of devicesByUser) { + const userRes = otkResult[userId] || {}; + for (const deviceInfo of devices) { + const deviceId = deviceInfo.deviceId; + const key = deviceInfo.getIdentityKey(); + if (key === olmDevice.deviceCurve25519Key) { + // We've already logged about this above. Skip here too + // otherwise we'll log saying there are no one-time keys + // which will be confusing. + continue; + } + if (result.get(userId)?.get(deviceId)?.sessionId && !force) { + // we already have a result for this device + continue; + } + const deviceRes = userRes[deviceId] || {}; + let oneTimeKey = null; + for (const keyId in deviceRes) { + if (keyId.indexOf(oneTimeKeyAlgorithm + ":") === 0) { + oneTimeKey = deviceRes[keyId]; + } + } + if (!oneTimeKey) { + log.warn(`No one-time keys (alg=${oneTimeKeyAlgorithm}) ` + `for device ${userId}:${deviceId}`); + resolveSession.get(key)?.(); + continue; + } + promises.push(_verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceInfo).then(sid => { + resolveSession.get(key)?.(sid ?? undefined); + const deviceInfo = result.get(userId)?.get(deviceId); + if (deviceInfo) deviceInfo.sessionId = sid; + }, e => { + resolveSession.get(key)?.(); + throw e; + })); + } + } + taskDetail = `Olm sessions for ${promises.length} devices`; + log.debug(`Starting ${taskDetail}`); + await Promise.all(promises); + log.debug(`Started ${taskDetail}`); + return result; +} +async function _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceInfo) { + const deviceId = deviceInfo.deviceId; + try { + await verifySignature(olmDevice, oneTimeKey, userId, deviceId, deviceInfo.getFingerprint()); + } catch (e) { + _logger.logger.error("Unable to verify signature on one-time key for device " + userId + ":" + deviceId + ":", e); + return null; + } + let sid; + try { + sid = await olmDevice.createOutboundSession(deviceInfo.getIdentityKey(), oneTimeKey.key); + } catch (e) { + // possibly a bad key + _logger.logger.error("Error starting olm session with device " + userId + ":" + deviceId + ": " + e); + return null; + } + _logger.logger.log("Started new olm sessionid " + sid + " for device " + userId + ":" + deviceId); + return sid; +} +/** + * Verify the signature on an object + * + * @param olmDevice - olm wrapper to use for verify op + * + * @param obj - object to check signature on. + * + * @param signingUserId - ID of the user whose signature should be checked + * + * @param signingDeviceId - ID of the device whose signature should be checked + * + * @param signingKey - base64-ed ed25519 public key + * + * Returns a promise which resolves (to undefined) if the the signature is good, + * or rejects with an Error if it is bad. + */ +async function verifySignature(olmDevice, obj, signingUserId, signingDeviceId, signingKey) { + const signKeyId = "ed25519:" + signingDeviceId; + const signatures = obj.signatures || {}; + const userSigs = signatures[signingUserId] || {}; + const signature = userSigs[signKeyId]; + if (!signature) { + throw Error("No signature"); + } + + // prepare the canonical json: remove unsigned and signatures, and stringify with anotherjson + const mangledObj = Object.assign({}, obj); + if ("unsigned" in mangledObj) { + delete mangledObj.unsigned; + } + delete mangledObj.signatures; + const json = _anotherJson.default.stringify(mangledObj); + olmDevice.verifySignature(signingKey, json, signature); +} + +/** + * Sign a JSON object using public key cryptography + * @param obj - Object to sign. The object will be modified to include + * the new signature + * @param key - the signing object or the private key + * seed + * @param userId - The user ID who owns the signing key + * @param pubKey - The public key (ignored if key is a seed) + * @returns the signature for the object + */ +function pkSign(obj, key, userId, pubKey) { + let createdKey = false; + if (key instanceof Uint8Array) { + const keyObj = new global.Olm.PkSigning(); + pubKey = keyObj.init_with_seed(key); + key = keyObj; + createdKey = true; + } + const sigs = obj.signatures || {}; + delete obj.signatures; + const unsigned = obj.unsigned; + if (obj.unsigned) delete obj.unsigned; + try { + const mysigs = sigs[userId] || {}; + sigs[userId] = mysigs; + return mysigs["ed25519:" + pubKey] = key.sign(_anotherJson.default.stringify(obj)); + } finally { + obj.signatures = sigs; + if (unsigned) obj.unsigned = unsigned; + if (createdKey) { + key.free(); + } + } +} + +/** + * Verify a signed JSON object + * @param obj - Object to verify + * @param pubKey - The public key to use to verify + * @param userId - The user ID who signed the object + */ +function pkVerify(obj, pubKey, userId) { + const keyId = "ed25519:" + pubKey; + if (!(obj.signatures && obj.signatures[userId] && obj.signatures[userId][keyId])) { + throw new Error("No signature"); + } + const signature = obj.signatures[userId][keyId]; + const util = new global.Olm.Utility(); + const sigs = obj.signatures; + delete obj.signatures; + const unsigned = obj.unsigned; + if (obj.unsigned) delete obj.unsigned; + try { + util.ed25519_verify(pubKey, _anotherJson.default.stringify(obj), signature); + } finally { + obj.signatures = sigs; + if (unsigned) obj.unsigned = unsigned; + util.free(); + } +} + +/** + * Check that an event was encrypted using olm. + */ +function isOlmEncrypted(event) { + if (!event.getSenderKey()) { + _logger.logger.error("Event has no sender key (not encrypted?)"); + return false; + } + if (event.getWireType() !== _event.EventType.RoomMessageEncrypted || !["m.olm.v1.curve25519-aes-sha2"].includes(event.getWireContent().algorithm)) { + _logger.logger.error("Event was not encrypted using an appropriate algorithm"); + return false; + } + return true; +} + +/** + * Encode a typed array of uint8 as base64. + * @param uint8Array - The data to encode. + * @returns The base64. + */ +function encodeBase64(uint8Array) { + return Buffer.from(uint8Array).toString("base64"); +} + +/** + * Encode a typed array of uint8 as unpadded base64. + * @param uint8Array - The data to encode. + * @returns The unpadded base64. + */ +function encodeUnpaddedBase64(uint8Array) { + return encodeBase64(uint8Array).replace(/=+$/g, ""); +} + +/** + * Decode a base64 string to a typed array of uint8. + * @param base64 - The base64 to decode. + * @returns The decoded data. + */ +function decodeBase64(base64) { + return Buffer.from(base64, "base64"); +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/recoverykey.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/recoverykey.js new file mode 100644 index 0000000000..a2a75618cb --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/recoverykey.js @@ -0,0 +1,60 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.decodeRecoveryKey = decodeRecoveryKey; +exports.encodeRecoveryKey = encodeRecoveryKey; +var bs58 = _interopRequireWildcard(require("bs58")); +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } +/* +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. +*/ + +// 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]; +function encodeRecoveryKey(key) { + const buf = Buffer.alloc(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(" "); +} +function decodeRecoveryKey(recoveryKey) { + const result = bs58.decode(recoveryKey.replace(/ /g, "")); + let parity = 0; + for (const b of result) { + parity ^= b; + } + if (parity !== 0) { + throw new Error("Incorrect parity"); + } + for (let i = 0; i < OLM_RECOVERY_KEY_PREFIX.length; ++i) { + if (result[i] !== OLM_RECOVERY_KEY_PREFIX[i]) { + throw new Error("Incorrect prefix"); + } + } + if (result.length !== OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH + 1) { + throw new Error("Incorrect length"); + } + return Uint8Array.from(result.slice(OLM_RECOVERY_KEY_PREFIX.length, OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH)); +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/base.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/base.js new file mode 100644 index 0000000000..430afc16cd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/base.js @@ -0,0 +1,5 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/indexeddb-crypto-store-backend.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/indexeddb-crypto-store-backend.js new file mode 100644 index 0000000000..e2d77f8af7 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/indexeddb-crypto-store-backend.js @@ -0,0 +1,913 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.VERSION = exports.Backend = void 0; +exports.upgradeDatabase = upgradeDatabase; +var _logger = require("../../logger"); +var _utils = require("../../utils"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2017 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +const PROFILE_TRANSACTIONS = false; + +/** + * Implementation of a CryptoStore which is backed by an existing + * IndexedDB connection. Generally you want IndexedDBCryptoStore + * which connects to the database and defers to one of these. + */ +class Backend { + /** + */ + constructor(db) { + this.db = db; + _defineProperty(this, "nextTxnId", 0); + // make sure we close the db on `onversionchange` - otherwise + // attempts to delete the database will block (and subsequent + // attempts to re-create it will also block). + db.onversionchange = () => { + _logger.logger.log(`versionchange for indexeddb ${this.db.name}: closing`); + db.close(); + }; + } + async startup() { + // No work to do, as the startup is done by the caller (e.g IndexedDBCryptoStore) + // by passing us a ready IDBDatabase instance + return this; + } + async deleteAllData() { + throw Error("This is not implemented, call IDBFactory::deleteDatabase(dbName) instead."); + } + + /** + * Look for an existing outgoing room key request, and if none is found, + * add a new one + * + * + * @returns resolves to + * {@link OutgoingRoomKeyRequest}: either the + * same instance as passed in, or the existing one. + */ + getOrAddOutgoingRoomKeyRequest(request) { + const requestBody = request.requestBody; + return new Promise((resolve, reject) => { + const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite"); + txn.onerror = reject; + + // first see if we already have an entry for this request. + this._getOutgoingRoomKeyRequest(txn, requestBody, existing => { + if (existing) { + // this entry matches the request - return it. + _logger.logger.log(`already have key request outstanding for ` + `${requestBody.room_id} / ${requestBody.session_id}: ` + `not sending another`); + resolve(existing); + return; + } + + // we got to the end of the list without finding a match + // - add the new request. + _logger.logger.log(`enqueueing key request for ${requestBody.room_id} / ` + requestBody.session_id); + txn.oncomplete = () => { + resolve(request); + }; + const store = txn.objectStore("outgoingRoomKeyRequests"); + store.add(request); + }); + }); + } + + /** + * Look for an existing room key request + * + * @param requestBody - existing request to look for + * + * @returns resolves to the matching + * {@link OutgoingRoomKeyRequest}, or null if + * not found + */ + getOutgoingRoomKeyRequest(requestBody) { + return new Promise((resolve, reject) => { + const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly"); + txn.onerror = reject; + this._getOutgoingRoomKeyRequest(txn, requestBody, existing => { + resolve(existing); + }); + }); + } + + /** + * look for an existing room key request in the db + * + * @internal + * @param txn - database transaction + * @param requestBody - existing request to look for + * @param callback - function to call with the results of the + * search. Either passed a matching + * {@link OutgoingRoomKeyRequest}, or null if + * not found. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + _getOutgoingRoomKeyRequest(txn, requestBody, callback) { + const store = txn.objectStore("outgoingRoomKeyRequests"); + const idx = store.index("session"); + const cursorReq = idx.openCursor([requestBody.room_id, requestBody.session_id]); + cursorReq.onsuccess = () => { + const cursor = cursorReq.result; + if (!cursor) { + // no match found + callback(null); + return; + } + const existing = cursor.value; + if ((0, _utils.deepCompare)(existing.requestBody, requestBody)) { + // got a match + callback(existing); + return; + } + + // look at the next entry in the index + cursor.continue(); + }; + } + + /** + * Look for room key requests by state + * + * @param wantedStates - list of acceptable states + * + * @returns resolves to the a + * {@link OutgoingRoomKeyRequest}, or null if + * there are no pending requests in those states. If there are multiple + * requests in those states, an arbitrary one is chosen. + */ + getOutgoingRoomKeyRequestByState(wantedStates) { + if (wantedStates.length === 0) { + return Promise.resolve(null); + } + + // this is a bit tortuous because we need to make sure we do the lookup + // in a single transaction, to avoid having a race with the insertion + // code. + + // index into the wantedStates array + let stateIndex = 0; + let result; + function onsuccess() { + const cursor = this.result; + if (cursor) { + // got a match + result = cursor.value; + return; + } + + // try the next state in the list + stateIndex++; + if (stateIndex >= wantedStates.length) { + // no matches + return; + } + const wantedState = wantedStates[stateIndex]; + const cursorReq = this.source.openCursor(wantedState); + cursorReq.onsuccess = onsuccess; + } + const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly"); + const store = txn.objectStore("outgoingRoomKeyRequests"); + const wantedState = wantedStates[stateIndex]; + const cursorReq = store.index("state").openCursor(wantedState); + cursorReq.onsuccess = onsuccess; + return promiseifyTxn(txn).then(() => result); + } + + /** + * + * @returns All elements in a given state + */ + getAllOutgoingRoomKeyRequestsByState(wantedState) { + return new Promise((resolve, reject) => { + const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly"); + const store = txn.objectStore("outgoingRoomKeyRequests"); + const index = store.index("state"); + const request = index.getAll(wantedState); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) { + let stateIndex = 0; + const results = []; + function onsuccess() { + const cursor = this.result; + if (cursor) { + const keyReq = cursor.value; + if (keyReq.recipients.some(recipient => recipient.userId === userId && recipient.deviceId === 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 = this.source.openCursor(wantedState); + cursorReq.onsuccess = onsuccess; + } + } + const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly"); + const store = txn.objectStore("outgoingRoomKeyRequests"); + const wantedState = wantedStates[stateIndex]; + const cursorReq = store.index("state").openCursor(wantedState); + cursorReq.onsuccess = onsuccess; + return promiseifyTxn(txn).then(() => results); + } + + /** + * Look for an existing room key request by id and state, and update it if + * found + * + * @param requestId - ID of request to update + * @param expectedState - state we expect to find the request in + * @param updates - name/value map of updates to apply + * + * @returns resolves to + * {@link OutgoingRoomKeyRequest} + * updated request, or null if no matching row was found + */ + updateOutgoingRoomKeyRequest(requestId, expectedState, updates) { + let result = null; + function onsuccess() { + const cursor = this.result; + if (!cursor) { + return; + } + const data = cursor.value; + if (data.state != expectedState) { + _logger.logger.warn(`Cannot update room key request from ${expectedState} ` + `as it was already updated to ${data.state}`); + return; + } + Object.assign(data, updates); + cursor.update(data); + result = data; + } + const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite"); + const cursorReq = txn.objectStore("outgoingRoomKeyRequests").openCursor(requestId); + cursorReq.onsuccess = onsuccess; + return promiseifyTxn(txn).then(() => result); + } + + /** + * Look for an existing room key request by id and state, and delete it if + * found + * + * @param requestId - ID of request to update + * @param expectedState - state we expect to find the request in + * + * @returns resolves once the operation is completed + */ + deleteOutgoingRoomKeyRequest(requestId, expectedState) { + const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite"); + const cursorReq = txn.objectStore("outgoingRoomKeyRequests").openCursor(requestId); + cursorReq.onsuccess = () => { + const cursor = cursorReq.result; + if (!cursor) { + return; + } + const data = cursor.value; + if (data.state != expectedState) { + _logger.logger.warn(`Cannot delete room key request in state ${data.state} ` + `(expected ${expectedState})`); + return; + } + cursor.delete(); + }; + return promiseifyTxn(txn); + } + + // Olm Account + + getAccount(txn, func) { + const objectStore = txn.objectStore("account"); + const getReq = objectStore.get("-"); + getReq.onsuccess = function () { + try { + func(getReq.result || null); + } catch (e) { + abortWithException(txn, e); + } + }; + } + storeAccount(txn, accountPickle) { + const objectStore = txn.objectStore("account"); + objectStore.put(accountPickle, "-"); + } + getCrossSigningKeys(txn, func) { + const objectStore = txn.objectStore("account"); + const getReq = objectStore.get("crossSigningKeys"); + getReq.onsuccess = function () { + try { + func(getReq.result || null); + } catch (e) { + abortWithException(txn, e); + } + }; + } + getSecretStorePrivateKey(txn, func, type) { + const objectStore = txn.objectStore("account"); + const getReq = objectStore.get(`ssss_cache:${type}`); + getReq.onsuccess = function () { + try { + func(getReq.result || null); + } catch (e) { + abortWithException(txn, e); + } + }; + } + storeCrossSigningKeys(txn, keys) { + const objectStore = txn.objectStore("account"); + objectStore.put(keys, "crossSigningKeys"); + } + storeSecretStorePrivateKey(txn, type, key) { + const objectStore = txn.objectStore("account"); + objectStore.put(key, `ssss_cache:${type}`); + } + + // Olm Sessions + + countEndToEndSessions(txn, func) { + const objectStore = txn.objectStore("sessions"); + const countReq = objectStore.count(); + countReq.onsuccess = function () { + try { + func(countReq.result); + } catch (e) { + abortWithException(txn, e); + } + }; + } + getEndToEndSessions(deviceKey, txn, func) { + const objectStore = txn.objectStore("sessions"); + const idx = objectStore.index("deviceKey"); + const getReq = idx.openCursor(deviceKey); + const results = {}; + getReq.onsuccess = function () { + const cursor = getReq.result; + if (cursor) { + results[cursor.value.sessionId] = { + session: cursor.value.session, + lastReceivedMessageTs: cursor.value.lastReceivedMessageTs + }; + cursor.continue(); + } else { + try { + func(results); + } catch (e) { + abortWithException(txn, e); + } + } + }; + } + getEndToEndSession(deviceKey, sessionId, txn, func) { + const objectStore = txn.objectStore("sessions"); + const getReq = objectStore.get([deviceKey, sessionId]); + getReq.onsuccess = function () { + try { + if (getReq.result) { + func({ + session: getReq.result.session, + lastReceivedMessageTs: getReq.result.lastReceivedMessageTs + }); + } else { + func(null); + } + } catch (e) { + abortWithException(txn, e); + } + }; + } + getAllEndToEndSessions(txn, func) { + const objectStore = txn.objectStore("sessions"); + const getReq = objectStore.openCursor(); + getReq.onsuccess = function () { + try { + const cursor = getReq.result; + if (cursor) { + func(cursor.value); + cursor.continue(); + } else { + func(null); + } + } catch (e) { + abortWithException(txn, e); + } + }; + } + storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) { + const objectStore = txn.objectStore("sessions"); + objectStore.put({ + deviceKey, + sessionId, + session: sessionInfo.session, + lastReceivedMessageTs: sessionInfo.lastReceivedMessageTs + }); + } + async storeEndToEndSessionProblem(deviceKey, type, fixed) { + const txn = this.db.transaction("session_problems", "readwrite"); + const objectStore = txn.objectStore("session_problems"); + objectStore.put({ + deviceKey, + type, + fixed, + time: Date.now() + }); + await promiseifyTxn(txn); + } + async getEndToEndSessionProblem(deviceKey, timestamp) { + let result = null; + const txn = this.db.transaction("session_problems", "readwrite"); + const objectStore = txn.objectStore("session_problems"); + const index = objectStore.index("deviceKey"); + const req = index.getAll(deviceKey); + req.onsuccess = () => { + const problems = req.result; + if (!problems.length) { + result = null; + return; + } + problems.sort((a, b) => { + return a.time - b.time; + }); + const lastProblem = problems[problems.length - 1]; + for (const problem of problems) { + if (problem.time > timestamp) { + result = Object.assign({}, problem, { + fixed: lastProblem.fixed + }); + return; + } + } + if (lastProblem.fixed) { + result = null; + } else { + result = lastProblem; + } + }; + await promiseifyTxn(txn); + return result; + } + + // FIXME: we should probably prune this when devices get deleted + async filterOutNotifiedErrorDevices(devices) { + const txn = this.db.transaction("notified_error_devices", "readwrite"); + const objectStore = txn.objectStore("notified_error_devices"); + const ret = []; + await Promise.all(devices.map(device => { + return new Promise(resolve => { + const { + userId, + deviceInfo + } = device; + const getReq = objectStore.get([userId, deviceInfo.deviceId]); + getReq.onsuccess = function () { + if (!getReq.result) { + objectStore.put({ + userId, + deviceId: deviceInfo.deviceId + }); + ret.push(device); + } + resolve(); + }; + }); + })); + return ret; + } + + // Inbound group sessions + + getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) { + let session = false; + let withheld = false; + const objectStore = txn.objectStore("inbound_group_sessions"); + const getReq = objectStore.get([senderCurve25519Key, sessionId]); + getReq.onsuccess = function () { + try { + if (getReq.result) { + session = getReq.result.session; + } else { + session = null; + } + if (withheld !== false) { + func(session, withheld); + } + } catch (e) { + abortWithException(txn, e); + } + }; + const withheldObjectStore = txn.objectStore("inbound_group_sessions_withheld"); + const withheldGetReq = withheldObjectStore.get([senderCurve25519Key, sessionId]); + withheldGetReq.onsuccess = function () { + try { + if (withheldGetReq.result) { + withheld = withheldGetReq.result.session; + } else { + withheld = null; + } + if (session !== false) { + func(session, withheld); + } + } catch (e) { + abortWithException(txn, e); + } + }; + } + getAllEndToEndInboundGroupSessions(txn, func) { + const objectStore = txn.objectStore("inbound_group_sessions"); + const getReq = objectStore.openCursor(); + getReq.onsuccess = function () { + const cursor = getReq.result; + if (cursor) { + try { + func({ + senderKey: cursor.value.senderCurve25519Key, + sessionId: cursor.value.sessionId, + sessionData: cursor.value.session + }); + } catch (e) { + abortWithException(txn, e); + } + cursor.continue(); + } else { + try { + func(null); + } catch (e) { + abortWithException(txn, e); + } + } + }; + } + addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) { + const objectStore = txn.objectStore("inbound_group_sessions"); + const addReq = objectStore.add({ + senderCurve25519Key, + sessionId, + session: sessionData + }); + addReq.onerror = ev => { + if (addReq.error?.name === "ConstraintError") { + // This stops the error from triggering the txn's onerror + ev.stopPropagation(); + // ...and this stops it from aborting the transaction + ev.preventDefault(); + _logger.logger.log("Ignoring duplicate inbound group session: " + senderCurve25519Key + " / " + sessionId); + } else { + abortWithException(txn, new Error("Failed to add inbound group session: " + addReq.error)); + } + }; + } + storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) { + const objectStore = txn.objectStore("inbound_group_sessions"); + objectStore.put({ + senderCurve25519Key, + sessionId, + session: sessionData + }); + } + storeEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId, sessionData, txn) { + const objectStore = txn.objectStore("inbound_group_sessions_withheld"); + objectStore.put({ + senderCurve25519Key, + sessionId, + session: sessionData + }); + } + getEndToEndDeviceData(txn, func) { + const objectStore = txn.objectStore("device_data"); + const getReq = objectStore.get("-"); + getReq.onsuccess = function () { + try { + func(getReq.result || null); + } catch (e) { + abortWithException(txn, e); + } + }; + } + storeEndToEndDeviceData(deviceData, txn) { + const objectStore = txn.objectStore("device_data"); + objectStore.put(deviceData, "-"); + } + storeEndToEndRoom(roomId, roomInfo, txn) { + const objectStore = txn.objectStore("rooms"); + objectStore.put(roomInfo, roomId); + } + getEndToEndRooms(txn, func) { + const rooms = {}; + const objectStore = txn.objectStore("rooms"); + const getReq = objectStore.openCursor(); + getReq.onsuccess = function () { + const cursor = getReq.result; + if (cursor) { + rooms[cursor.key] = cursor.value; + cursor.continue(); + } else { + try { + func(rooms); + } catch (e) { + abortWithException(txn, e); + } + } + }; + } + + // session backups + + getSessionsNeedingBackup(limit) { + return new Promise((resolve, reject) => { + const sessions = []; + const txn = this.db.transaction(["sessions_needing_backup", "inbound_group_sessions"], "readonly"); + txn.onerror = reject; + txn.oncomplete = function () { + resolve(sessions); + }; + const objectStore = txn.objectStore("sessions_needing_backup"); + const sessionStore = txn.objectStore("inbound_group_sessions"); + const getReq = objectStore.openCursor(); + getReq.onsuccess = function () { + const cursor = getReq.result; + if (cursor) { + const sessionGetReq = sessionStore.get(cursor.key); + sessionGetReq.onsuccess = function () { + sessions.push({ + senderKey: sessionGetReq.result.senderCurve25519Key, + sessionId: sessionGetReq.result.sessionId, + sessionData: sessionGetReq.result.session + }); + }; + if (!limit || sessions.length < limit) { + cursor.continue(); + } + } + }; + }); + } + countSessionsNeedingBackup(txn) { + if (!txn) { + txn = this.db.transaction("sessions_needing_backup", "readonly"); + } + const objectStore = txn.objectStore("sessions_needing_backup"); + return new Promise((resolve, reject) => { + const req = objectStore.count(); + req.onerror = reject; + req.onsuccess = () => resolve(req.result); + }); + } + async unmarkSessionsNeedingBackup(sessions, txn) { + if (!txn) { + txn = this.db.transaction("sessions_needing_backup", "readwrite"); + } + const objectStore = txn.objectStore("sessions_needing_backup"); + await Promise.all(sessions.map(session => { + return new Promise((resolve, reject) => { + const req = objectStore.delete([session.senderKey, session.sessionId]); + req.onsuccess = resolve; + req.onerror = reject; + }); + })); + } + async markSessionsNeedingBackup(sessions, txn) { + if (!txn) { + txn = this.db.transaction("sessions_needing_backup", "readwrite"); + } + const objectStore = txn.objectStore("sessions_needing_backup"); + await Promise.all(sessions.map(session => { + return new Promise((resolve, reject) => { + const req = objectStore.put({ + senderCurve25519Key: session.senderKey, + sessionId: session.sessionId + }); + req.onsuccess = resolve; + req.onerror = reject; + }); + })); + } + addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn) { + if (!txn) { + txn = this.db.transaction("shared_history_inbound_group_sessions", "readwrite"); + } + const objectStore = txn.objectStore("shared_history_inbound_group_sessions"); + const req = objectStore.get([roomId]); + req.onsuccess = () => { + const { + sessions + } = req.result || { + sessions: [] + }; + sessions.push([senderKey, sessionId]); + objectStore.put({ + roomId, + sessions + }); + }; + } + getSharedHistoryInboundGroupSessions(roomId, txn) { + if (!txn) { + txn = this.db.transaction("shared_history_inbound_group_sessions", "readonly"); + } + const objectStore = txn.objectStore("shared_history_inbound_group_sessions"); + const req = objectStore.get([roomId]); + return new Promise((resolve, reject) => { + req.onsuccess = () => { + const { + sessions + } = req.result || { + sessions: [] + }; + resolve(sessions); + }; + req.onerror = reject; + }); + } + addParkedSharedHistory(roomId, parkedData, txn) { + if (!txn) { + txn = this.db.transaction("parked_shared_history", "readwrite"); + } + const objectStore = txn.objectStore("parked_shared_history"); + const req = objectStore.get([roomId]); + req.onsuccess = () => { + const { + parked + } = req.result || { + parked: [] + }; + parked.push(parkedData); + objectStore.put({ + roomId, + parked + }); + }; + } + takeParkedSharedHistory(roomId, txn) { + if (!txn) { + txn = this.db.transaction("parked_shared_history", "readwrite"); + } + const cursorReq = txn.objectStore("parked_shared_history").openCursor(roomId); + return new Promise((resolve, reject) => { + cursorReq.onsuccess = () => { + const cursor = cursorReq.result; + if (!cursor) { + resolve([]); + return; + } + const data = cursor.value; + cursor.delete(); + resolve(data); + }; + cursorReq.onerror = reject; + }); + } + doTxn(mode, stores, func, log = _logger.logger) { + let startTime; + let description; + if (PROFILE_TRANSACTIONS) { + const txnId = this.nextTxnId++; + startTime = Date.now(); + description = `${mode} crypto store transaction ${txnId} in ${stores}`; + log.debug(`Starting ${description}`); + } + const txn = this.db.transaction(stores, mode); + const promise = promiseifyTxn(txn); + const result = func(txn); + if (PROFILE_TRANSACTIONS) { + promise.then(() => { + const elapsedTime = Date.now() - startTime; + log.debug(`Finished ${description}, took ${elapsedTime} ms`); + }, () => { + const elapsedTime = Date.now() - startTime; + log.error(`Failed ${description}, took ${elapsedTime} ms`); + }); + } + return promise.then(() => { + return result; + }); + } +} +exports.Backend = Backend; +const DB_MIGRATIONS = [db => { + createDatabase(db); +}, db => { + db.createObjectStore("account"); +}, db => { + const sessionsStore = db.createObjectStore("sessions", { + keyPath: ["deviceKey", "sessionId"] + }); + sessionsStore.createIndex("deviceKey", "deviceKey"); +}, db => { + db.createObjectStore("inbound_group_sessions", { + keyPath: ["senderCurve25519Key", "sessionId"] + }); +}, db => { + db.createObjectStore("device_data"); +}, db => { + db.createObjectStore("rooms"); +}, db => { + db.createObjectStore("sessions_needing_backup", { + keyPath: ["senderCurve25519Key", "sessionId"] + }); +}, db => { + db.createObjectStore("inbound_group_sessions_withheld", { + keyPath: ["senderCurve25519Key", "sessionId"] + }); +}, db => { + const problemsStore = db.createObjectStore("session_problems", { + keyPath: ["deviceKey", "time"] + }); + problemsStore.createIndex("deviceKey", "deviceKey"); + db.createObjectStore("notified_error_devices", { + keyPath: ["userId", "deviceId"] + }); +}, db => { + db.createObjectStore("shared_history_inbound_group_sessions", { + keyPath: ["roomId"] + }); +}, db => { + db.createObjectStore("parked_shared_history", { + keyPath: ["roomId"] + }); +} +// Expand as needed. +]; + +const VERSION = DB_MIGRATIONS.length; +exports.VERSION = VERSION; +function upgradeDatabase(db, oldVersion) { + _logger.logger.log(`Upgrading IndexedDBCryptoStore from version ${oldVersion}` + ` to ${VERSION}`); + DB_MIGRATIONS.forEach((migration, index) => { + if (oldVersion <= index) migration(db); + }); +} +function createDatabase(db) { + const outgoingRoomKeyRequestsStore = db.createObjectStore("outgoingRoomKeyRequests", { + keyPath: "requestId" + }); + + // we assume that the RoomKeyRequestBody will have room_id and session_id + // properties, to make the index efficient. + outgoingRoomKeyRequestsStore.createIndex("session", ["requestBody.room_id", "requestBody.session_id"]); + outgoingRoomKeyRequestsStore.createIndex("state", "state"); +} +/* + * Aborts a transaction with a given exception + * The transaction promise will be rejected with this exception. + */ +function abortWithException(txn, e) { + // We cheekily stick our exception onto the transaction object here + // We could alternatively make the thing we pass back to the app + // an object containing the transaction and exception. + txn._mx_abortexception = e; + try { + txn.abort(); + } catch (e) { + // sometimes we won't be able to abort the transaction + // (ie. if it's aborted or completed) + } +} +function promiseifyTxn(txn) { + return new Promise((resolve, reject) => { + txn.oncomplete = () => { + if (txn._mx_abortexception !== undefined) { + reject(txn._mx_abortexception); + } + resolve(null); + }; + txn.onerror = event => { + if (txn._mx_abortexception !== undefined) { + reject(txn._mx_abortexception); + } else { + _logger.logger.log("Error performing indexeddb txn", event); + reject(txn.error); + } + }; + txn.onabort = event => { + if (txn._mx_abortexception !== undefined) { + reject(txn._mx_abortexception); + } else { + _logger.logger.log("Error performing indexeddb txn", event); + reject(txn.error); + } + }; + }); +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/indexeddb-crypto-store.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/indexeddb-crypto-store.js new file mode 100644 index 0000000000..dc48bd400f --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/indexeddb-crypto-store.js @@ -0,0 +1,599 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.IndexedDBCryptoStore = void 0; +var _logger = require("../../logger"); +var _localStorageCryptoStore = require("./localStorage-crypto-store"); +var _memoryCryptoStore = require("./memory-crypto-store"); +var IndexedDBCryptoStoreBackend = _interopRequireWildcard(require("./indexeddb-crypto-store-backend")); +var _errors = require("../../errors"); +var IndexedDBHelpers = _interopRequireWildcard(require("../../indexeddb-helpers")); +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2017 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/** + * Internal module. indexeddb storage for e2e. + */ + +/** + * An implementation of CryptoStore, which is normally backed by an indexeddb, + * but with fallback to MemoryCryptoStore. + */ +class IndexedDBCryptoStore { + static exists(indexedDB, dbName) { + return IndexedDBHelpers.exists(indexedDB, dbName); + } + /** + * Create a new IndexedDBCryptoStore + * + * @param indexedDB - global indexedDB instance + * @param dbName - name of db to connect to + */ + constructor(indexedDB, dbName) { + this.indexedDB = indexedDB; + this.dbName = dbName; + _defineProperty(this, "backendPromise", void 0); + _defineProperty(this, "backend", void 0); + } + + /** + * Ensure the database exists and is up-to-date, or fall back to + * a local storage or in-memory store. + * + * This must be called before the store can be used. + * + * @returns resolves to either an IndexedDBCryptoStoreBackend.Backend, + * or a MemoryCryptoStore + */ + startup() { + if (this.backendPromise) { + return this.backendPromise; + } + this.backendPromise = new Promise((resolve, reject) => { + if (!this.indexedDB) { + reject(new Error("no indexeddb support available")); + return; + } + _logger.logger.log(`connecting to indexeddb ${this.dbName}`); + const req = this.indexedDB.open(this.dbName, IndexedDBCryptoStoreBackend.VERSION); + req.onupgradeneeded = ev => { + const db = req.result; + const oldVersion = ev.oldVersion; + IndexedDBCryptoStoreBackend.upgradeDatabase(db, oldVersion); + }; + req.onblocked = () => { + _logger.logger.log(`can't yet open IndexedDBCryptoStore because it is open elsewhere`); + }; + req.onerror = ev => { + _logger.logger.log("Error connecting to indexeddb", ev); + reject(req.error); + }; + req.onsuccess = () => { + const db = req.result; + _logger.logger.log(`connected to indexeddb ${this.dbName}`); + resolve(new IndexedDBCryptoStoreBackend.Backend(db)); + }; + }).then(backend => { + // Edge has IndexedDB but doesn't support compund keys which we use fairly extensively. + // Try a dummy query which will fail if the browser doesn't support compund keys, so + // we can fall back to a different backend. + return backend.doTxn("readonly", [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD], txn => { + backend.getEndToEndInboundGroupSession("", "", txn, () => {}); + }).then(() => backend); + }).catch(e => { + if (e.name === "VersionError") { + _logger.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 _errors.InvalidCryptoStoreError(_errors.InvalidCryptoStoreState.TooNew); + } + _logger.logger.warn(`unable to connect to indexeddb ${this.dbName}` + `: falling back to localStorage store: ${e}`); + try { + return new _localStorageCryptoStore.LocalStorageCryptoStore(global.localStorage); + } catch (e) { + _logger.logger.warn(`unable to open localStorage: falling back to in-memory store: ${e}`); + return new _memoryCryptoStore.MemoryCryptoStore(); + } + }).then(backend => { + this.backend = backend; + return backend; + }); + return this.backendPromise; + } + + /** + * Delete all data from this store. + * + * @returns resolves when the store has been cleared. + */ + deleteAllData() { + return new Promise((resolve, reject) => { + if (!this.indexedDB) { + reject(new Error("no indexeddb support available")); + return; + } + _logger.logger.log(`Removing indexeddb instance: ${this.dbName}`); + const req = this.indexedDB.deleteDatabase(this.dbName); + req.onblocked = () => { + _logger.logger.log(`can't yet delete IndexedDBCryptoStore because it is open elsewhere`); + }; + req.onerror = ev => { + _logger.logger.log("Error deleting data from indexeddb", ev); + reject(req.error); + }; + req.onsuccess = () => { + _logger.logger.log(`Removed indexeddb instance: ${this.dbName}`); + resolve(); + }; + }).catch(e => { + // in firefox, with indexedDB disabled, this fails with a + // DOMError. We treat this as non-fatal, so that people can + // still use the app. + _logger.logger.warn(`unable to delete IndexedDBCryptoStore: ${e}`); + }); + } + + /** + * Look for an existing outgoing room key request, and if none is found, + * add a new one + * + * + * @returns resolves to + * {@link OutgoingRoomKeyRequest}: either the + * same instance as passed in, or the existing one. + */ + getOrAddOutgoingRoomKeyRequest(request) { + return this.backend.getOrAddOutgoingRoomKeyRequest(request); + } + + /** + * Look for an existing room key request + * + * @param requestBody - existing request to look for + * + * @returns resolves to the matching + * {@link OutgoingRoomKeyRequest}, or null if + * not found + */ + getOutgoingRoomKeyRequest(requestBody) { + return this.backend.getOutgoingRoomKeyRequest(requestBody); + } + + /** + * Look for room key requests by state + * + * @param wantedStates - list of acceptable states + * + * @returns resolves to the a + * {@link OutgoingRoomKeyRequest}, or null if + * there are no pending requests in those states. If there are multiple + * requests in those states, an arbitrary one is chosen. + */ + getOutgoingRoomKeyRequestByState(wantedStates) { + return this.backend.getOutgoingRoomKeyRequestByState(wantedStates); + } + + /** + * Look for room key requests by state – + * unlike above, return a list of all entries in one state. + * + * @returns Returns an array of requests in the given state + */ + getAllOutgoingRoomKeyRequestsByState(wantedState) { + return this.backend.getAllOutgoingRoomKeyRequestsByState(wantedState); + } + + /** + * Look for room key requests by target device and state + * + * @param userId - Target user ID + * @param deviceId - Target device ID + * @param wantedStates - list of acceptable states + * + * @returns resolves to a list of all the + * {@link OutgoingRoomKeyRequest} + */ + getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) { + return this.backend.getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates); + } + + /** + * Look for an existing room key request by id and state, and update it if + * found + * + * @param requestId - ID of request to update + * @param expectedState - state we expect to find the request in + * @param updates - name/value map of updates to apply + * + * @returns resolves to + * {@link OutgoingRoomKeyRequest} + * updated request, or null if no matching row was found + */ + updateOutgoingRoomKeyRequest(requestId, expectedState, updates) { + return this.backend.updateOutgoingRoomKeyRequest(requestId, expectedState, updates); + } + + /** + * Look for an existing room key request by id and state, and delete it if + * found + * + * @param requestId - ID of request to update + * @param expectedState - state we expect to find the request in + * + * @returns resolves once the operation is completed + */ + deleteOutgoingRoomKeyRequest(requestId, expectedState) { + return this.backend.deleteOutgoingRoomKeyRequest(requestId, expectedState); + } + + // Olm Account + + /* + * Get the account pickle from the store. + * This requires an active transaction. See doTxn(). + * + * @param txn - An active transaction. See doTxn(). + * @param func - Called with the account pickle + */ + getAccount(txn, func) { + this.backend.getAccount(txn, func); + } + + /** + * Write the account pickle to the store. + * This requires an active transaction. See doTxn(). + * + * @param txn - An active transaction. See doTxn(). + * @param accountPickle - The new account pickle to store. + */ + storeAccount(txn, accountPickle) { + this.backend.storeAccount(txn, accountPickle); + } + + /** + * Get the public part of the cross-signing keys (eg. self-signing key, + * user signing key). + * + * @param txn - An active transaction. See doTxn(). + * @param func - Called with the account keys object: + * `{ key_type: base64 encoded seed }` where key type = user_signing_key_seed or self_signing_key_seed + */ + getCrossSigningKeys(txn, func) { + this.backend.getCrossSigningKeys(txn, func); + } + + /** + * @param txn - An active transaction. See doTxn(). + * @param func - Called with the private key + * @param type - A key type + */ + getSecretStorePrivateKey(txn, func, type) { + this.backend.getSecretStorePrivateKey(txn, func, type); + } + + /** + * Write the cross-signing keys back to the store + * + * @param txn - An active transaction. See doTxn(). + * @param keys - keys object as getCrossSigningKeys() + */ + storeCrossSigningKeys(txn, keys) { + this.backend.storeCrossSigningKeys(txn, keys); + } + + /** + * Write the cross-signing private keys back to the store + * + * @param txn - An active transaction. See doTxn(). + * @param type - The type of cross-signing private key to store + * @param key - keys object as getCrossSigningKeys() + */ + storeSecretStorePrivateKey(txn, type, key) { + this.backend.storeSecretStorePrivateKey(txn, type, key); + } + + // Olm sessions + + /** + * Returns the number of end-to-end sessions in the store + * @param txn - An active transaction. See doTxn(). + * @param func - Called with the count of sessions + */ + countEndToEndSessions(txn, func) { + this.backend.countEndToEndSessions(txn, func); + } + + /** + * Retrieve a specific end-to-end session between the logged-in user + * and another device. + * @param deviceKey - The public key of the other device. + * @param sessionId - The ID of the session to retrieve + * @param txn - An active transaction. See doTxn(). + * @param func - Called with A map from sessionId + * to session information object with 'session' key being the + * Base64 end-to-end session and lastReceivedMessageTs being the + * timestamp in milliseconds at which the session last received + * a message. + */ + getEndToEndSession(deviceKey, sessionId, txn, func) { + this.backend.getEndToEndSession(deviceKey, sessionId, txn, func); + } + + /** + * Retrieve the end-to-end sessions between the logged-in user and another + * device. + * @param deviceKey - The public key of the other device. + * @param txn - An active transaction. See doTxn(). + * @param func - Called with A map from sessionId + * to session information object with 'session' key being the + * Base64 end-to-end session and lastReceivedMessageTs being the + * timestamp in milliseconds at which the session last received + * a message. + */ + getEndToEndSessions(deviceKey, txn, func) { + this.backend.getEndToEndSessions(deviceKey, txn, func); + } + + /** + * Retrieve all end-to-end sessions + * @param txn - An active transaction. See doTxn(). + * @param func - Called one for each session with + * an object with, deviceKey, lastReceivedMessageTs, sessionId + * and session keys. + */ + getAllEndToEndSessions(txn, func) { + this.backend.getAllEndToEndSessions(txn, func); + } + + /** + * Store a session between the logged-in user and another device + * @param deviceKey - The public key of the other device. + * @param sessionId - The ID for this end-to-end session. + * @param sessionInfo - Session information object + * @param txn - An active transaction. See doTxn(). + */ + storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) { + this.backend.storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn); + } + storeEndToEndSessionProblem(deviceKey, type, fixed) { + return this.backend.storeEndToEndSessionProblem(deviceKey, type, fixed); + } + getEndToEndSessionProblem(deviceKey, timestamp) { + return this.backend.getEndToEndSessionProblem(deviceKey, timestamp); + } + filterOutNotifiedErrorDevices(devices) { + return this.backend.filterOutNotifiedErrorDevices(devices); + } + + // Inbound group sessions + + /** + * Retrieve the end-to-end inbound group session for a given + * server key and session ID + * @param senderCurve25519Key - The sender's curve 25519 key + * @param sessionId - The ID of the session + * @param txn - An active transaction. See doTxn(). + * @param func - Called with A map from sessionId + * to Base64 end-to-end session. + */ + getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) { + this.backend.getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func); + } + + /** + * Fetches all inbound group sessions in the store + * @param txn - An active transaction. See doTxn(). + * @param func - Called once for each group session + * in the store with an object having keys `{senderKey, sessionId, sessionData}`, + * then once with null to indicate the end of the list. + */ + getAllEndToEndInboundGroupSessions(txn, func) { + this.backend.getAllEndToEndInboundGroupSessions(txn, func); + } + + /** + * Adds an end-to-end inbound group session to the store. + * If there already exists an inbound group session with the same + * senderCurve25519Key and sessionID, the session will not be added. + * @param senderCurve25519Key - The sender's curve 25519 key + * @param sessionId - The ID of the session + * @param sessionData - The session data structure + * @param txn - An active transaction. See doTxn(). + */ + addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) { + this.backend.addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn); + } + + /** + * Writes an end-to-end inbound group session to the store. + * If there already exists an inbound group session with the same + * senderCurve25519Key and sessionID, it will be overwritten. + * @param senderCurve25519Key - The sender's curve 25519 key + * @param sessionId - The ID of the session + * @param sessionData - The session data structure + * @param txn - An active transaction. See doTxn(). + */ + storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) { + this.backend.storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn); + } + storeEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId, sessionData, txn) { + this.backend.storeEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId, sessionData, txn); + } + + // End-to-end device tracking + + /** + * Store the state of all tracked devices + * This contains devices for each user, a tracking state for each user + * and a sync token matching the point in time the snapshot represents. + * These all need to be written out in full each time such that the snapshot + * is always consistent, so they are stored in one object. + * + * @param txn - An active transaction. See doTxn(). + */ + storeEndToEndDeviceData(deviceData, txn) { + this.backend.storeEndToEndDeviceData(deviceData, txn); + } + + /** + * Get the state of all tracked devices + * + * @param txn - An active transaction. See doTxn(). + * @param func - Function called with the + * device data + */ + getEndToEndDeviceData(txn, func) { + this.backend.getEndToEndDeviceData(txn, func); + } + + // End to End Rooms + + /** + * Store the end-to-end state for a room. + * @param roomId - The room's ID. + * @param roomInfo - The end-to-end info for the room. + * @param txn - An active transaction. See doTxn(). + */ + storeEndToEndRoom(roomId, roomInfo, txn) { + this.backend.storeEndToEndRoom(roomId, roomInfo, txn); + } + + /** + * Get an object of `roomId->roomInfo` for all e2e rooms in the store + * @param txn - An active transaction. See doTxn(). + * @param func - Function called with the end-to-end encrypted rooms + */ + getEndToEndRooms(txn, func) { + this.backend.getEndToEndRooms(txn, func); + } + + // session backups + + /** + * Get the inbound group sessions that need to be backed up. + * @param limit - The maximum number of sessions to retrieve. 0 + * for no limit. + * @returns resolves to an array of inbound group sessions + */ + getSessionsNeedingBackup(limit) { + return this.backend.getSessionsNeedingBackup(limit); + } + + /** + * Count the inbound group sessions that need to be backed up. + * @param txn - An active transaction. See doTxn(). (optional) + * @returns resolves to the number of sessions + */ + countSessionsNeedingBackup(txn) { + return this.backend.countSessionsNeedingBackup(txn); + } + + /** + * Unmark sessions as needing to be backed up. + * @param sessions - The sessions that need to be backed up. + * @param txn - An active transaction. See doTxn(). (optional) + * @returns resolves when the sessions are unmarked + */ + unmarkSessionsNeedingBackup(sessions, txn) { + return this.backend.unmarkSessionsNeedingBackup(sessions, txn); + } + + /** + * Mark sessions as needing to be backed up. + * @param sessions - The sessions that need to be backed up. + * @param txn - An active transaction. See doTxn(). (optional) + * @returns resolves when the sessions are marked + */ + markSessionsNeedingBackup(sessions, txn) { + return this.backend.markSessionsNeedingBackup(sessions, txn); + } + + /** + * Add a shared-history group session for a room. + * @param roomId - The room that the key belongs to + * @param senderKey - The sender's curve 25519 key + * @param sessionId - The ID of the session + * @param txn - An active transaction. See doTxn(). (optional) + */ + addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn) { + this.backend.addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn); + } + + /** + * Get the shared-history group session for a room. + * @param roomId - The room that the key belongs to + * @param txn - An active transaction. See doTxn(). (optional) + * @returns Promise which resolves to an array of [senderKey, sessionId] + */ + getSharedHistoryInboundGroupSessions(roomId, txn) { + return this.backend.getSharedHistoryInboundGroupSessions(roomId, txn); + } + + /** + * Park a shared-history group session for a room we may be invited to later. + */ + addParkedSharedHistory(roomId, parkedData, txn) { + this.backend.addParkedSharedHistory(roomId, parkedData, txn); + } + + /** + * Pop out all shared-history group sessions for a room. + */ + takeParkedSharedHistory(roomId, txn) { + return this.backend.takeParkedSharedHistory(roomId, txn); + } + + /** + * Perform a transaction on the crypto store. Any store methods + * that require a transaction (txn) object to be passed in may + * only be called within a callback of either this function or + * one of the store functions operating on the same transaction. + * + * @param mode - 'readwrite' if you need to call setter + * functions with this transaction. Otherwise, 'readonly'. + * @param stores - List IndexedDBCryptoStore.STORE_* + * options representing all types of object that will be + * accessed or written to with this transaction. + * @param func - Function called with the + * transaction object: an opaque object that should be passed + * to store functions. + * @param log - A possibly customised log + * @returns Promise that resolves with the result of the `func` + * when the transaction is complete. If the backend is + * async (ie. the indexeddb backend) any of the callback + * functions throwing an exception will cause this promise to + * reject with that exception. On synchronous backends, the + * exception will propagate to the caller of the getFoo method. + */ + doTxn(mode, stores, func, log) { + return this.backend.doTxn(mode, stores, func, log); + } +} +exports.IndexedDBCryptoStore = IndexedDBCryptoStore; +_defineProperty(IndexedDBCryptoStore, "STORE_ACCOUNT", "account"); +_defineProperty(IndexedDBCryptoStore, "STORE_SESSIONS", "sessions"); +_defineProperty(IndexedDBCryptoStore, "STORE_INBOUND_GROUP_SESSIONS", "inbound_group_sessions"); +_defineProperty(IndexedDBCryptoStore, "STORE_INBOUND_GROUP_SESSIONS_WITHHELD", "inbound_group_sessions_withheld"); +_defineProperty(IndexedDBCryptoStore, "STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS", "shared_history_inbound_group_sessions"); +_defineProperty(IndexedDBCryptoStore, "STORE_PARKED_SHARED_HISTORY", "parked_shared_history"); +_defineProperty(IndexedDBCryptoStore, "STORE_DEVICE_DATA", "device_data"); +_defineProperty(IndexedDBCryptoStore, "STORE_ROOMS", "rooms"); +_defineProperty(IndexedDBCryptoStore, "STORE_BACKUP", "sessions_needing_backup");
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/localStorage-crypto-store.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/localStorage-crypto-store.js new file mode 100644 index 0000000000..17348d1813 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/localStorage-crypto-store.js @@ -0,0 +1,329 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.LocalStorageCryptoStore = void 0; +var _logger = require("../../logger"); +var _memoryCryptoStore = require("./memory-crypto-store"); +var _utils = require("../../utils"); +/* +Copyright 2017 - 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Internal module. Partial localStorage backed storage for e2e. + * This is not a full crypto store, just the in-memory store with + * some things backed by localStorage. It exists because indexedDB + * is broken in Firefox private mode or set to, "will not remember + * history". + */ + +const E2E_PREFIX = "crypto."; +const KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account"; +const KEY_CROSS_SIGNING_KEYS = E2E_PREFIX + "cross_signing_keys"; +const KEY_NOTIFIED_ERROR_DEVICES = E2E_PREFIX + "notified_error_devices"; +const KEY_DEVICE_DATA = E2E_PREFIX + "device_data"; +const KEY_INBOUND_SESSION_PREFIX = E2E_PREFIX + "inboundgroupsessions/"; +const KEY_INBOUND_SESSION_WITHHELD_PREFIX = E2E_PREFIX + "inboundgroupsessions.withheld/"; +const KEY_ROOMS_PREFIX = E2E_PREFIX + "rooms/"; +const KEY_SESSIONS_NEEDING_BACKUP = E2E_PREFIX + "sessionsneedingbackup"; +function keyEndToEndSessions(deviceKey) { + return E2E_PREFIX + "sessions/" + deviceKey; +} +function keyEndToEndSessionProblems(deviceKey) { + return E2E_PREFIX + "session.problems/" + deviceKey; +} +function keyEndToEndInboundGroupSession(senderKey, sessionId) { + return KEY_INBOUND_SESSION_PREFIX + senderKey + "/" + sessionId; +} +function keyEndToEndInboundGroupSessionWithheld(senderKey, sessionId) { + return KEY_INBOUND_SESSION_WITHHELD_PREFIX + senderKey + "/" + sessionId; +} +function keyEndToEndRoomsPrefix(roomId) { + return KEY_ROOMS_PREFIX + roomId; +} +class LocalStorageCryptoStore extends _memoryCryptoStore.MemoryCryptoStore { + static exists(store) { + const length = store.length; + for (let i = 0; i < length; i++) { + if (store.key(i)?.startsWith(E2E_PREFIX)) { + return true; + } + } + return false; + } + constructor(store) { + super(); + this.store = store; + } + + // Olm Sessions + + countEndToEndSessions(txn, func) { + let count = 0; + for (let i = 0; i < this.store.length; ++i) { + if (this.store.key(i)?.startsWith(keyEndToEndSessions(""))) ++count; + } + func(count); + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + _getEndToEndSessions(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) { + const sessions = this._getEndToEndSessions(deviceKey); + func(sessions[sessionId] || {}); + } + getEndToEndSessions(deviceKey, txn, func) { + func(this._getEndToEndSessions(deviceKey) || {}); + } + getAllEndToEndSessions(txn, func) { + for (let i = 0; i < this.store.length; ++i) { + if (this.store.key(i)?.startsWith(keyEndToEndSessions(""))) { + const deviceKey = this.store.key(i).split("/")[1]; + for (const sess of Object.values(this._getEndToEndSessions(deviceKey))) { + func(sess); + } + } + } + } + storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) { + const sessions = this._getEndToEndSessions(deviceKey) || {}; + sessions[sessionId] = sessionInfo; + setJsonItem(this.store, keyEndToEndSessions(deviceKey), sessions); + } + async storeEndToEndSessionProblem(deviceKey, type, fixed) { + const key = keyEndToEndSessionProblems(deviceKey); + const problems = getJsonItem(this.store, key) || []; + problems.push({ + type, + fixed, + time: Date.now() + }); + problems.sort((a, b) => { + return a.time - b.time; + }); + setJsonItem(this.store, key, problems); + } + async getEndToEndSessionProblem(deviceKey, timestamp) { + const key = keyEndToEndSessionProblems(deviceKey); + const problems = getJsonItem(this.store, key) || []; + if (!problems.length) { + return null; + } + const lastProblem = problems[problems.length - 1]; + for (const problem of problems) { + if (problem.time > timestamp) { + return Object.assign({}, problem, { + fixed: lastProblem.fixed + }); + } + } + if (lastProblem.fixed) { + return null; + } else { + return lastProblem; + } + } + async filterOutNotifiedErrorDevices(devices) { + const notifiedErrorDevices = getJsonItem(this.store, KEY_NOTIFIED_ERROR_DEVICES) || {}; + const ret = []; + for (const device of devices) { + const { + userId, + deviceInfo + } = device; + if (userId in notifiedErrorDevices) { + if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) { + ret.push(device); + (0, _utils.safeSet)(notifiedErrorDevices[userId], deviceInfo.deviceId, true); + } + } else { + ret.push(device); + (0, _utils.safeSet)(notifiedErrorDevices, userId, { + [deviceInfo.deviceId]: true + }); + } + } + setJsonItem(this.store, KEY_NOTIFIED_ERROR_DEVICES, notifiedErrorDevices); + return ret; + } + + // Inbound Group Sessions + + getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) { + func(getJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId)), getJsonItem(this.store, keyEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId))); + } + getAllEndToEndInboundGroupSessions(txn, func) { + for (let i = 0; i < this.store.length; ++i) { + const key = this.store.key(i); + if (key?.startsWith(KEY_INBOUND_SESSION_PREFIX)) { + // we can't use split, as the components we are trying to split out + // might themselves contain '/' characters. We rely on the + // senderKey being a (32-byte) curve25519 key, base64-encoded + // (hence 43 characters long). + + func({ + senderKey: key.slice(KEY_INBOUND_SESSION_PREFIX.length, KEY_INBOUND_SESSION_PREFIX.length + 43), + sessionId: key.slice(KEY_INBOUND_SESSION_PREFIX.length + 44), + sessionData: getJsonItem(this.store, key) + }); + } + } + func(null); + } + addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) { + const existing = getJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId)); + if (!existing) { + this.storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn); + } + } + storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) { + setJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId), sessionData); + } + storeEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId, sessionData, txn) { + setJsonItem(this.store, keyEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId), sessionData); + } + getEndToEndDeviceData(txn, func) { + func(getJsonItem(this.store, KEY_DEVICE_DATA)); + } + storeEndToEndDeviceData(deviceData, txn) { + setJsonItem(this.store, KEY_DEVICE_DATA, deviceData); + } + storeEndToEndRoom(roomId, roomInfo, txn) { + setJsonItem(this.store, keyEndToEndRoomsPrefix(roomId), roomInfo); + } + getEndToEndRooms(txn, func) { + const result = {}; + const prefix = keyEndToEndRoomsPrefix(""); + for (let i = 0; i < this.store.length; ++i) { + const key = this.store.key(i); + if (key?.startsWith(prefix)) { + const roomId = key.slice(prefix.length); + result[roomId] = getJsonItem(this.store, key); + } + } + func(result); + } + getSessionsNeedingBackup(limit) { + const sessionsNeedingBackup = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; + const sessions = []; + for (const session in sessionsNeedingBackup) { + if (Object.prototype.hasOwnProperty.call(sessionsNeedingBackup, session)) { + // see getAllEndToEndInboundGroupSessions for the magic number explanations + const senderKey = session.slice(0, 43); + const sessionId = session.slice(44); + this.getEndToEndInboundGroupSession(senderKey, sessionId, null, sessionData => { + sessions.push({ + senderKey: senderKey, + sessionId: sessionId, + sessionData: sessionData + }); + }); + if (limit && sessions.length >= limit) { + break; + } + } + } + return Promise.resolve(sessions); + } + countSessionsNeedingBackup() { + const sessionsNeedingBackup = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; + return Promise.resolve(Object.keys(sessionsNeedingBackup).length); + } + unmarkSessionsNeedingBackup(sessions) { + const sessionsNeedingBackup = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; + for (const session of sessions) { + delete sessionsNeedingBackup[session.senderKey + "/" + session.sessionId]; + } + setJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP, sessionsNeedingBackup); + return Promise.resolve(); + } + markSessionsNeedingBackup(sessions) { + const sessionsNeedingBackup = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; + for (const session of sessions) { + sessionsNeedingBackup[session.senderKey + "/" + session.sessionId] = true; + } + setJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP, sessionsNeedingBackup); + return Promise.resolve(); + } + + /** + * Delete all data from this store. + * + * @returns Promise which resolves when the store has been cleared. + */ + deleteAllData() { + this.store.removeItem(KEY_END_TO_END_ACCOUNT); + return Promise.resolve(); + } + + // Olm account + + getAccount(txn, func) { + const accountPickle = getJsonItem(this.store, KEY_END_TO_END_ACCOUNT); + func(accountPickle); + } + storeAccount(txn, accountPickle) { + setJsonItem(this.store, KEY_END_TO_END_ACCOUNT, accountPickle); + } + getCrossSigningKeys(txn, func) { + const keys = getJsonItem(this.store, KEY_CROSS_SIGNING_KEYS); + func(keys); + } + getSecretStorePrivateKey(txn, func, type) { + const key = getJsonItem(this.store, E2E_PREFIX + `ssss_cache.${type}`); + func(key); + } + storeCrossSigningKeys(txn, keys) { + setJsonItem(this.store, KEY_CROSS_SIGNING_KEYS, keys); + } + storeSecretStorePrivateKey(txn, type, key) { + setJsonItem(this.store, E2E_PREFIX + `ssss_cache.${type}`, key); + } + doTxn(mode, stores, func) { + return Promise.resolve(func(null)); + } +} +exports.LocalStorageCryptoStore = LocalStorageCryptoStore; +function getJsonItem(store, key) { + try { + // if the key is absent, store.getItem() returns null, and + // JSON.parse(null) === null, so this returns null. + return JSON.parse(store.getItem(key)); + } catch (e) { + _logger.logger.log("Error: Failed to get key %s: %s", key, e.message); + _logger.logger.log(e.stack); + } + return null; +} +function setJsonItem(store, key, val) { + store.setItem(key, JSON.stringify(val)); +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/memory-crypto-store.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/memory-crypto-store.js new file mode 100644 index 0000000000..5b9fba0289 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/memory-crypto-store.js @@ -0,0 +1,439 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.MemoryCryptoStore = void 0; +var _logger = require("../../logger"); +var _utils = require("../../utils"); +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2017 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/** + * Internal module. in-memory storage for e2e. + */ + +class MemoryCryptoStore { + constructor() { + _defineProperty(this, "outgoingRoomKeyRequests", []); + _defineProperty(this, "account", null); + _defineProperty(this, "crossSigningKeys", null); + _defineProperty(this, "privateKeys", {}); + _defineProperty(this, "sessions", {}); + _defineProperty(this, "sessionProblems", {}); + _defineProperty(this, "notifiedErrorDevices", {}); + _defineProperty(this, "inboundGroupSessions", {}); + _defineProperty(this, "inboundGroupSessionsWithheld", {}); + // Opaque device data object + _defineProperty(this, "deviceData", null); + _defineProperty(this, "rooms", {}); + _defineProperty(this, "sessionsNeedingBackup", {}); + _defineProperty(this, "sharedHistoryInboundGroupSessions", {}); + _defineProperty(this, "parkedSharedHistory", new Map()); + } + // keyed by room ID + /** + * Ensure the database exists and is up-to-date. + * + * This must be called before the store can be used. + * + * @returns resolves to the store. + */ + async startup() { + // No startup work to do for the memory store. + return this; + } + + /** + * Delete all data from this store. + * + * @returns Promise which resolves when the store has been cleared. + */ + deleteAllData() { + return Promise.resolve(); + } + + /** + * Look for an existing outgoing room key request, and if none is found, + * add a new one + * + * + * @returns resolves to + * {@link OutgoingRoomKeyRequest}: either the + * same instance as passed in, or the existing one. + */ + getOrAddOutgoingRoomKeyRequest(request) { + const requestBody = request.requestBody; + return (0, _utils.promiseTry)(() => { + // first see if we already have an entry for this request. + const existing = this._getOutgoingRoomKeyRequest(requestBody); + if (existing) { + // this entry matches the request - return it. + _logger.logger.log(`already have key request outstanding for ` + `${requestBody.room_id} / ${requestBody.session_id}: ` + `not sending another`); + return existing; + } + + // we got to the end of the list without finding a match + // - add the new request. + _logger.logger.log(`enqueueing key request for ${requestBody.room_id} / ` + requestBody.session_id); + this.outgoingRoomKeyRequests.push(request); + return request; + }); + } + + /** + * Look for an existing room key request + * + * @param requestBody - existing request to look for + * + * @returns resolves to the matching + * {@link OutgoingRoomKeyRequest}, or null if + * not found + */ + getOutgoingRoomKeyRequest(requestBody) { + return Promise.resolve(this._getOutgoingRoomKeyRequest(requestBody)); + } + + /** + * Looks for existing room key request, and returns the result synchronously. + * + * @internal + * + * @param requestBody - existing request to look for + * + * @returns + * the matching request, or null if not found + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + _getOutgoingRoomKeyRequest(requestBody) { + for (const existing of this.outgoingRoomKeyRequests) { + if ((0, _utils.deepCompare)(existing.requestBody, requestBody)) { + return existing; + } + } + return null; + } + + /** + * Look for room key requests by state + * + * @param wantedStates - list of acceptable states + * + * @returns resolves to the a + * {@link OutgoingRoomKeyRequest}, or null if + * there are no pending requests in those states + */ + getOutgoingRoomKeyRequestByState(wantedStates) { + for (const req of this.outgoingRoomKeyRequests) { + for (const state of wantedStates) { + if (req.state === state) { + return Promise.resolve(req); + } + } + } + return Promise.resolve(null); + } + + /** + * + * @returns All OutgoingRoomKeyRequests in state + */ + getAllOutgoingRoomKeyRequestsByState(wantedState) { + return Promise.resolve(this.outgoingRoomKeyRequests.filter(r => r.state == wantedState)); + } + getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) { + const results = []; + for (const req of this.outgoingRoomKeyRequests) { + for (const state of wantedStates) { + if (req.state === state && req.recipients.some(recipient => recipient.userId === userId && recipient.deviceId === deviceId)) { + results.push(req); + } + } + } + return Promise.resolve(results); + } + + /** + * Look for an existing room key request by id and state, and update it if + * found + * + * @param requestId - ID of request to update + * @param expectedState - state we expect to find the request in + * @param updates - name/value map of updates to apply + * + * @returns resolves to + * {@link OutgoingRoomKeyRequest} + * updated request, or null if no matching row was found + */ + updateOutgoingRoomKeyRequest(requestId, expectedState, updates) { + for (const req of this.outgoingRoomKeyRequests) { + if (req.requestId !== requestId) { + continue; + } + if (req.state !== expectedState) { + _logger.logger.warn(`Cannot update room key request from ${expectedState} ` + `as it was already updated to ${req.state}`); + return Promise.resolve(null); + } + Object.assign(req, updates); + return Promise.resolve(req); + } + return Promise.resolve(null); + } + + /** + * Look for an existing room key request by id and state, and delete it if + * found + * + * @param requestId - ID of request to update + * @param expectedState - state we expect to find the request in + * + * @returns resolves once the operation is completed + */ + deleteOutgoingRoomKeyRequest(requestId, expectedState) { + for (let i = 0; i < this.outgoingRoomKeyRequests.length; i++) { + const req = this.outgoingRoomKeyRequests[i]; + if (req.requestId !== requestId) { + continue; + } + if (req.state != expectedState) { + _logger.logger.warn(`Cannot delete room key request in state ${req.state} ` + `(expected ${expectedState})`); + return Promise.resolve(null); + } + this.outgoingRoomKeyRequests.splice(i, 1); + return Promise.resolve(req); + } + return Promise.resolve(null); + } + + // Olm Account + + getAccount(txn, func) { + func(this.account); + } + storeAccount(txn, accountPickle) { + this.account = accountPickle; + } + getCrossSigningKeys(txn, func) { + func(this.crossSigningKeys); + } + getSecretStorePrivateKey(txn, func, type) { + const result = this.privateKeys[type]; + func(result || null); + } + storeCrossSigningKeys(txn, keys) { + this.crossSigningKeys = keys; + } + storeSecretStorePrivateKey(txn, type, key) { + this.privateKeys[type] = key; + } + + // Olm Sessions + + countEndToEndSessions(txn, func) { + func(Object.keys(this.sessions).length); + } + getEndToEndSession(deviceKey, sessionId, txn, func) { + const deviceSessions = this.sessions[deviceKey] || {}; + func(deviceSessions[sessionId] || null); + } + getEndToEndSessions(deviceKey, txn, func) { + func(this.sessions[deviceKey] || {}); + } + getAllEndToEndSessions(txn, func) { + Object.entries(this.sessions).forEach(([deviceKey, deviceSessions]) => { + Object.entries(deviceSessions).forEach(([sessionId, session]) => { + func(_objectSpread(_objectSpread({}, session), {}, { + deviceKey, + sessionId + })); + }); + }); + } + storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) { + let deviceSessions = this.sessions[deviceKey]; + if (deviceSessions === undefined) { + deviceSessions = {}; + this.sessions[deviceKey] = deviceSessions; + } + (0, _utils.safeSet)(deviceSessions, sessionId, sessionInfo); + } + async storeEndToEndSessionProblem(deviceKey, type, fixed) { + const problems = this.sessionProblems[deviceKey] = this.sessionProblems[deviceKey] || []; + problems.push({ + type, + fixed, + time: Date.now() + }); + problems.sort((a, b) => { + return a.time - b.time; + }); + } + async getEndToEndSessionProblem(deviceKey, timestamp) { + const problems = this.sessionProblems[deviceKey] || []; + if (!problems.length) { + return null; + } + const lastProblem = problems[problems.length - 1]; + for (const problem of problems) { + if (problem.time > timestamp) { + return Object.assign({}, problem, { + fixed: lastProblem.fixed + }); + } + } + if (lastProblem.fixed) { + return null; + } else { + return lastProblem; + } + } + async filterOutNotifiedErrorDevices(devices) { + const notifiedErrorDevices = this.notifiedErrorDevices; + const ret = []; + for (const device of devices) { + const { + userId, + deviceInfo + } = device; + if (userId in notifiedErrorDevices) { + if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) { + ret.push(device); + (0, _utils.safeSet)(notifiedErrorDevices[userId], deviceInfo.deviceId, true); + } + } else { + ret.push(device); + (0, _utils.safeSet)(notifiedErrorDevices, userId, { + [deviceInfo.deviceId]: true + }); + } + } + return ret; + } + + // Inbound Group Sessions + + getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) { + const k = senderCurve25519Key + "/" + sessionId; + func(this.inboundGroupSessions[k] || null, this.inboundGroupSessionsWithheld[k] || null); + } + getAllEndToEndInboundGroupSessions(txn, func) { + for (const key of Object.keys(this.inboundGroupSessions)) { + // we can't use split, as the components we are trying to split out + // might themselves contain '/' characters. We rely on the + // senderKey being a (32-byte) curve25519 key, base64-encoded + // (hence 43 characters long). + + func({ + senderKey: key.slice(0, 43), + sessionId: key.slice(44), + sessionData: this.inboundGroupSessions[key] + }); + } + func(null); + } + addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) { + const k = senderCurve25519Key + "/" + sessionId; + if (this.inboundGroupSessions[k] === undefined) { + this.inboundGroupSessions[k] = sessionData; + } + } + storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) { + this.inboundGroupSessions[senderCurve25519Key + "/" + sessionId] = sessionData; + } + storeEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId, sessionData, txn) { + const k = senderCurve25519Key + "/" + sessionId; + this.inboundGroupSessionsWithheld[k] = sessionData; + } + + // Device Data + + getEndToEndDeviceData(txn, func) { + func(this.deviceData); + } + storeEndToEndDeviceData(deviceData, txn) { + this.deviceData = deviceData; + } + + // E2E rooms + + storeEndToEndRoom(roomId, roomInfo, txn) { + this.rooms[roomId] = roomInfo; + } + getEndToEndRooms(txn, func) { + func(this.rooms); + } + getSessionsNeedingBackup(limit) { + const sessions = []; + for (const session in this.sessionsNeedingBackup) { + if (this.inboundGroupSessions[session]) { + sessions.push({ + senderKey: session.slice(0, 43), + sessionId: session.slice(44), + sessionData: this.inboundGroupSessions[session] + }); + if (limit && session.length >= limit) { + break; + } + } + } + return Promise.resolve(sessions); + } + countSessionsNeedingBackup() { + return Promise.resolve(Object.keys(this.sessionsNeedingBackup).length); + } + unmarkSessionsNeedingBackup(sessions) { + for (const session of sessions) { + const sessionKey = session.senderKey + "/" + session.sessionId; + delete this.sessionsNeedingBackup[sessionKey]; + } + return Promise.resolve(); + } + markSessionsNeedingBackup(sessions) { + for (const session of sessions) { + const sessionKey = session.senderKey + "/" + session.sessionId; + this.sessionsNeedingBackup[sessionKey] = true; + } + return Promise.resolve(); + } + addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId) { + const sessions = this.sharedHistoryInboundGroupSessions[roomId] || []; + sessions.push([senderKey, sessionId]); + this.sharedHistoryInboundGroupSessions[roomId] = sessions; + } + getSharedHistoryInboundGroupSessions(roomId) { + return Promise.resolve(this.sharedHistoryInboundGroupSessions[roomId] || []); + } + addParkedSharedHistory(roomId, parkedData) { + const parked = this.parkedSharedHistory.get(roomId) ?? []; + parked.push(parkedData); + this.parkedSharedHistory.set(roomId, parked); + } + takeParkedSharedHistory(roomId) { + const parked = this.parkedSharedHistory.get(roomId) ?? []; + this.parkedSharedHistory.delete(roomId); + return Promise.resolve(parked); + } + + // Session key backups + + doTxn(mode, stores, func) { + return Promise.resolve(func(null)); + } +} +exports.MemoryCryptoStore = MemoryCryptoStore;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/Base.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/Base.js new file mode 100644 index 0000000000..4da45f880e --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/Base.js @@ -0,0 +1,345 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.VerificationEvent = exports.VerificationBase = exports.SwitchStartEventError = void 0; +var _event = require("../../models/event"); +var _event2 = require("../../@types/event"); +var _logger = require("../../logger"); +var _deviceinfo = require("../deviceinfo"); +var _Error = require("./Error"); +var _CrossSigning = require("../CrossSigning"); +var _typedEventEmitter = require("../../models/typed-event-emitter"); +var _verification = require("../../crypto-api/verification"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2018 New Vector Ltd + Copyright 2020 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ /** + * Base class for verification methods. + */ +const timeoutException = new Error("Verification timed out"); +class SwitchStartEventError extends Error { + constructor(startEvent) { + super(); + this.startEvent = startEvent; + } +} + +/** @deprecated use VerifierEvent */ +exports.SwitchStartEventError = SwitchStartEventError; +/** @deprecated use VerifierEvent */ +const VerificationEvent = _verification.VerifierEvent; + +/** @deprecated use VerifierEventHandlerMap */ +exports.VerificationEvent = VerificationEvent; +// The type parameters of VerificationBase are no longer used, but we need some placeholders to maintain +// backwards compatibility with applications that reference the class. +class VerificationBase extends _typedEventEmitter.TypedEventEmitter { + /** + * Base class for verification methods. + * + * <p>Once a verifier object is created, the verification can be started by + * calling the verify() method, which will return a promise that will + * resolve when the verification is completed, or reject if it could not + * complete.</p> + * + * <p>Subclasses must have a NAME class property.</p> + * + * @param channel - the verification channel to send verification messages over. + * TODO: Channel types + * + * @param baseApis - base matrix api interface + * + * @param userId - the user ID that is being verified + * + * @param deviceId - the device ID that is being verified + * + * @param startEvent - the m.key.verification.start event that + * initiated this verification, if any + * + * @param request - the key verification request object related to + * this verification, if any + */ + constructor(channel, baseApis, userId, deviceId, startEvent, request) { + super(); + this.channel = channel; + this.baseApis = baseApis; + this.userId = userId; + this.deviceId = deviceId; + this.startEvent = startEvent; + this.request = request; + _defineProperty(this, "cancelled", false); + _defineProperty(this, "_done", false); + _defineProperty(this, "promise", null); + _defineProperty(this, "transactionTimeoutTimer", null); + _defineProperty(this, "expectedEvent", void 0); + _defineProperty(this, "resolve", void 0); + _defineProperty(this, "reject", void 0); + _defineProperty(this, "resolveEvent", void 0); + _defineProperty(this, "rejectEvent", void 0); + _defineProperty(this, "started", void 0); + _defineProperty(this, "doVerification", void 0); + } + get initiatedByMe() { + // if there is no start event yet, + // we probably want to send it, + // which happens if we initiate + if (!this.startEvent) { + return true; + } + const sender = this.startEvent.getSender(); + const content = this.startEvent.getContent(); + return sender === this.baseApis.getUserId() && content.from_device === this.baseApis.getDeviceId(); + } + get hasBeenCancelled() { + return this.cancelled; + } + resetTimer() { + _logger.logger.info("Refreshing/starting the verification transaction timeout timer"); + if (this.transactionTimeoutTimer !== null) { + clearTimeout(this.transactionTimeoutTimer); + } + this.transactionTimeoutTimer = setTimeout(() => { + if (!this._done && !this.cancelled) { + _logger.logger.info("Triggering verification timeout"); + this.cancel(timeoutException); + } + }, 10 * 60 * 1000); // 10 minutes + } + + endTimer() { + if (this.transactionTimeoutTimer !== null) { + clearTimeout(this.transactionTimeoutTimer); + this.transactionTimeoutTimer = null; + } + } + send(type, uncompletedContent) { + return this.channel.send(type, uncompletedContent); + } + waitForEvent(type) { + if (this._done) { + return Promise.reject(new Error("Verification is already done")); + } + const existingEvent = this.request.getEventFromOtherParty(type); + if (existingEvent) { + return Promise.resolve(existingEvent); + } + this.expectedEvent = type; + return new Promise((resolve, reject) => { + this.resolveEvent = resolve; + this.rejectEvent = reject; + }); + } + canSwitchStartEvent(event) { + return false; + } + switchStartEvent(event) { + if (this.canSwitchStartEvent(event)) { + _logger.logger.log("Verification Base: switching verification start event", { + restartingFlow: !!this.rejectEvent + }); + if (this.rejectEvent) { + const reject = this.rejectEvent; + this.rejectEvent = undefined; + reject(new SwitchStartEventError(event)); + } else { + this.startEvent = event; + } + } + } + handleEvent(e) { + if (this._done) { + return; + } else if (e.getType() === this.expectedEvent) { + // if we receive an expected m.key.verification.done, then just + // ignore it, since we don't need to do anything about it + if (this.expectedEvent !== _event2.EventType.KeyVerificationDone) { + this.expectedEvent = undefined; + this.rejectEvent = undefined; + this.resetTimer(); + this.resolveEvent?.(e); + } + } else if (e.getType() === _event2.EventType.KeyVerificationCancel) { + const reject = this.reject; + this.reject = undefined; + // there is only promise to reject if verify has been called + if (reject) { + const content = e.getContent(); + const { + reason, + code + } = content; + reject(new Error(`Other side cancelled verification ` + `because ${reason} (${code})`)); + } + } else if (this.expectedEvent) { + // only cancel if there is an event expected. + // if there is no event expected, it means verify() wasn't called + // and we're just replaying the timeline events when syncing + // after a refresh when the events haven't been stored in the cache yet. + const exception = new Error("Unexpected message: expecting " + this.expectedEvent + " but got " + e.getType()); + this.expectedEvent = undefined; + if (this.rejectEvent) { + const reject = this.rejectEvent; + this.rejectEvent = undefined; + reject(exception); + } + this.cancel(exception); + } + } + async done() { + this.endTimer(); // always kill the activity timer + if (!this._done) { + this.request.onVerifierFinished(); + this.resolve?.(); + return (0, _CrossSigning.requestKeysDuringVerification)(this.baseApis, this.userId, this.deviceId); + } + } + cancel(e) { + this.endTimer(); // always kill the activity timer + if (!this._done) { + this.cancelled = true; + this.request.onVerifierCancelled(); + if (this.userId && this.deviceId) { + // send a cancellation to the other user (if it wasn't + // cancelled by the other user) + if (e === timeoutException) { + const timeoutEvent = (0, _Error.newTimeoutError)(); + this.send(timeoutEvent.getType(), timeoutEvent.getContent()); + } else if (e instanceof _event.MatrixEvent) { + const sender = e.getSender(); + if (sender !== this.userId) { + const content = e.getContent(); + if (e.getType() === _event2.EventType.KeyVerificationCancel) { + content.code = content.code || "m.unknown"; + content.reason = content.reason || content.body || "Unknown reason"; + this.send(_event2.EventType.KeyVerificationCancel, content); + } else { + this.send(_event2.EventType.KeyVerificationCancel, { + code: "m.unknown", + reason: content.body || "Unknown reason" + }); + } + } + } else { + this.send(_event2.EventType.KeyVerificationCancel, { + code: "m.unknown", + reason: e.toString() + }); + } + } + if (this.promise !== null) { + // when we cancel without a promise, we end up with a promise + // but no reject function. If cancel is called again, we'd error. + if (this.reject) this.reject(e); + } else { + // FIXME: this causes an "Uncaught promise" console message + // if nothing ends up chaining this promise. + this.promise = Promise.reject(e); + } + // Also emit a 'cancel' event that the app can listen for to detect cancellation + // before calling verify() + this.emit(VerificationEvent.Cancel, e); + } + } + + /** + * Begin the key verification + * + * @returns Promise which resolves when the verification has + * completed. + */ + verify() { + if (this.promise) return this.promise; + this.promise = new Promise((resolve, reject) => { + this.resolve = (...args) => { + this._done = true; + this.endTimer(); + resolve(...args); + }; + this.reject = e => { + this._done = true; + this.endTimer(); + reject(e); + }; + }); + if (this.doVerification && !this.started) { + this.started = true; + this.resetTimer(); // restart the timeout + new Promise((resolve, reject) => { + const crossSignId = this.baseApis.crypto.deviceList.getStoredCrossSigningForUser(this.userId)?.getId(); + if (crossSignId === this.deviceId) { + reject(new Error("Device ID is the same as the cross-signing ID")); + } + resolve(); + }).then(() => this.doVerification()).then(this.done.bind(this), this.cancel.bind(this)); + } + return this.promise; + } + async verifyKeys(userId, keys, verifier) { + // we try to verify all the keys that we're told about, but we might + // not know about all of them, so keep track of the keys that we know + // about, and ignore the rest + const verifiedDevices = []; + for (const [keyId, keyInfo] of Object.entries(keys)) { + const deviceId = keyId.split(":", 2)[1]; + const device = this.baseApis.getStoredDevice(userId, deviceId); + if (device) { + verifier(keyId, device, keyInfo); + verifiedDevices.push([deviceId, keyId, device.keys[keyId]]); + } else { + const crossSigningInfo = this.baseApis.crypto.deviceList.getStoredCrossSigningForUser(userId); + if (crossSigningInfo && crossSigningInfo.getId() === deviceId) { + verifier(keyId, _deviceinfo.DeviceInfo.fromStorage({ + keys: { + [keyId]: deviceId + } + }, deviceId), keyInfo); + verifiedDevices.push([deviceId, keyId, deviceId]); + } else { + _logger.logger.warn(`verification: Could not find device ${deviceId} to verify`); + } + } + } + + // if none of the keys could be verified, then error because the app + // should be informed about that + if (!verifiedDevices.length) { + throw new Error("No devices could be verified"); + } + _logger.logger.info("Verification completed! Marking devices verified: ", verifiedDevices); + // TODO: There should probably be a batch version of this, otherwise it's going + // to upload each signature in a separate API call which is silly because the + // API supports as many signatures as you like. + for (const [deviceId, keyId, key] of verifiedDevices) { + await this.baseApis.crypto.setDeviceVerification(userId, deviceId, true, null, null, { + [keyId]: key + }); + } + + // 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.baseApis.credentials.userId) { + await this.baseApis.checkKeyBackup(); + } + } + get events() { + return undefined; + } +} +exports.VerificationBase = VerificationBase;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/Error.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/Error.js new file mode 100644 index 0000000000..3d24c03955 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/Error.js @@ -0,0 +1,100 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.errorFactory = errorFactory; +exports.errorFromEvent = errorFromEvent; +exports.newUserCancelledError = exports.newUnknownMethodError = exports.newUnexpectedMessageError = exports.newTimeoutError = exports.newKeyMismatchError = exports.newInvalidMessageError = void 0; +exports.newVerificationError = newVerificationError; +var _event = require("../../models/event"); +var _event2 = require("../../@types/event"); +/* +Copyright 2018 - 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Error messages. + */ + +function newVerificationError(code, reason, extraData) { + const content = Object.assign({}, { + code, + reason + }, extraData); + return new _event.MatrixEvent({ + type: _event2.EventType.KeyVerificationCancel, + content + }); +} +function errorFactory(code, reason) { + return function (extraData) { + return newVerificationError(code, reason, extraData); + }; +} + +/** + * The verification was cancelled by the user. + */ +const newUserCancelledError = errorFactory("m.user", "Cancelled by user"); + +/** + * The verification timed out. + */ +exports.newUserCancelledError = newUserCancelledError; +const newTimeoutError = errorFactory("m.timeout", "Timed out"); + +/** + * An unknown method was selected. + */ +exports.newTimeoutError = newTimeoutError; +const newUnknownMethodError = errorFactory("m.unknown_method", "Unknown method"); + +/** + * An unexpected message was sent. + */ +exports.newUnknownMethodError = newUnknownMethodError; +const newUnexpectedMessageError = errorFactory("m.unexpected_message", "Unexpected message"); + +/** + * The key does not match. + */ +exports.newUnexpectedMessageError = newUnexpectedMessageError; +const newKeyMismatchError = errorFactory("m.key_mismatch", "Key mismatch"); + +/** + * An invalid message was sent. + */ +exports.newKeyMismatchError = newKeyMismatchError; +const newInvalidMessageError = errorFactory("m.invalid_message", "Invalid message"); +exports.newInvalidMessageError = newInvalidMessageError; +function errorFromEvent(event) { + const content = event.getContent(); + if (content) { + const { + code, + reason + } = content; + return { + code, + reason + }; + } else { + return { + code: "Unknown error", + reason: "m.unknown" + }; + } +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/IllegalMethod.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/IllegalMethod.js new file mode 100644 index 0000000000..396d911eec --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/IllegalMethod.js @@ -0,0 +1,46 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.IllegalMethod = void 0; +var _Base = require("./Base"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2020 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ /** + * Verification method that is illegal to have (cannot possibly + * do verification with this method). + */ +class IllegalMethod extends _Base.VerificationBase { + constructor(...args) { + super(...args); + _defineProperty(this, "doVerification", async () => { + throw new Error("Verification is not possible with this method"); + }); + } + static factory(channel, baseApis, userId, deviceId, startEvent, request) { + return new IllegalMethod(channel, baseApis, userId, deviceId, startEvent, request); + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + static get NAME() { + // Typically the name will be something else, but to complete + // the contract we offer a default one here. + return "org.matrix.illegal_method"; + } +} +exports.IllegalMethod = IllegalMethod;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/QRCode.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/QRCode.js new file mode 100644 index 0000000000..e2334c64e7 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/QRCode.js @@ -0,0 +1,269 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.SHOW_QR_CODE_METHOD = exports.SCAN_QR_CODE_METHOD = exports.ReciprocateQRCode = exports.QrCodeEvent = exports.QRCodeData = void 0; +var _Base = require("./Base"); +var _Error = require("./Error"); +var _olmlib = require("../olmlib"); +var _logger = require("../../logger"); +var _verification = require("../../crypto-api/verification"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2018 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ /** + * QR code key verification. + */ +const SHOW_QR_CODE_METHOD = "m.qr_code.show.v1"; +exports.SHOW_QR_CODE_METHOD = SHOW_QR_CODE_METHOD; +const SCAN_QR_CODE_METHOD = "m.qr_code.scan.v1"; + +/** @deprecated use VerifierEvent */ +exports.SCAN_QR_CODE_METHOD = SCAN_QR_CODE_METHOD; +/** @deprecated use VerifierEvent */ +const QrCodeEvent = _verification.VerifierEvent; +exports.QrCodeEvent = QrCodeEvent; +class ReciprocateQRCode extends _Base.VerificationBase { + constructor(...args) { + super(...args); + _defineProperty(this, "reciprocateQREvent", void 0); + _defineProperty(this, "doVerification", async () => { + if (!this.startEvent) { + // TODO: Support scanning QR codes + throw new Error("It is not currently possible to start verification" + "with this method yet."); + } + const { + qrCodeData + } = this.request; + // 1. check the secret + if (this.startEvent.getContent()["secret"] !== qrCodeData?.encodedSharedSecret) { + throw (0, _Error.newKeyMismatchError)(); + } + + // 2. ask if other user shows shield as well + await new Promise((resolve, reject) => { + this.reciprocateQREvent = { + confirm: resolve, + cancel: () => reject((0, _Error.newUserCancelledError)()) + }; + this.emit(QrCodeEvent.ShowReciprocateQr, this.reciprocateQREvent); + }); + + // 3. determine key to sign / mark as trusted + const keys = {}; + switch (qrCodeData?.mode) { + case Mode.VerifyOtherUser: + { + // add master key to keys to be signed, only if we're not doing self-verification + const masterKey = qrCodeData.otherUserMasterKey; + keys[`ed25519:${masterKey}`] = masterKey; + break; + } + case Mode.VerifySelfTrusted: + { + const deviceId = this.request.targetDevice.deviceId; + keys[`ed25519:${deviceId}`] = qrCodeData.otherDeviceKey; + break; + } + case Mode.VerifySelfUntrusted: + { + const masterKey = qrCodeData.myMasterKey; + keys[`ed25519:${masterKey}`] = masterKey; + break; + } + } + + // 4. sign the key (or mark own MSK as verified in case of MODE_VERIFY_SELF_TRUSTED) + await this.verifyKeys(this.userId, keys, (keyId, device, keyInfo) => { + // make sure the device has the expected keys + const targetKey = keys[keyId]; + if (!targetKey) throw (0, _Error.newKeyMismatchError)(); + if (keyInfo !== targetKey) { + _logger.logger.error("key ID from key info does not match"); + throw (0, _Error.newKeyMismatchError)(); + } + for (const deviceKeyId in device.keys) { + if (!deviceKeyId.startsWith("ed25519")) continue; + const deviceTargetKey = keys[deviceKeyId]; + if (!deviceTargetKey) throw (0, _Error.newKeyMismatchError)(); + if (device.keys[deviceKeyId] !== deviceTargetKey) { + _logger.logger.error("master key does not match"); + throw (0, _Error.newKeyMismatchError)(); + } + } + }); + }); + } + static factory(channel, baseApis, userId, deviceId, startEvent, request) { + return new ReciprocateQRCode(channel, baseApis, userId, deviceId, startEvent, request); + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + static get NAME() { + return "m.reciprocate.v1"; + } +} +exports.ReciprocateQRCode = ReciprocateQRCode; +const CODE_VERSION = 0x02; // the version of binary QR codes we support +const BINARY_PREFIX = "MATRIX"; // ASCII, used to prefix the binary format +var Mode = /*#__PURE__*/function (Mode) { + Mode[Mode["VerifyOtherUser"] = 0] = "VerifyOtherUser"; + Mode[Mode["VerifySelfTrusted"] = 1] = "VerifySelfTrusted"; + Mode[Mode["VerifySelfUntrusted"] = 2] = "VerifySelfUntrusted"; + return Mode; +}(Mode || {}); // We do not trust the master key +class QRCodeData { + constructor(mode, sharedSecret, + // only set when mode is MODE_VERIFY_OTHER_USER, master key of other party at time of generating QR code + otherUserMasterKey, + // only set when mode is MODE_VERIFY_SELF_TRUSTED, device key of other party at time of generating QR code + otherDeviceKey, + // only set when mode is MODE_VERIFY_SELF_UNTRUSTED, own master key at time of generating QR code + myMasterKey, buffer) { + this.mode = mode; + this.sharedSecret = sharedSecret; + this.otherUserMasterKey = otherUserMasterKey; + this.otherDeviceKey = otherDeviceKey; + this.myMasterKey = myMasterKey; + this.buffer = buffer; + } + static async create(request, client) { + const sharedSecret = QRCodeData.generateSharedSecret(); + const mode = QRCodeData.determineMode(request, client); + let otherUserMasterKey = null; + let otherDeviceKey = null; + let myMasterKey = null; + if (mode === Mode.VerifyOtherUser) { + const otherUserCrossSigningInfo = client.getStoredCrossSigningForUser(request.otherUserId); + otherUserMasterKey = otherUserCrossSigningInfo.getId("master"); + } else if (mode === Mode.VerifySelfTrusted) { + otherDeviceKey = await QRCodeData.getOtherDeviceKey(request, client); + } else if (mode === Mode.VerifySelfUntrusted) { + const myUserId = client.getUserId(); + const myCrossSigningInfo = client.getStoredCrossSigningForUser(myUserId); + myMasterKey = myCrossSigningInfo.getId("master"); + } + const qrData = QRCodeData.generateQrData(request, client, mode, sharedSecret, otherUserMasterKey, otherDeviceKey, myMasterKey); + const buffer = QRCodeData.generateBuffer(qrData); + return new QRCodeData(mode, sharedSecret, otherUserMasterKey, otherDeviceKey, myMasterKey, buffer); + } + + /** + * The unpadded base64 encoded shared secret. + */ + get encodedSharedSecret() { + return this.sharedSecret; + } + getBuffer() { + return this.buffer; + } + static generateSharedSecret() { + const secretBytes = new Uint8Array(11); + global.crypto.getRandomValues(secretBytes); + return (0, _olmlib.encodeUnpaddedBase64)(secretBytes); + } + static async getOtherDeviceKey(request, client) { + const myUserId = client.getUserId(); + const otherDevice = request.targetDevice; + const device = otherDevice.deviceId ? client.getStoredDevice(myUserId, otherDevice.deviceId) : undefined; + if (!device) { + throw new Error("could not find device " + otherDevice?.deviceId); + } + return device.getFingerprint(); + } + static determineMode(request, client) { + const myUserId = client.getUserId(); + const otherUserId = request.otherUserId; + let mode = Mode.VerifyOtherUser; + if (myUserId === otherUserId) { + // Mode changes depending on whether or not we trust the master cross signing key + const myTrust = client.checkUserTrust(myUserId); + if (myTrust.isCrossSigningVerified()) { + mode = Mode.VerifySelfTrusted; + } else { + mode = Mode.VerifySelfUntrusted; + } + } + return mode; + } + static generateQrData(request, client, mode, encodedSharedSecret, otherUserMasterKey, otherDeviceKey, myMasterKey) { + const myUserId = client.getUserId(); + const transactionId = request.channel.transactionId; + const qrData = { + prefix: BINARY_PREFIX, + version: CODE_VERSION, + mode, + transactionId, + firstKeyB64: "", + // worked out shortly + secondKeyB64: "", + // worked out shortly + secretB64: encodedSharedSecret + }; + const myCrossSigningInfo = client.getStoredCrossSigningForUser(myUserId); + if (mode === Mode.VerifyOtherUser) { + // First key is our master cross signing key + qrData.firstKeyB64 = myCrossSigningInfo.getId("master"); + // Second key is the other user's master cross signing key + qrData.secondKeyB64 = otherUserMasterKey; + } else if (mode === Mode.VerifySelfTrusted) { + // First key is our master cross signing key + qrData.firstKeyB64 = myCrossSigningInfo.getId("master"); + qrData.secondKeyB64 = otherDeviceKey; + } else if (mode === Mode.VerifySelfUntrusted) { + // First key is our device's key + qrData.firstKeyB64 = client.getDeviceEd25519Key(); + // Second key is what we think our master cross signing key is + qrData.secondKeyB64 = myMasterKey; + } + return qrData; + } + static generateBuffer(qrData) { + let buf = Buffer.alloc(0); // we'll concat our way through life + + const appendByte = b => { + const tmpBuf = Buffer.from([b]); + buf = Buffer.concat([buf, tmpBuf]); + }; + const appendInt = i => { + const tmpBuf = Buffer.alloc(2); + tmpBuf.writeInt16BE(i, 0); + buf = Buffer.concat([buf, tmpBuf]); + }; + const appendStr = (s, enc, withLengthPrefix = true) => { + const tmpBuf = Buffer.from(s, enc); + if (withLengthPrefix) appendInt(tmpBuf.byteLength); + buf = Buffer.concat([buf, tmpBuf]); + }; + const appendEncBase64 = b64 => { + const b = (0, _olmlib.decodeBase64)(b64); + const tmpBuf = Buffer.from(b); + buf = Buffer.concat([buf, tmpBuf]); + }; + + // Actually build the buffer for the QR code + appendStr(qrData.prefix, "ascii", false); + appendByte(qrData.version); + appendByte(qrData.mode); + appendStr(qrData.transactionId, "utf-8"); + appendEncBase64(qrData.firstKeyB64); + appendEncBase64(qrData.secondKeyB64); + appendEncBase64(qrData.secretB64); + return buf; + } +} +exports.QRCodeData = QRCodeData;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/SAS.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/SAS.js new file mode 100644 index 0000000000..fac79f7a00 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/SAS.js @@ -0,0 +1,454 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.SasEvent = exports.SAS = void 0; +var _anotherJson = _interopRequireDefault(require("another-json")); +var _Base = require("./Base"); +var _Error = require("./Error"); +var _logger = require("../../logger"); +var _SASDecimal = require("./SASDecimal"); +var _event = require("../../@types/event"); +var _verification = require("../../crypto-api/verification"); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2018 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ /** + * Short Authentication String (SAS) verification. + */ +// backwards-compatibility exports + +const START_TYPE = _event.EventType.KeyVerificationStart; +const EVENTS = [_event.EventType.KeyVerificationAccept, _event.EventType.KeyVerificationKey, _event.EventType.KeyVerificationMac]; +let olmutil; +const newMismatchedSASError = (0, _Error.errorFactory)("m.mismatched_sas", "Mismatched short authentication string"); +const newMismatchedCommitmentError = (0, _Error.errorFactory)("m.mismatched_commitment", "Mismatched commitment"); +const emojiMapping = [["🐶", "dog"], +// 0 +["🐱", "cat"], +// 1 +["🦁", "lion"], +// 2 +["🐎", "horse"], +// 3 +["🦄", "unicorn"], +// 4 +["🐷", "pig"], +// 5 +["🐘", "elephant"], +// 6 +["🐰", "rabbit"], +// 7 +["🐼", "panda"], +// 8 +["🐓", "rooster"], +// 9 +["🐧", "penguin"], +// 10 +["🐢", "turtle"], +// 11 +["🐟", "fish"], +// 12 +["🐙", "octopus"], +// 13 +["🦋", "butterfly"], +// 14 +["🌷", "flower"], +// 15 +["🌳", "tree"], +// 16 +["🌵", "cactus"], +// 17 +["🍄", "mushroom"], +// 18 +["🌏", "globe"], +// 19 +["🌙", "moon"], +// 20 +["☁️", "cloud"], +// 21 +["🔥", "fire"], +// 22 +["🍌", "banana"], +// 23 +["🍎", "apple"], +// 24 +["🍓", "strawberry"], +// 25 +["🌽", "corn"], +// 26 +["🍕", "pizza"], +// 27 +["🎂", "cake"], +// 28 +["❤️", "heart"], +// 29 +["🙂", "smiley"], +// 30 +["🤖", "robot"], +// 31 +["🎩", "hat"], +// 32 +["👓", "glasses"], +// 33 +["🔧", "spanner"], +// 34 +["🎅", "santa"], +// 35 +["👍", "thumbs up"], +// 36 +["☂️", "umbrella"], +// 37 +["⌛", "hourglass"], +// 38 +["⏰", "clock"], +// 39 +["🎁", "gift"], +// 40 +["💡", "light bulb"], +// 41 +["📕", "book"], +// 42 +["✏️", "pencil"], +// 43 +["📎", "paperclip"], +// 44 +["✂️", "scissors"], +// 45 +["🔒", "lock"], +// 46 +["🔑", "key"], +// 47 +["🔨", "hammer"], +// 48 +["☎️", "telephone"], +// 49 +["🏁", "flag"], +// 50 +["🚂", "train"], +// 51 +["🚲", "bicycle"], +// 52 +["✈️", "aeroplane"], +// 53 +["🚀", "rocket"], +// 54 +["🏆", "trophy"], +// 55 +["⚽", "ball"], +// 56 +["🎸", "guitar"], +// 57 +["🎺", "trumpet"], +// 58 +["🔔", "bell"], +// 59 +["⚓️", "anchor"], +// 60 +["🎧", "headphones"], +// 61 +["📁", "folder"], +// 62 +["📌", "pin"] // 63 +]; + +function generateEmojiSas(sasBytes) { + const emojis = [ + // just like base64 encoding + sasBytes[0] >> 2, (sasBytes[0] & 0x3) << 4 | sasBytes[1] >> 4, (sasBytes[1] & 0xf) << 2 | sasBytes[2] >> 6, sasBytes[2] & 0x3f, sasBytes[3] >> 2, (sasBytes[3] & 0x3) << 4 | sasBytes[4] >> 4, (sasBytes[4] & 0xf) << 2 | sasBytes[5] >> 6]; + return emojis.map(num => emojiMapping[num]); +} +const sasGenerators = { + decimal: _SASDecimal.generateDecimalSas, + emoji: generateEmojiSas +}; +function generateSas(sasBytes, methods) { + const sas = {}; + for (const method of methods) { + if (method in sasGenerators) { + // @ts-ignore - ts doesn't like us mixing types like this + sas[method] = sasGenerators[method](Array.from(sasBytes)); + } + } + return sas; +} +const macMethods = { + "hkdf-hmac-sha256": "calculate_mac", + "org.matrix.msc3783.hkdf-hmac-sha256": "calculate_mac_fixed_base64", + "hkdf-hmac-sha256.v2": "calculate_mac_fixed_base64", + "hmac-sha256": "calculate_mac_long_kdf" +}; +function calculateMAC(olmSAS, method) { + return function (input, info) { + const mac = olmSAS[macMethods[method]](input, info); + _logger.logger.log("SAS calculateMAC:", method, [input, info], mac); + return mac; + }; +} +const calculateKeyAgreement = { + // eslint-disable-next-line @typescript-eslint/naming-convention + "curve25519-hkdf-sha256": function (sas, olmSAS, bytes) { + const ourInfo = `${sas.baseApis.getUserId()}|${sas.baseApis.deviceId}|` + `${sas.ourSASPubKey}|`; + const theirInfo = `${sas.userId}|${sas.deviceId}|${sas.theirSASPubKey}|`; + const sasInfo = "MATRIX_KEY_VERIFICATION_SAS|" + (sas.initiatedByMe ? ourInfo + theirInfo : theirInfo + ourInfo) + sas.channel.transactionId; + return olmSAS.generate_bytes(sasInfo, bytes); + }, + "curve25519": function (sas, olmSAS, bytes) { + const ourInfo = `${sas.baseApis.getUserId()}${sas.baseApis.deviceId}`; + const theirInfo = `${sas.userId}${sas.deviceId}`; + const sasInfo = "MATRIX_KEY_VERIFICATION_SAS" + (sas.initiatedByMe ? ourInfo + theirInfo : theirInfo + ourInfo) + sas.channel.transactionId; + return olmSAS.generate_bytes(sasInfo, bytes); + } +}; +/* lists of algorithms/methods that are supported. The key agreement, hashes, + * and MAC lists should be sorted in order of preference (most preferred + * first). + */ +const KEY_AGREEMENT_LIST = ["curve25519-hkdf-sha256", "curve25519"]; +const HASHES_LIST = ["sha256"]; +const MAC_LIST = ["hkdf-hmac-sha256.v2", "org.matrix.msc3783.hkdf-hmac-sha256", "hkdf-hmac-sha256", "hmac-sha256"]; +const SAS_LIST = Object.keys(sasGenerators); +const KEY_AGREEMENT_SET = new Set(KEY_AGREEMENT_LIST); +const HASHES_SET = new Set(HASHES_LIST); +const MAC_SET = new Set(MAC_LIST); +const SAS_SET = new Set(SAS_LIST); +function intersection(anArray, aSet) { + return Array.isArray(anArray) ? anArray.filter(x => aSet.has(x)) : []; +} + +/** @deprecated use VerifierEvent */ + +/** @deprecated use VerifierEvent */ +const SasEvent = _verification.VerifierEvent; +exports.SasEvent = SasEvent; +class SAS extends _Base.VerificationBase { + constructor(...args) { + super(...args); + _defineProperty(this, "waitingForAccept", void 0); + _defineProperty(this, "ourSASPubKey", void 0); + _defineProperty(this, "theirSASPubKey", void 0); + _defineProperty(this, "sasEvent", void 0); + _defineProperty(this, "doVerification", async () => { + await global.Olm.init(); + olmutil = olmutil || new global.Olm.Utility(); + + // make sure user's keys are downloaded + await this.baseApis.downloadKeys([this.userId]); + let retry = false; + do { + try { + if (this.initiatedByMe) { + return await this.doSendVerification(); + } else { + return await this.doRespondVerification(); + } + } catch (err) { + if (err instanceof _Base.SwitchStartEventError) { + // this changes what initiatedByMe returns + this.startEvent = err.startEvent; + retry = true; + } else { + throw err; + } + } + } while (retry); + }); + } + // eslint-disable-next-line @typescript-eslint/naming-convention + static get NAME() { + return "m.sas.v1"; + } + get events() { + return EVENTS; + } + canSwitchStartEvent(event) { + if (event.getType() !== START_TYPE) { + return false; + } + const content = event.getContent(); + return content?.method === SAS.NAME && !!this.waitingForAccept; + } + async sendStart() { + const startContent = this.channel.completeContent(START_TYPE, { + method: SAS.NAME, + from_device: this.baseApis.deviceId, + key_agreement_protocols: KEY_AGREEMENT_LIST, + hashes: HASHES_LIST, + message_authentication_codes: MAC_LIST, + // FIXME: allow app to specify what SAS methods can be used + short_authentication_string: SAS_LIST + }); + await this.channel.sendCompleted(START_TYPE, startContent); + return startContent; + } + async verifyAndCheckMAC(keyAgreement, sasMethods, olmSAS, macMethod) { + const sasBytes = calculateKeyAgreement[keyAgreement](this, olmSAS, 6); + const verifySAS = new Promise((resolve, reject) => { + this.sasEvent = { + sas: generateSas(sasBytes, sasMethods), + confirm: async () => { + try { + await this.sendMAC(olmSAS, macMethod); + resolve(); + } catch (err) { + reject(err); + } + }, + cancel: () => reject((0, _Error.newUserCancelledError)()), + mismatch: () => reject(newMismatchedSASError()) + }; + this.emit(SasEvent.ShowSas, this.sasEvent); + }); + const [e] = await Promise.all([this.waitForEvent(_event.EventType.KeyVerificationMac).then(e => { + // we don't expect any more messages from the other + // party, and they may send a m.key.verification.done + // when they're done on their end + this.expectedEvent = _event.EventType.KeyVerificationDone; + return e; + }), verifySAS]); + const content = e.getContent(); + await this.checkMAC(olmSAS, content, macMethod); + } + async doSendVerification() { + this.waitingForAccept = true; + let startContent; + if (this.startEvent) { + startContent = this.channel.completedContentFromEvent(this.startEvent); + } else { + startContent = await this.sendStart(); + } + + // we might have switched to a different start event, + // but was we didn't call _waitForEvent there was no + // call that could throw yet. So check manually that + // we're still on the initiator side + if (!this.initiatedByMe) { + throw new _Base.SwitchStartEventError(this.startEvent); + } + let e; + try { + e = await this.waitForEvent(_event.EventType.KeyVerificationAccept); + } finally { + this.waitingForAccept = false; + } + let content = e.getContent(); + const sasMethods = intersection(content.short_authentication_string, SAS_SET); + if (!(KEY_AGREEMENT_SET.has(content.key_agreement_protocol) && HASHES_SET.has(content.hash) && MAC_SET.has(content.message_authentication_code) && sasMethods.length)) { + throw (0, _Error.newUnknownMethodError)(); + } + if (typeof content.commitment !== "string") { + throw (0, _Error.newInvalidMessageError)(); + } + const keyAgreement = content.key_agreement_protocol; + const macMethod = content.message_authentication_code; + const hashCommitment = content.commitment; + const olmSAS = new global.Olm.SAS(); + try { + this.ourSASPubKey = olmSAS.get_pubkey(); + await this.send(_event.EventType.KeyVerificationKey, { + key: this.ourSASPubKey + }); + e = await this.waitForEvent(_event.EventType.KeyVerificationKey); + // FIXME: make sure event is properly formed + content = e.getContent(); + const commitmentStr = content.key + _anotherJson.default.stringify(startContent); + // TODO: use selected hash function (when we support multiple) + if (olmutil.sha256(commitmentStr) !== hashCommitment) { + throw newMismatchedCommitmentError(); + } + this.theirSASPubKey = content.key; + olmSAS.set_their_key(content.key); + await this.verifyAndCheckMAC(keyAgreement, sasMethods, olmSAS, macMethod); + } finally { + olmSAS.free(); + } + } + async doRespondVerification() { + // as m.related_to is not included in the encrypted content in e2e rooms, + // we need to make sure it is added + let content = this.channel.completedContentFromEvent(this.startEvent); + + // Note: we intersect using our pre-made lists, rather than the sets, + // so that the result will be in our order of preference. Then + // fetching the first element from the array will give our preferred + // method out of the ones offered by the other party. + const keyAgreement = intersection(KEY_AGREEMENT_LIST, new Set(content.key_agreement_protocols))[0]; + const hashMethod = intersection(HASHES_LIST, new Set(content.hashes))[0]; + const macMethod = intersection(MAC_LIST, new Set(content.message_authentication_codes))[0]; + // FIXME: allow app to specify what SAS methods can be used + const sasMethods = intersection(content.short_authentication_string, SAS_SET); + if (!(keyAgreement !== undefined && hashMethod !== undefined && macMethod !== undefined && sasMethods.length)) { + throw (0, _Error.newUnknownMethodError)(); + } + const olmSAS = new global.Olm.SAS(); + try { + const commitmentStr = olmSAS.get_pubkey() + _anotherJson.default.stringify(content); + await this.send(_event.EventType.KeyVerificationAccept, { + key_agreement_protocol: keyAgreement, + hash: hashMethod, + message_authentication_code: macMethod, + short_authentication_string: sasMethods, + // TODO: use selected hash function (when we support multiple) + commitment: olmutil.sha256(commitmentStr) + }); + const e = await this.waitForEvent(_event.EventType.KeyVerificationKey); + // FIXME: make sure event is properly formed + content = e.getContent(); + this.theirSASPubKey = content.key; + olmSAS.set_their_key(content.key); + this.ourSASPubKey = olmSAS.get_pubkey(); + await this.send(_event.EventType.KeyVerificationKey, { + key: this.ourSASPubKey + }); + await this.verifyAndCheckMAC(keyAgreement, sasMethods, olmSAS, macMethod); + } finally { + olmSAS.free(); + } + } + sendMAC(olmSAS, method) { + const mac = {}; + const keyList = []; + const baseInfo = "MATRIX_KEY_VERIFICATION_MAC" + this.baseApis.getUserId() + this.baseApis.deviceId + this.userId + this.deviceId + this.channel.transactionId; + const deviceKeyId = `ed25519:${this.baseApis.deviceId}`; + mac[deviceKeyId] = calculateMAC(olmSAS, method)(this.baseApis.getDeviceEd25519Key(), baseInfo + deviceKeyId); + keyList.push(deviceKeyId); + const crossSigningId = this.baseApis.getCrossSigningId(); + if (crossSigningId) { + const crossSigningKeyId = `ed25519:${crossSigningId}`; + mac[crossSigningKeyId] = calculateMAC(olmSAS, method)(crossSigningId, baseInfo + crossSigningKeyId); + keyList.push(crossSigningKeyId); + } + const keys = calculateMAC(olmSAS, method)(keyList.sort().join(","), baseInfo + "KEY_IDS"); + return this.send(_event.EventType.KeyVerificationMac, { + mac, + keys + }); + } + async checkMAC(olmSAS, content, method) { + const baseInfo = "MATRIX_KEY_VERIFICATION_MAC" + this.userId + this.deviceId + this.baseApis.getUserId() + this.baseApis.deviceId + this.channel.transactionId; + if (content.keys !== calculateMAC(olmSAS, method)(Object.keys(content.mac).sort().join(","), baseInfo + "KEY_IDS")) { + throw (0, _Error.newKeyMismatchError)(); + } + await this.verifyKeys(this.userId, content.mac, (keyId, device, keyInfo) => { + if (keyInfo !== calculateMAC(olmSAS, method)(device.keys[keyId], baseInfo + keyId)) { + throw (0, _Error.newKeyMismatchError)(); + } + }); + } +} +exports.SAS = SAS;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/SASDecimal.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/SASDecimal.js new file mode 100644 index 0000000000..7cd8fb2505 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/SASDecimal.js @@ -0,0 +1,39 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.generateDecimalSas = generateDecimalSas; +/* +Copyright 2018 - 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Implementation of decimal encoding of SAS as per: + * https://spec.matrix.org/v1.4/client-server-api/#sas-method-decimal + * @param sasBytes - the five bytes generated by HKDF + * @returns the derived three numbers between 1000 and 9191 inclusive + */ +function generateDecimalSas(sasBytes) { + /* + * +--------+--------+--------+--------+--------+ + * | Byte 0 | Byte 1 | Byte 2 | Byte 3 | Byte 4 | + * +--------+--------+--------+--------+--------+ + * bits: 87654321 87654321 87654321 87654321 87654321 + * \____________/\_____________/\____________/ + * 1st number 2nd number 3rd number + */ + return [(sasBytes[0] << 5 | sasBytes[1] >> 3) + 1000, ((sasBytes[1] & 0x7) << 10 | sasBytes[2] << 2 | sasBytes[3] >> 6) + 1000, ((sasBytes[3] & 0x3f) << 7 | sasBytes[4] >> 1) + 1000]; +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/Channel.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/Channel.js new file mode 100644 index 0000000000..430afc16cd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/Channel.js @@ -0,0 +1,5 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/InRoomChannel.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/InRoomChannel.js new file mode 100644 index 0000000000..15c7fcae5a --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/InRoomChannel.js @@ -0,0 +1,349 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.InRoomRequests = exports.InRoomChannel = void 0; +var _VerificationRequest = require("./VerificationRequest"); +var _logger = require("../../../logger"); +var _event = require("../../../@types/event"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2018 New Vector Ltd + Copyright 2019 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +const MESSAGE_TYPE = _event.EventType.RoomMessage; +const M_REFERENCE = "m.reference"; +const M_RELATES_TO = "m.relates_to"; + +/** + * A key verification channel that sends verification events in the timeline of a room. + * Uses the event id of the initial m.key.verification.request event as a transaction id. + */ +class InRoomChannel { + /** + * @param client - the matrix client, to send messages with and get current user & device from. + * @param roomId - id of the room where verification events should be posted in, should be a DM with the given user. + * @param userId - id of user that the verification request is directed at, should be present in the room. + */ + constructor(client, roomId, userId) { + this.client = client; + this.roomId = roomId; + this.userId = userId; + _defineProperty(this, "requestEventId", void 0); + } + get receiveStartFromOtherDevices() { + return true; + } + + /** The transaction id generated/used by this verification channel */ + get transactionId() { + return this.requestEventId; + } + static getOtherPartyUserId(event, client) { + const type = InRoomChannel.getEventType(event); + if (type !== _VerificationRequest.REQUEST_TYPE) { + return; + } + const ownUserId = client.getUserId(); + const sender = event.getSender(); + const content = event.getContent(); + const receiver = content.to; + if (sender === ownUserId) { + return receiver; + } else if (receiver === ownUserId) { + return sender; + } + } + + /** + * @param event - the event to get the timestamp of + * @returns the timestamp when the event was sent + */ + getTimestamp(event) { + return event.getTs(); + } + + /** + * Checks whether the given event type should be allowed to initiate a new VerificationRequest over this channel + * @param type - the event type to check + * @returns boolean flag + */ + static canCreateRequest(type) { + return type === _VerificationRequest.REQUEST_TYPE; + } + canCreateRequest(type) { + return InRoomChannel.canCreateRequest(type); + } + + /** + * Extract the transaction id used by a given key verification event, if any + * @param event - the event + * @returns the transaction id + */ + static getTransactionId(event) { + if (InRoomChannel.getEventType(event) === _VerificationRequest.REQUEST_TYPE) { + return event.getId(); + } else { + const relation = event.getRelation(); + if (relation?.rel_type === M_REFERENCE) { + return relation.event_id; + } + } + } + + /** + * Checks whether this event is a well-formed key verification event. + * This only does checks that don't rely on the current state of a potentially already channel + * so we can prevent channels being created by invalid events. + * `handleEvent` can do more checks and choose to ignore invalid events. + * @param event - the event to validate + * @param client - the client to get the current user and device id from + * @returns whether the event is valid and should be passed to handleEvent + */ + static validateEvent(event, client) { + const txnId = InRoomChannel.getTransactionId(event); + if (typeof txnId !== "string" || txnId.length === 0) { + return false; + } + const type = InRoomChannel.getEventType(event); + const content = event.getContent(); + + // from here on we're fairly sure that this is supposed to be + // part of a verification request, so be noisy when rejecting something + if (type === _VerificationRequest.REQUEST_TYPE) { + if (!content || typeof content.to !== "string" || !content.to.length) { + _logger.logger.log("InRoomChannel: validateEvent: " + "no valid to " + content.to); + return false; + } + + // ignore requests that are not direct to or sent by the syncing user + if (!InRoomChannel.getOtherPartyUserId(event, client)) { + _logger.logger.log("InRoomChannel: validateEvent: " + `not directed to or sent by me: ${event.getSender()}` + `, ${content.to}`); + return false; + } + } + return _VerificationRequest.VerificationRequest.validateEvent(type, event, client); + } + + /** + * As m.key.verification.request events are as m.room.message events with the InRoomChannel + * to have a fallback message in non-supporting clients, we map the real event type + * to the symbolic one to keep things in unison with ToDeviceChannel + * @param event - the event to get the type of + * @returns the "symbolic" event type + */ + static getEventType(event) { + const type = event.getType(); + if (type === MESSAGE_TYPE) { + const content = event.getContent(); + if (content) { + const { + msgtype + } = content; + if (msgtype === _VerificationRequest.REQUEST_TYPE) { + return _VerificationRequest.REQUEST_TYPE; + } + } + } + if (type && type !== _VerificationRequest.REQUEST_TYPE) { + return type; + } else { + return ""; + } + } + + /** + * Changes the state of the channel, request, and verifier in response to a key verification event. + * @param event - to handle + * @param request - the request to forward handling to + * @param isLiveEvent - whether this is an even received through sync or not + * @returns a promise that resolves when any requests as an answer to the passed-in event are sent. + */ + async handleEvent(event, request, isLiveEvent = false) { + // prevent processing the same event multiple times, as under + // some circumstances Room.timeline can get emitted twice for the same event + if (request.hasEventId(event.getId())) { + return; + } + const type = InRoomChannel.getEventType(event); + // do validations that need state (roomId, userId), + // ignore if invalid + + if (event.getRoomId() !== this.roomId) { + return; + } + // set userId if not set already + if (!this.userId) { + const userId = InRoomChannel.getOtherPartyUserId(event, this.client); + if (userId) { + this.userId = userId; + } + } + // ignore events not sent by us or the other party + const ownUserId = this.client.getUserId(); + const sender = event.getSender(); + if (this.userId) { + if (sender !== ownUserId && sender !== this.userId) { + _logger.logger.log(`InRoomChannel: ignoring verification event from non-participating sender ${sender}`); + return; + } + } + if (!this.requestEventId) { + this.requestEventId = InRoomChannel.getTransactionId(event); + } + + // With pendingEventOrdering: "chronological", we will see events that have been sent but not yet reflected + // back via /sync. These are "local echoes" and are identifiable by their txnId + const isLocalEcho = !!event.getTxnId(); + + // Alternatively, we may see an event that we sent that is reflected back via /sync. These are "remote echoes" + // and have a transaction ID in the "unsigned" data + const isRemoteEcho = !!event.getUnsigned().transaction_id; + const isSentByUs = event.getSender() === this.client.getUserId(); + return request.handleEvent(type, event, isLiveEvent, isLocalEcho || isRemoteEcho, isSentByUs); + } + + /** + * Adds the transaction id (relation) back to a received event + * so it has the same format as returned by `completeContent` before sending. + * The relation can not appear on the event content because of encryption, + * relations are excluded from encryption. + * @param event - the received event + * @returns the content object with the relation added again + */ + completedContentFromEvent(event) { + // ensure m.related_to is included in e2ee rooms + // as the field is excluded from encryption + const content = Object.assign({}, event.getContent()); + content[M_RELATES_TO] = event.getRelation(); + return content; + } + + /** + * Add all the fields to content needed for sending it over this channel. + * This is public so verification methods (SAS uses this) can get the exact + * content that will be sent independent of the used channel, + * as they need to calculate the hash of it. + * @param type - the event type + * @param content - the (incomplete) content + * @returns the complete content, as it will be sent. + */ + completeContent(type, content) { + content = Object.assign({}, content); + if (type === _VerificationRequest.REQUEST_TYPE || type === _VerificationRequest.READY_TYPE || type === _VerificationRequest.START_TYPE) { + content.from_device = this.client.getDeviceId(); + } + if (type === _VerificationRequest.REQUEST_TYPE) { + // type is mapped to m.room.message in the send method + content = { + body: this.client.getUserId() + " is requesting to verify " + "your key, but your client does not support in-chat key " + "verification. You will need to use legacy key " + "verification to verify keys.", + msgtype: _VerificationRequest.REQUEST_TYPE, + to: this.userId, + from_device: content.from_device, + methods: content.methods + }; + } else { + content[M_RELATES_TO] = { + rel_type: M_REFERENCE, + event_id: this.transactionId + }; + } + return content; + } + + /** + * Send an event over the channel with the content not having gone through `completeContent`. + * @param type - the event type + * @param uncompletedContent - the (incomplete) content + * @returns the promise of the request + */ + send(type, uncompletedContent) { + const content = this.completeContent(type, uncompletedContent); + return this.sendCompleted(type, content); + } + + /** + * Send an event over the channel with the content having gone through `completeContent` already. + * @param type - the event type + * @returns the promise of the request + */ + async sendCompleted(type, content) { + let sendType = type; + if (type === _VerificationRequest.REQUEST_TYPE) { + sendType = MESSAGE_TYPE; + } + const response = await this.client.sendEvent(this.roomId, sendType, content); + if (type === _VerificationRequest.REQUEST_TYPE) { + this.requestEventId = response.event_id; + } + } +} +exports.InRoomChannel = InRoomChannel; +class InRoomRequests { + constructor() { + _defineProperty(this, "requestsByRoomId", new Map()); + } + getRequest(event) { + const roomId = event.getRoomId(); + const txnId = InRoomChannel.getTransactionId(event); + return this.getRequestByTxnId(roomId, txnId); + } + getRequestByChannel(channel) { + return this.getRequestByTxnId(channel.roomId, channel.transactionId); + } + getRequestByTxnId(roomId, txnId) { + const requestsByTxnId = this.requestsByRoomId.get(roomId); + if (requestsByTxnId) { + return requestsByTxnId.get(txnId); + } + } + setRequest(event, request) { + this.doSetRequest(event.getRoomId(), InRoomChannel.getTransactionId(event), request); + } + setRequestByChannel(channel, request) { + this.doSetRequest(channel.roomId, channel.transactionId, request); + } + doSetRequest(roomId, txnId, request) { + let requestsByTxnId = this.requestsByRoomId.get(roomId); + if (!requestsByTxnId) { + requestsByTxnId = new Map(); + this.requestsByRoomId.set(roomId, requestsByTxnId); + } + requestsByTxnId.set(txnId, request); + } + removeRequest(event) { + const roomId = event.getRoomId(); + const requestsByTxnId = this.requestsByRoomId.get(roomId); + if (requestsByTxnId) { + requestsByTxnId.delete(InRoomChannel.getTransactionId(event)); + if (requestsByTxnId.size === 0) { + this.requestsByRoomId.delete(roomId); + } + } + } + findRequestInProgress(roomId) { + const requestsByTxnId = this.requestsByRoomId.get(roomId); + if (requestsByTxnId) { + for (const request of requestsByTxnId.values()) { + if (request.pending) { + return request; + } + } + } + } +} +exports.InRoomRequests = InRoomRequests;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/ToDeviceChannel.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/ToDeviceChannel.js new file mode 100644 index 0000000000..781ec9358f --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/ToDeviceChannel.js @@ -0,0 +1,322 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.ToDeviceRequests = exports.ToDeviceChannel = void 0; +var _randomstring = require("../../../randomstring"); +var _logger = require("../../../logger"); +var _VerificationRequest = require("./VerificationRequest"); +var _Error = require("../Error"); +var _event = require("../../../models/event"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2018 New Vector Ltd + Copyright 2019 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/** + * A key verification channel that sends verification events over to_device messages. + * Generates its own transaction ids. + */ +class ToDeviceChannel { + // userId and devices of user we're about to verify + constructor(client, userId, devices, transactionId, deviceId) { + this.client = client; + this.userId = userId; + this.devices = devices; + this.transactionId = transactionId; + this.deviceId = deviceId; + _defineProperty(this, "request", void 0); + } + isToDevices(devices) { + if (devices.length === this.devices.length) { + for (const device of devices) { + if (!this.devices.includes(device)) { + return false; + } + } + return true; + } else { + return false; + } + } + static getEventType(event) { + return event.getType(); + } + + /** + * Extract the transaction id used by a given key verification event, if any + * @param event - the event + * @returns the transaction id + */ + static getTransactionId(event) { + const content = event.getContent(); + return content && content.transaction_id; + } + + /** + * Checks whether the given event type should be allowed to initiate a new VerificationRequest over this channel + * @param type - the event type to check + * @returns boolean flag + */ + static canCreateRequest(type) { + return type === _VerificationRequest.REQUEST_TYPE || type === _VerificationRequest.START_TYPE; + } + canCreateRequest(type) { + return ToDeviceChannel.canCreateRequest(type); + } + + /** + * Checks whether this event is a well-formed key verification event. + * This only does checks that don't rely on the current state of a potentially already channel + * so we can prevent channels being created by invalid events. + * `handleEvent` can do more checks and choose to ignore invalid events. + * @param event - the event to validate + * @param client - the client to get the current user and device id from + * @returns whether the event is valid and should be passed to handleEvent + */ + static validateEvent(event, client) { + if (event.isCancelled()) { + _logger.logger.warn("Ignoring flagged verification request from " + event.getSender()); + return false; + } + const content = event.getContent(); + if (!content) { + _logger.logger.warn("ToDeviceChannel.validateEvent: invalid: no content"); + return false; + } + if (!content.transaction_id) { + _logger.logger.warn("ToDeviceChannel.validateEvent: invalid: no transaction_id"); + return false; + } + const type = event.getType(); + if (type === _VerificationRequest.REQUEST_TYPE) { + if (!Number.isFinite(content.timestamp)) { + _logger.logger.warn("ToDeviceChannel.validateEvent: invalid: no timestamp"); + return false; + } + if (event.getSender() === client.getUserId() && content.from_device == client.getDeviceId()) { + // ignore requests from ourselves, because it doesn't make sense for a + // device to verify itself + _logger.logger.warn("ToDeviceChannel.validateEvent: invalid: from own device"); + return false; + } + } + return _VerificationRequest.VerificationRequest.validateEvent(type, event, client); + } + + /** + * @param event - the event to get the timestamp of + * @returns the timestamp when the event was sent + */ + getTimestamp(event) { + const content = event.getContent(); + return content && content.timestamp; + } + + /** + * Changes the state of the channel, request, and verifier in response to a key verification event. + * @param event - to handle + * @param request - the request to forward handling to + * @param isLiveEvent - whether this is an even received through sync or not + * @returns a promise that resolves when any requests as an answer to the passed-in event are sent. + */ + async handleEvent(event, request, isLiveEvent = false) { + const type = event.getType(); + const content = event.getContent(); + if (type === _VerificationRequest.REQUEST_TYPE || type === _VerificationRequest.READY_TYPE || type === _VerificationRequest.START_TYPE) { + if (!this.transactionId) { + this.transactionId = content.transaction_id; + } + const deviceId = content.from_device; + // adopt deviceId if not set before and valid + if (!this.deviceId && this.devices.includes(deviceId)) { + this.deviceId = deviceId; + } + // if no device id or different from adopted one, cancel with sender + if (!this.deviceId || this.deviceId !== deviceId) { + // also check that message came from the device we sent the request to earlier on + // and do send a cancel message to that device + // (but don't cancel the request for the device we should be talking to) + const cancelContent = this.completeContent(_VerificationRequest.CANCEL_TYPE, (0, _Error.errorFromEvent)((0, _Error.newUnexpectedMessageError)())); + return this.sendToDevices(_VerificationRequest.CANCEL_TYPE, cancelContent, [deviceId]); + } + } + const wasStarted = request.phase === _VerificationRequest.PHASE_STARTED || request.phase === _VerificationRequest.PHASE_READY; + await request.handleEvent(event.getType(), event, isLiveEvent, false, false); + const isStarted = request.phase === _VerificationRequest.PHASE_STARTED || request.phase === _VerificationRequest.PHASE_READY; + const isAcceptingEvent = type === _VerificationRequest.START_TYPE || type === _VerificationRequest.READY_TYPE; + // the request has picked a ready or start event, tell the other devices about it + if (isAcceptingEvent && !wasStarted && isStarted && this.deviceId) { + const nonChosenDevices = this.devices.filter(d => d !== this.deviceId && d !== this.client.getDeviceId()); + if (nonChosenDevices.length) { + const message = this.completeContent(_VerificationRequest.CANCEL_TYPE, { + code: "m.accepted", + reason: "Verification request accepted by another device" + }); + await this.sendToDevices(_VerificationRequest.CANCEL_TYPE, message, nonChosenDevices); + } + } + } + + /** + * See {@link InRoomChannel#completedContentFromEvent} for why this is needed. + * @param event - the received event + * @returns the content object + */ + completedContentFromEvent(event) { + return event.getContent(); + } + + /** + * Add all the fields to content needed for sending it over this channel. + * This is public so verification methods (SAS uses this) can get the exact + * content that will be sent independent of the used channel, + * as they need to calculate the hash of it. + * @param type - the event type + * @param content - the (incomplete) content + * @returns the complete content, as it will be sent. + */ + completeContent(type, content) { + // make a copy + content = Object.assign({}, content); + if (this.transactionId) { + content.transaction_id = this.transactionId; + } + if (type === _VerificationRequest.REQUEST_TYPE || type === _VerificationRequest.READY_TYPE || type === _VerificationRequest.START_TYPE) { + content.from_device = this.client.getDeviceId(); + } + if (type === _VerificationRequest.REQUEST_TYPE) { + content.timestamp = Date.now(); + } + return content; + } + + /** + * Send an event over the channel with the content not having gone through `completeContent`. + * @param type - the event type + * @param uncompletedContent - the (incomplete) content + * @returns the promise of the request + */ + send(type, uncompletedContent = {}) { + // create transaction id when sending request + if ((type === _VerificationRequest.REQUEST_TYPE || type === _VerificationRequest.START_TYPE) && !this.transactionId) { + this.transactionId = ToDeviceChannel.makeTransactionId(); + } + const content = this.completeContent(type, uncompletedContent); + return this.sendCompleted(type, content); + } + + /** + * Send an event over the channel with the content having gone through `completeContent` already. + * @param type - the event type + * @returns the promise of the request + */ + async sendCompleted(type, content) { + let result; + if (type === _VerificationRequest.REQUEST_TYPE || type === _VerificationRequest.CANCEL_TYPE && !this.deviceId) { + result = await this.sendToDevices(type, content, this.devices); + } else { + result = await this.sendToDevices(type, content, [this.deviceId]); + } + // the VerificationRequest state machine requires remote echos of the event + // the client sends itself, so we fake this for to_device messages + const remoteEchoEvent = new _event.MatrixEvent({ + sender: this.client.getUserId(), + content, + type + }); + await this.request.handleEvent(type, remoteEchoEvent, /*isLiveEvent=*/true, /*isRemoteEcho=*/true, /*isSentByUs=*/true); + return result; + } + async sendToDevices(type, content, devices) { + if (devices.length) { + const deviceMessages = new Map(); + for (const deviceId of devices) { + deviceMessages.set(deviceId, content); + } + await this.client.sendToDevice(type, new Map([[this.userId, deviceMessages]])); + } + } + + /** + * Allow Crypto module to create and know the transaction id before the .start event gets sent. + * @returns the transaction id + */ + static makeTransactionId() { + return (0, _randomstring.randomString)(32); + } +} +exports.ToDeviceChannel = ToDeviceChannel; +class ToDeviceRequests { + constructor() { + _defineProperty(this, "requestsByUserId", new Map()); + } + getRequest(event) { + return this.getRequestBySenderAndTxnId(event.getSender(), ToDeviceChannel.getTransactionId(event)); + } + getRequestByChannel(channel) { + return this.getRequestBySenderAndTxnId(channel.userId, channel.transactionId); + } + getRequestBySenderAndTxnId(sender, txnId) { + const requestsByTxnId = this.requestsByUserId.get(sender); + if (requestsByTxnId) { + return requestsByTxnId.get(txnId); + } + } + setRequest(event, request) { + this.setRequestBySenderAndTxnId(event.getSender(), ToDeviceChannel.getTransactionId(event), request); + } + setRequestByChannel(channel, request) { + this.setRequestBySenderAndTxnId(channel.userId, channel.transactionId, request); + } + setRequestBySenderAndTxnId(sender, txnId, request) { + let requestsByTxnId = this.requestsByUserId.get(sender); + if (!requestsByTxnId) { + requestsByTxnId = new Map(); + this.requestsByUserId.set(sender, requestsByTxnId); + } + requestsByTxnId.set(txnId, request); + } + removeRequest(event) { + const userId = event.getSender(); + const requestsByTxnId = this.requestsByUserId.get(userId); + if (requestsByTxnId) { + requestsByTxnId.delete(ToDeviceChannel.getTransactionId(event)); + if (requestsByTxnId.size === 0) { + this.requestsByUserId.delete(userId); + } + } + } + findRequestInProgress(userId, devices) { + const requestsByTxnId = this.requestsByUserId.get(userId); + if (requestsByTxnId) { + for (const request of requestsByTxnId.values()) { + if (request.pending && request.channel.isToDevices(devices)) { + return request; + } + } + } + } + getRequestsInProgress(userId) { + const requestsByTxnId = this.requestsByUserId.get(userId); + if (requestsByTxnId) { + return Array.from(requestsByTxnId.values()).filter(r => r.pending); + } + return []; + } +} +exports.ToDeviceRequests = ToDeviceRequests;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/VerificationRequest.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/VerificationRequest.js new file mode 100644 index 0000000000..d7987f367f --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/VerificationRequest.js @@ -0,0 +1,870 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.VerificationRequestEvent = exports.VerificationRequest = exports.START_TYPE = exports.REQUEST_TYPE = exports.READY_TYPE = exports.Phase = exports.PHASE_UNSENT = exports.PHASE_STARTED = exports.PHASE_REQUESTED = exports.PHASE_READY = exports.PHASE_DONE = exports.PHASE_CANCELLED = exports.EVENT_PREFIX = exports.DONE_TYPE = exports.CANCEL_TYPE = void 0; +var _logger = require("../../../logger"); +var _Error = require("../Error"); +var _QRCode = require("../QRCode"); +var _event = require("../../../@types/event"); +var _typedEventEmitter = require("../../../models/typed-event-emitter"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2018 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + 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. + */ +// How long after the event's timestamp that the request times out +const TIMEOUT_FROM_EVENT_TS = 10 * 60 * 1000; // 10 minutes + +// How long after we receive the event that the request times out +const TIMEOUT_FROM_EVENT_RECEIPT = 2 * 60 * 1000; // 2 minutes + +// to avoid almost expired verification notifications +// from showing a notification and almost immediately +// disappearing, also ignore verification requests that +// are this amount of time away from expiring. +const VERIFICATION_REQUEST_MARGIN = 3 * 1000; // 3 seconds + +const EVENT_PREFIX = "m.key.verification."; +exports.EVENT_PREFIX = EVENT_PREFIX; +const REQUEST_TYPE = EVENT_PREFIX + "request"; +exports.REQUEST_TYPE = REQUEST_TYPE; +const START_TYPE = EVENT_PREFIX + "start"; +exports.START_TYPE = START_TYPE; +const CANCEL_TYPE = EVENT_PREFIX + "cancel"; +exports.CANCEL_TYPE = CANCEL_TYPE; +const DONE_TYPE = EVENT_PREFIX + "done"; +exports.DONE_TYPE = DONE_TYPE; +const READY_TYPE = EVENT_PREFIX + "ready"; +exports.READY_TYPE = READY_TYPE; +let Phase = /*#__PURE__*/function (Phase) { + Phase[Phase["Unsent"] = 1] = "Unsent"; + Phase[Phase["Requested"] = 2] = "Requested"; + Phase[Phase["Ready"] = 3] = "Ready"; + Phase[Phase["Started"] = 4] = "Started"; + Phase[Phase["Cancelled"] = 5] = "Cancelled"; + Phase[Phase["Done"] = 6] = "Done"; + return Phase; +}({}); // Legacy export fields +exports.Phase = Phase; +const PHASE_UNSENT = Phase.Unsent; +exports.PHASE_UNSENT = PHASE_UNSENT; +const PHASE_REQUESTED = Phase.Requested; +exports.PHASE_REQUESTED = PHASE_REQUESTED; +const PHASE_READY = Phase.Ready; +exports.PHASE_READY = PHASE_READY; +const PHASE_STARTED = Phase.Started; +exports.PHASE_STARTED = PHASE_STARTED; +const PHASE_CANCELLED = Phase.Cancelled; +exports.PHASE_CANCELLED = PHASE_CANCELLED; +const PHASE_DONE = Phase.Done; +exports.PHASE_DONE = PHASE_DONE; +let VerificationRequestEvent = /*#__PURE__*/function (VerificationRequestEvent) { + VerificationRequestEvent["Change"] = "change"; + return VerificationRequestEvent; +}({}); +exports.VerificationRequestEvent = VerificationRequestEvent; +/** + * State machine for verification requests. + * Things that differ based on what channel is used to + * send and receive verification events are put in `InRoomChannel` or `ToDeviceChannel`. + */ +class VerificationRequest extends _typedEventEmitter.TypedEventEmitter { + constructor(channel, verificationMethods, client) { + super(); + this.channel = channel; + this.verificationMethods = verificationMethods; + this.client = client; + _defineProperty(this, "eventsByUs", new Map()); + _defineProperty(this, "eventsByThem", new Map()); + _defineProperty(this, "_observeOnly", false); + _defineProperty(this, "timeoutTimer", null); + _defineProperty(this, "_accepting", false); + _defineProperty(this, "_declining", false); + _defineProperty(this, "verifierHasFinished", false); + _defineProperty(this, "_cancelled", false); + _defineProperty(this, "_chosenMethod", null); + // we keep a copy of the QR Code data (including other user master key) around + // for QR reciprocate verification, to protect against + // cross-signing identity reset between the .ready and .start event + // and signing the wrong key after .start + _defineProperty(this, "_qrCodeData", null); + // The timestamp when we received the request event from the other side + _defineProperty(this, "requestReceivedAt", null); + _defineProperty(this, "commonMethods", []); + _defineProperty(this, "_phase", void 0); + _defineProperty(this, "_cancellingUserId", void 0); + // Used in tests only + _defineProperty(this, "_verifier", void 0); + _defineProperty(this, "cancelOnTimeout", async () => { + try { + if (this.initiatedByMe) { + await this.cancel({ + reason: "Other party didn't accept in time", + code: "m.timeout" + }); + } else { + await this.cancel({ + reason: "User didn't accept in time", + code: "m.timeout" + }); + } + } catch (err) { + _logger.logger.error("Error while cancelling verification request", err); + } + }); + this.channel.request = this; + this.setPhase(PHASE_UNSENT, false); + } + + /** + * Stateless validation logic not specific to the channel. + * Invoked by the same static method in either channel. + * @param type - the "symbolic" event type, as returned by the `getEventType` function on the channel. + * @param event - the event to validate. Don't call getType() on it but use the `type` parameter instead. + * @param client - the client to get the current user and device id from + * @returns whether the event is valid and should be passed to handleEvent + */ + static validateEvent(type, event, client) { + const content = event.getContent(); + if (!type || !type.startsWith(EVENT_PREFIX)) { + return false; + } + + // from here on we're fairly sure that this is supposed to be + // part of a verification request, so be noisy when rejecting something + if (!content) { + _logger.logger.log("VerificationRequest: validateEvent: no content"); + return false; + } + if (type === REQUEST_TYPE || type === READY_TYPE) { + if (!Array.isArray(content.methods)) { + _logger.logger.log("VerificationRequest: validateEvent: " + "fail because methods"); + return false; + } + } + if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) { + if (typeof content.from_device !== "string" || content.from_device.length === 0) { + _logger.logger.log("VerificationRequest: validateEvent: " + "fail because from_device"); + return false; + } + } + return true; + } + get invalid() { + return this.phase === PHASE_UNSENT; + } + + /** returns whether the phase is PHASE_REQUESTED */ + get requested() { + return this.phase === PHASE_REQUESTED; + } + + /** returns whether the phase is PHASE_CANCELLED */ + get cancelled() { + return this.phase === PHASE_CANCELLED; + } + + /** returns whether the phase is PHASE_READY */ + get ready() { + return this.phase === PHASE_READY; + } + + /** returns whether the phase is PHASE_STARTED */ + get started() { + return this.phase === PHASE_STARTED; + } + + /** returns whether the phase is PHASE_DONE */ + get done() { + return this.phase === PHASE_DONE; + } + + /** once the phase is PHASE_STARTED (and !initiatedByMe) or PHASE_READY: common methods supported by both sides */ + get methods() { + return this.commonMethods; + } + + /** the method picked in the .start event */ + get chosenMethod() { + return this._chosenMethod; + } + calculateEventTimeout(event) { + let effectiveExpiresAt = this.channel.getTimestamp(event) + TIMEOUT_FROM_EVENT_TS; + if (this.requestReceivedAt && !this.initiatedByMe && this.phase <= PHASE_REQUESTED) { + const expiresAtByReceipt = this.requestReceivedAt + TIMEOUT_FROM_EVENT_RECEIPT; + effectiveExpiresAt = Math.min(effectiveExpiresAt, expiresAtByReceipt); + } + return Math.max(0, effectiveExpiresAt - Date.now()); + } + + /** The current remaining amount of ms before the request should be automatically cancelled */ + get timeout() { + const requestEvent = this.getEventByEither(REQUEST_TYPE); + if (requestEvent) { + return this.calculateEventTimeout(requestEvent); + } + return 0; + } + + /** + * The key verification request event. + * @returns The request event, or falsey if not found. + */ + get requestEvent() { + return this.getEventByEither(REQUEST_TYPE); + } + + /** current phase of the request. Some properties might only be defined in a current phase. */ + get phase() { + return this._phase; + } + + /** The verifier to do the actual verification, once the method has been established. Only defined when the `phase` is PHASE_STARTED. */ + get verifier() { + return this._verifier; + } + get canAccept() { + return this.phase < PHASE_READY && !this._accepting && !this._declining; + } + get accepting() { + return this._accepting; + } + get declining() { + return this._declining; + } + + /** whether this request has sent it's initial event and needs more events to complete */ + get pending() { + return !this.observeOnly && this._phase !== PHASE_DONE && this._phase !== PHASE_CANCELLED; + } + + /** Only set after a .ready if the other party can scan a QR code */ + get qrCodeData() { + return this._qrCodeData; + } + + /** Checks whether the other party supports a given verification method. + * This is useful when setting up the QR code UI, as it is somewhat asymmetrical: + * if the other party supports SCAN_QR, we should show a QR code in the UI, and vice versa. + * For methods that need to be supported by both ends, use the `methods` property. + * @param method - the method to check + * @param force - to check even if the phase is not ready or started yet, internal usage + * @returns whether or not the other party said the supported the method */ + otherPartySupportsMethod(method, force = false) { + if (!force && !this.ready && !this.started) { + return false; + } + const theirMethodEvent = this.eventsByThem.get(REQUEST_TYPE) || this.eventsByThem.get(READY_TYPE); + if (!theirMethodEvent) { + // if we started straight away with .start event, + // we are assuming that the other side will support the + // chosen method, so return true for that. + if (this.started && this.initiatedByMe) { + const myStartEvent = this.eventsByUs.get(START_TYPE); + const content = myStartEvent && myStartEvent.getContent(); + const myStartMethod = content && content.method; + return method == myStartMethod; + } + return false; + } + const content = theirMethodEvent.getContent(); + if (!content) { + return false; + } + const { + methods + } = content; + if (!Array.isArray(methods)) { + return false; + } + return methods.includes(method); + } + + /** Whether this request was initiated by the syncing user. + * For InRoomChannel, this is who sent the .request event. + * For ToDeviceChannel, this is who sent the .start event + */ + get initiatedByMe() { + // event created by us but no remote echo has been received yet + const noEventsYet = this.eventsByUs.size + this.eventsByThem.size === 0; + if (this._phase === PHASE_UNSENT && noEventsYet) { + return true; + } + const hasMyRequest = this.eventsByUs.has(REQUEST_TYPE); + const hasTheirRequest = this.eventsByThem.has(REQUEST_TYPE); + if (hasMyRequest && !hasTheirRequest) { + return true; + } + if (!hasMyRequest && hasTheirRequest) { + return false; + } + const hasMyStart = this.eventsByUs.has(START_TYPE); + const hasTheirStart = this.eventsByThem.has(START_TYPE); + if (hasMyStart && !hasTheirStart) { + return true; + } + return false; + } + + /** The id of the user that initiated the request */ + get requestingUserId() { + if (this.initiatedByMe) { + return this.client.getUserId(); + } else { + return this.otherUserId; + } + } + + /** The id of the user that (will) receive(d) the request */ + get receivingUserId() { + if (this.initiatedByMe) { + return this.otherUserId; + } else { + return this.client.getUserId(); + } + } + + /** The user id of the other party in this request */ + get otherUserId() { + return this.channel.userId; + } + get isSelfVerification() { + return this.client.getUserId() === this.otherUserId; + } + + /** + * The id of the user that cancelled the request, + * only defined when phase is PHASE_CANCELLED + */ + get cancellingUserId() { + const myCancel = this.eventsByUs.get(CANCEL_TYPE); + const theirCancel = this.eventsByThem.get(CANCEL_TYPE); + if (myCancel && (!theirCancel || myCancel.getId() < theirCancel.getId())) { + return myCancel.getSender(); + } + if (theirCancel) { + return theirCancel.getSender(); + } + return undefined; + } + + /** + * The cancellation code e.g m.user which is responsible for cancelling this verification + */ + get cancellationCode() { + const ev = this.getEventByEither(CANCEL_TYPE); + return ev ? ev.getContent().code : null; + } + get observeOnly() { + return this._observeOnly; + } + + /** + * Gets which device the verification should be started with + * given the events sent so far in the verification. This is the + * same algorithm used to determine which device to send the + * verification to when no specific device is specified. + * @returns The device information + */ + get targetDevice() { + const theirFirstEvent = this.eventsByThem.get(REQUEST_TYPE) || this.eventsByThem.get(READY_TYPE) || this.eventsByThem.get(START_TYPE); + const theirFirstContent = theirFirstEvent?.getContent(); + const fromDevice = theirFirstContent?.from_device; + return { + userId: this.otherUserId, + deviceId: fromDevice + }; + } + + /* Start the key verification, creating a verifier and sending a .start event. + * If no previous events have been sent, pass in `targetDevice` to set who to direct this request to. + * @param method - the name of the verification method to use. + * @param targetDevice.userId the id of the user to direct this request to + * @param targetDevice.deviceId the id of the device to direct this request to + * @returns the verifier of the given method + */ + beginKeyVerification(method, targetDevice = null) { + // need to allow also when unsent in case of to_device + if (!this.observeOnly && !this._verifier) { + const validStartPhase = this.phase === PHASE_REQUESTED || this.phase === PHASE_READY || this.phase === PHASE_UNSENT && this.channel.canCreateRequest(START_TYPE); + if (validStartPhase) { + // when called on a request that was initiated with .request event + // check the method is supported by both sides + if (this.commonMethods.length && !this.commonMethods.includes(method)) { + throw (0, _Error.newUnknownMethodError)(); + } + this._verifier = this.createVerifier(method, null, targetDevice); + if (!this._verifier) { + throw (0, _Error.newUnknownMethodError)(); + } + this._chosenMethod = method; + } + } + return this._verifier; + } + + /** + * sends the initial .request event. + * @returns resolves when the event has been sent. + */ + async sendRequest() { + if (!this.observeOnly && this._phase === PHASE_UNSENT) { + const methods = [...this.verificationMethods.keys()]; + await this.channel.send(REQUEST_TYPE, { + methods + }); + } + } + + /** + * Cancels the request, sending a cancellation to the other party + * @param reason - the error reason to send the cancellation with + * @param code - the error code to send the cancellation with + * @returns resolves when the event has been sent. + */ + async cancel({ + reason = "User declined", + code = "m.user" + } = {}) { + if (!this.observeOnly && this._phase !== PHASE_CANCELLED) { + this._declining = true; + this.emit(VerificationRequestEvent.Change); + if (this._verifier) { + return this._verifier.cancel((0, _Error.errorFactory)(code, reason)()); + } else { + this._cancellingUserId = this.client.getUserId(); + await this.channel.send(CANCEL_TYPE, { + code, + reason + }); + } + } + } + + /** + * Accepts the request, sending a .ready event to the other party + * @returns resolves when the event has been sent. + */ + async accept() { + if (!this.observeOnly && this.phase === PHASE_REQUESTED && !this.initiatedByMe) { + const methods = [...this.verificationMethods.keys()]; + this._accepting = true; + this.emit(VerificationRequestEvent.Change); + await this.channel.send(READY_TYPE, { + methods + }); + } + } + + /** + * Can be used to listen for state changes until the callback returns true. + * @param fn - callback to evaluate whether the request is in the desired state. + * Takes the request as an argument. + * @returns that resolves once the callback returns true + * @throws Error when the request is cancelled + */ + waitFor(fn) { + return new Promise((resolve, reject) => { + const check = () => { + let handled = false; + if (fn(this)) { + resolve(this); + handled = true; + } else if (this.cancelled) { + reject(new Error("cancelled")); + handled = true; + } + if (handled) { + this.off(VerificationRequestEvent.Change, check); + } + return handled; + }; + if (!check()) { + this.on(VerificationRequestEvent.Change, check); + } + }); + } + setPhase(phase, notify = true) { + this._phase = phase; + if (notify) { + this.emit(VerificationRequestEvent.Change); + } + } + getEventByEither(type) { + return this.eventsByThem.get(type) || this.eventsByUs.get(type); + } + getEventBy(type, byThem = false) { + if (byThem) { + return this.eventsByThem.get(type); + } else { + return this.eventsByUs.get(type); + } + } + calculatePhaseTransitions() { + const transitions = [{ + phase: PHASE_UNSENT + }]; + const phase = () => transitions[transitions.length - 1].phase; + + // always pass by .request first to be sure channel.userId has been set + const hasRequestByThem = this.eventsByThem.has(REQUEST_TYPE); + const requestEvent = this.getEventBy(REQUEST_TYPE, hasRequestByThem); + if (requestEvent) { + transitions.push({ + phase: PHASE_REQUESTED, + event: requestEvent + }); + } + const readyEvent = requestEvent && this.getEventBy(READY_TYPE, !hasRequestByThem); + if (readyEvent && phase() === PHASE_REQUESTED) { + transitions.push({ + phase: PHASE_READY, + event: readyEvent + }); + } + let startEvent; + if (readyEvent || !requestEvent) { + const theirStartEvent = this.eventsByThem.get(START_TYPE); + const ourStartEvent = this.eventsByUs.get(START_TYPE); + // any party can send .start after a .ready or unsent + if (theirStartEvent && ourStartEvent) { + startEvent = theirStartEvent.getSender() < ourStartEvent.getSender() ? theirStartEvent : ourStartEvent; + } else { + startEvent = theirStartEvent ? theirStartEvent : ourStartEvent; + } + } else { + startEvent = this.getEventBy(START_TYPE, !hasRequestByThem); + } + if (startEvent) { + const fromRequestPhase = phase() === PHASE_REQUESTED && requestEvent?.getSender() !== startEvent.getSender(); + const fromUnsentPhase = phase() === PHASE_UNSENT && this.channel.canCreateRequest(START_TYPE); + if (fromRequestPhase || phase() === PHASE_READY || fromUnsentPhase) { + transitions.push({ + phase: PHASE_STARTED, + event: startEvent + }); + } + } + const ourDoneEvent = this.eventsByUs.get(DONE_TYPE); + if (this.verifierHasFinished || ourDoneEvent && phase() === PHASE_STARTED) { + transitions.push({ + phase: PHASE_DONE + }); + } + const cancelEvent = this.getEventByEither(CANCEL_TYPE); + if ((this._cancelled || cancelEvent) && phase() !== PHASE_DONE) { + transitions.push({ + phase: PHASE_CANCELLED, + event: cancelEvent + }); + return transitions; + } + return transitions; + } + transitionToPhase(transition) { + const { + phase, + event + } = transition; + // get common methods + if (phase === PHASE_REQUESTED || phase === PHASE_READY) { + if (!this.wasSentByOwnDevice(event)) { + const content = event.getContent(); + this.commonMethods = content.methods.filter(m => this.verificationMethods.has(m)); + } + } + // detect if we're not a party in the request, and we should just observe + if (!this.observeOnly) { + // if requested or accepted by one of my other devices + if (phase === PHASE_REQUESTED || phase === PHASE_STARTED || phase === PHASE_READY) { + if (this.channel.receiveStartFromOtherDevices && this.wasSentByOwnUser(event) && !this.wasSentByOwnDevice(event)) { + this._observeOnly = true; + } + } + } + // create verifier + if (phase === PHASE_STARTED) { + const { + method + } = event.getContent(); + if (!this._verifier && !this.observeOnly) { + this._verifier = this.createVerifier(method, event); + if (!this._verifier) { + this.cancel({ + code: "m.unknown_method", + reason: `Unknown method: ${method}` + }); + } else { + this._chosenMethod = method; + } + } + } + } + applyPhaseTransitions() { + const transitions = this.calculatePhaseTransitions(); + const existingIdx = transitions.findIndex(t => t.phase === this.phase); + // trim off phases we already went through, if any + const newTransitions = transitions.slice(existingIdx + 1); + // transition to all new phases + for (const transition of newTransitions) { + this.transitionToPhase(transition); + } + return newTransitions; + } + isWinningStartRace(newEvent) { + if (newEvent.getType() !== START_TYPE) { + return false; + } + const oldEvent = this._verifier.startEvent; + let oldRaceIdentifier; + if (this.isSelfVerification) { + // if the verifier does not have a startEvent, + // it is because it's still sending and we are on the initator side + // we know we are sending a .start event because we already + // have a verifier (checked in calling method) + if (oldEvent) { + const oldContent = oldEvent.getContent(); + oldRaceIdentifier = oldContent && oldContent.from_device; + } else { + oldRaceIdentifier = this.client.getDeviceId(); + } + } else { + if (oldEvent) { + oldRaceIdentifier = oldEvent.getSender(); + } else { + oldRaceIdentifier = this.client.getUserId(); + } + } + let newRaceIdentifier; + if (this.isSelfVerification) { + const newContent = newEvent.getContent(); + newRaceIdentifier = newContent && newContent.from_device; + } else { + newRaceIdentifier = newEvent.getSender(); + } + return newRaceIdentifier < oldRaceIdentifier; + } + hasEventId(eventId) { + for (const event of this.eventsByUs.values()) { + if (event.getId() === eventId) { + return true; + } + } + for (const event of this.eventsByThem.values()) { + if (event.getId() === eventId) { + return true; + } + } + return false; + } + + /** + * Changes the state of the request and verifier in response to a key verification event. + * @param type - the "symbolic" event type, as returned by the `getEventType` function on the channel. + * @param event - the event to handle. Don't call getType() on it but use the `type` parameter instead. + * @param isLiveEvent - whether this is an even received through sync or not + * @param isRemoteEcho - whether this is the remote echo of an event sent by the same device + * @param isSentByUs - whether this event is sent by a party that can accept and/or observe the request like one of our peers. + * For InRoomChannel this means any device for the syncing user. For ToDeviceChannel, just the syncing device. + * @returns a promise that resolves when any requests as an answer to the passed-in event are sent. + */ + async handleEvent(type, event, isLiveEvent, isRemoteEcho, isSentByUs) { + // if reached phase cancelled or done, ignore anything else that comes + if (this.done || this.cancelled) { + return; + } + const wasObserveOnly = this._observeOnly; + this.adjustObserveOnly(event, isLiveEvent); + if (!this.observeOnly && !isRemoteEcho) { + if (await this.cancelOnError(type, event)) { + return; + } + } + + // This assumes verification won't need to send an event with + // the same type for the same party twice. + // This is true for QR and SAS verification, and was + // added here to prevent verification getting cancelled + // when the server duplicates an event (https://github.com/matrix-org/synapse/issues/3365) + const isDuplicateEvent = isSentByUs ? this.eventsByUs.has(type) : this.eventsByThem.has(type); + if (isDuplicateEvent) { + return; + } + const oldPhase = this.phase; + this.addEvent(type, event, isSentByUs); + + // this will create if needed the verifier so needs to happen before calling it + const newTransitions = this.applyPhaseTransitions(); + try { + // only pass events from the other side to the verifier, + // no remote echos of our own events + if (this._verifier && !this.observeOnly) { + const newEventWinsRace = this.isWinningStartRace(event); + if (this._verifier.canSwitchStartEvent(event) && newEventWinsRace) { + this._verifier.switchStartEvent(event); + } else if (!isRemoteEcho) { + if (type === CANCEL_TYPE || this._verifier.events?.includes(type)) { + this._verifier.handleEvent(event); + } + } + } + if (newTransitions.length) { + // create QRCodeData if the other side can scan + // important this happens before emitting a phase change, + // so listeners can rely on it being there already + // We only do this for live events because it is important that + // we sign the keys that were in the QR code, and not the keys + // we happen to have at some later point in time. + if (isLiveEvent && newTransitions.some(t => t.phase === PHASE_READY)) { + const shouldGenerateQrCode = this.otherPartySupportsMethod(_QRCode.SCAN_QR_CODE_METHOD, true); + if (shouldGenerateQrCode) { + this._qrCodeData = await _QRCode.QRCodeData.create(this, this.client); + } + } + const lastTransition = newTransitions[newTransitions.length - 1]; + const { + phase + } = lastTransition; + this.setupTimeout(phase); + // set phase as last thing as this emits the "change" event + this.setPhase(phase); + } else if (this._observeOnly !== wasObserveOnly) { + this.emit(VerificationRequestEvent.Change); + } + } finally { + // log events we processed so we can see from rageshakes what events were added to a request + _logger.logger.log(`Verification request ${this.channel.transactionId}: ` + `${type} event with id:${event.getId()}, ` + `content:${JSON.stringify(event.getContent())} ` + `deviceId:${this.channel.deviceId}, ` + `sender:${event.getSender()}, isSentByUs:${isSentByUs}, ` + `isLiveEvent:${isLiveEvent}, isRemoteEcho:${isRemoteEcho}, ` + `phase:${oldPhase}=>${this.phase}, ` + `observeOnly:${wasObserveOnly}=>${this._observeOnly}`); + } + } + setupTimeout(phase) { + const shouldTimeout = !this.timeoutTimer && !this.observeOnly && phase === PHASE_REQUESTED; + if (shouldTimeout) { + this.timeoutTimer = setTimeout(this.cancelOnTimeout, this.timeout); + } + if (this.timeoutTimer) { + const shouldClear = phase === PHASE_STARTED || phase === PHASE_READY || phase === PHASE_DONE || phase === PHASE_CANCELLED; + if (shouldClear) { + clearTimeout(this.timeoutTimer); + this.timeoutTimer = null; + } + } + } + async cancelOnError(type, event) { + if (type === START_TYPE) { + const method = event.getContent().method; + if (!this.verificationMethods.has(method)) { + await this.cancel((0, _Error.errorFromEvent)((0, _Error.newUnknownMethodError)())); + return true; + } + } + const isUnexpectedRequest = type === REQUEST_TYPE && this.phase !== PHASE_UNSENT; + const isUnexpectedReady = type === READY_TYPE && this.phase !== PHASE_REQUESTED && this.phase !== PHASE_STARTED; + // only if phase has passed from PHASE_UNSENT should we cancel, because events + // are allowed to come in in any order (at least with InRoomChannel). So we only know + // we're dealing with a valid request we should participate in once we've moved to PHASE_REQUESTED. + // Before that, we could be looking at somebody else's verification request and we just + // happen to be in the room + if (this.phase !== PHASE_UNSENT && (isUnexpectedRequest || isUnexpectedReady)) { + _logger.logger.warn(`Cancelling, unexpected ${type} verification ` + `event from ${event.getSender()}`); + const reason = `Unexpected ${type} event in phase ${this.phase}`; + await this.cancel((0, _Error.errorFromEvent)((0, _Error.newUnexpectedMessageError)({ + reason + }))); + return true; + } + return false; + } + adjustObserveOnly(event, isLiveEvent = false) { + // don't send out events for historical requests + if (!isLiveEvent) { + this._observeOnly = true; + } + if (this.calculateEventTimeout(event) < VERIFICATION_REQUEST_MARGIN) { + this._observeOnly = true; + } + } + addEvent(type, event, isSentByUs = false) { + if (isSentByUs) { + this.eventsByUs.set(type, event); + } else { + this.eventsByThem.set(type, event); + } + + // once we know the userId of the other party (from the .request event) + // see if any event by anyone else crept into this.eventsByThem + if (type === REQUEST_TYPE) { + for (const [type, event] of this.eventsByThem.entries()) { + if (event.getSender() !== this.otherUserId) { + this.eventsByThem.delete(type); + } + } + // also remember when we received the request event + this.requestReceivedAt = Date.now(); + } + } + createVerifier(method, startEvent = null, targetDevice = null) { + if (!targetDevice) { + targetDevice = this.targetDevice; + } + const { + userId, + deviceId + } = targetDevice; + const VerifierCtor = this.verificationMethods.get(method); + if (!VerifierCtor) { + _logger.logger.warn("could not find verifier constructor for method", method); + return; + } + return new VerifierCtor(this.channel, this.client, userId, deviceId, startEvent, this); + } + wasSentByOwnUser(event) { + return event?.getSender() === this.client.getUserId(); + } + + // only for .request, .ready or .start + wasSentByOwnDevice(event) { + if (!this.wasSentByOwnUser(event)) { + return false; + } + const content = event.getContent(); + if (!content || content.from_device !== this.client.getDeviceId()) { + return false; + } + return true; + } + onVerifierCancelled() { + this._cancelled = true; + // move to cancelled phase + const newTransitions = this.applyPhaseTransitions(); + if (newTransitions.length) { + this.setPhase(newTransitions[newTransitions.length - 1].phase); + } + } + onVerifierFinished() { + this.channel.send(_event.EventType.KeyVerificationDone, {}); + this.verifierHasFinished = true; + // move to .done phase + const newTransitions = this.applyPhaseTransitions(); + if (newTransitions.length) { + this.setPhase(newTransitions[newTransitions.length - 1].phase); + } + } + getEventFromOtherParty(type) { + return this.eventsByThem.get(type); + } +} +exports.VerificationRequest = VerificationRequest;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/embedded.js b/comm/chat/protocols/matrix/lib/matrix-sdk/embedded.js new file mode 100644 index 0000000000..3ec315820a --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/embedded.js @@ -0,0 +1,261 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.RoomWidgetClient = void 0; +var _matrixWidgetApi = require("matrix-widget-api"); +var _event = require("./models/event"); +var _event2 = require("./@types/event"); +var _logger = require("./logger"); +var _client = require("./client"); +var _sync = require("./sync"); +var _slidingSyncSdk = require("./sliding-sync-sdk"); +var _user = require("./models/user"); +var _utils = require("./utils"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2022 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/** + * A MatrixClient that routes its requests through the widget API instead of the + * real CS API. + * @experimental This class is considered unstable! + */ +class RoomWidgetClient extends _client.MatrixClient { + constructor(widgetApi, capabilities, roomId, opts) { + super(opts); + + // Request capabilities for the functionality this client needs to support + this.widgetApi = widgetApi; + this.capabilities = capabilities; + this.roomId = roomId; + _defineProperty(this, "room", void 0); + _defineProperty(this, "widgetApiReady", new Promise(resolve => this.widgetApi.once("ready", resolve))); + _defineProperty(this, "lifecycle", void 0); + _defineProperty(this, "syncState", null); + _defineProperty(this, "onEvent", async ev => { + ev.preventDefault(); + + // Verify the room ID matches, since it's possible for the client to + // send us events from other rooms if this widget is always on screen + if (ev.detail.data.room_id === this.roomId) { + const event = new _event.MatrixEvent(ev.detail.data); + await this.syncApi.injectRoomEvents(this.room, [], [event]); + this.emit(_client.ClientEvent.Event, event); + this.setSyncState(_sync.SyncState.Syncing); + _logger.logger.info(`Received event ${event.getId()} ${event.getType()} ${event.getStateKey()}`); + } else { + const { + event_id: eventId, + room_id: roomId + } = ev.detail.data; + _logger.logger.info(`Received event ${eventId} for a different room ${roomId}; discarding`); + } + await this.ack(ev); + }); + _defineProperty(this, "onToDevice", async ev => { + ev.preventDefault(); + const event = new _event.MatrixEvent({ + type: ev.detail.data.type, + sender: ev.detail.data.sender, + content: ev.detail.data.content + }); + // Mark the event as encrypted if it was, using fake contents and keys since those are unknown to us + if (ev.detail.data.encrypted) event.makeEncrypted(_event2.EventType.RoomMessageEncrypted, {}, "", ""); + this.emit(_client.ClientEvent.ToDeviceEvent, event); + this.setSyncState(_sync.SyncState.Syncing); + await this.ack(ev); + }); + if (capabilities.sendEvent?.length || capabilities.receiveEvent?.length || capabilities.sendMessage === true || Array.isArray(capabilities.sendMessage) && capabilities.sendMessage.length || capabilities.receiveMessage === true || Array.isArray(capabilities.receiveMessage) && capabilities.receiveMessage.length || capabilities.sendState?.length || capabilities.receiveState?.length) { + widgetApi.requestCapabilityForRoomTimeline(roomId); + } + capabilities.sendEvent?.forEach(eventType => widgetApi.requestCapabilityToSendEvent(eventType)); + capabilities.receiveEvent?.forEach(eventType => widgetApi.requestCapabilityToReceiveEvent(eventType)); + if (capabilities.sendMessage === true) { + widgetApi.requestCapabilityToSendMessage(); + } else if (Array.isArray(capabilities.sendMessage)) { + capabilities.sendMessage.forEach(msgType => widgetApi.requestCapabilityToSendMessage(msgType)); + } + if (capabilities.receiveMessage === true) { + widgetApi.requestCapabilityToReceiveMessage(); + } else if (Array.isArray(capabilities.receiveMessage)) { + capabilities.receiveMessage.forEach(msgType => widgetApi.requestCapabilityToReceiveMessage(msgType)); + } + capabilities.sendState?.forEach(({ + eventType, + stateKey + }) => widgetApi.requestCapabilityToSendState(eventType, stateKey)); + capabilities.receiveState?.forEach(({ + eventType, + stateKey + }) => widgetApi.requestCapabilityToReceiveState(eventType, stateKey)); + capabilities.sendToDevice?.forEach(eventType => widgetApi.requestCapabilityToSendToDevice(eventType)); + capabilities.receiveToDevice?.forEach(eventType => widgetApi.requestCapabilityToReceiveToDevice(eventType)); + if (capabilities.turnServers) { + widgetApi.requestCapability(_matrixWidgetApi.MatrixCapabilities.MSC3846TurnServers); + } + widgetApi.on(`action:${_matrixWidgetApi.WidgetApiToWidgetAction.SendEvent}`, this.onEvent); + widgetApi.on(`action:${_matrixWidgetApi.WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice); + + // Open communication with the host + widgetApi.start(); + } + async startClient(opts = {}) { + this.lifecycle = new AbortController(); + + // Create our own user object artificially (instead of waiting for sync) + // so it's always available, even if the user is not in any rooms etc. + const userId = this.getUserId(); + if (userId) { + this.store.storeUser(new _user.User(userId)); + } + + // Even though we have no access token and cannot sync, the sync class + // still has some valuable helper methods that we make use of, so we + // instantiate it anyways + if (opts.slidingSync) { + this.syncApi = new _slidingSyncSdk.SlidingSyncSdk(opts.slidingSync, this, opts, this.buildSyncApiOptions()); + } else { + this.syncApi = new _sync.SyncApi(this, opts, this.buildSyncApiOptions()); + } + this.room = this.syncApi.createRoom(this.roomId); + this.store.storeRoom(this.room); + await this.widgetApiReady; + + // Backfill the requested events + // We only get the most recent event for every type + state key combo, + // so it doesn't really matter what order we inject them in + await Promise.all(this.capabilities.receiveState?.map(async ({ + eventType, + stateKey + }) => { + const rawEvents = await this.widgetApi.readStateEvents(eventType, undefined, stateKey, [this.roomId]); + const events = rawEvents.map(rawEvent => new _event.MatrixEvent(rawEvent)); + await this.syncApi.injectRoomEvents(this.room, [], events); + events.forEach(event => { + this.emit(_client.ClientEvent.Event, event); + _logger.logger.info(`Backfilled event ${event.getId()} ${event.getType()} ${event.getStateKey()}`); + }); + }) ?? []); + this.setSyncState(_sync.SyncState.Syncing); + _logger.logger.info("Finished backfilling events"); + + // Watch for TURN servers, if requested + if (this.capabilities.turnServers) this.watchTurnServers(); + } + stopClient() { + this.widgetApi.off(`action:${_matrixWidgetApi.WidgetApiToWidgetAction.SendEvent}`, this.onEvent); + this.widgetApi.off(`action:${_matrixWidgetApi.WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice); + super.stopClient(); + this.lifecycle.abort(); // Signal to other async tasks that the client has stopped + } + + async joinRoom(roomIdOrAlias) { + if (roomIdOrAlias === this.roomId) return this.room; + throw new Error(`Unknown room: ${roomIdOrAlias}`); + } + async encryptAndSendEvent(room, event) { + let response; + try { + response = await this.widgetApi.sendRoomEvent(event.getType(), event.getContent(), room.roomId); + } catch (e) { + this.updatePendingEventStatus(room, event, _event.EventStatus.NOT_SENT); + throw e; + } + room.updatePendingEvent(event, _event.EventStatus.SENT, response.event_id); + return { + event_id: response.event_id + }; + } + async sendStateEvent(roomId, eventType, content, stateKey = "") { + return await this.widgetApi.sendStateEvent(eventType, stateKey, content, roomId); + } + async sendToDevice(eventType, contentMap) { + await this.widgetApi.sendToDevice(eventType, false, (0, _utils.recursiveMapToObject)(contentMap)); + return {}; + } + async queueToDevice({ + eventType, + batch + }) { + // map: user Id → device Id → payload + const contentMap = new _utils.MapWithDefault(() => new Map()); + for (const { + userId, + deviceId, + payload + } of batch) { + contentMap.getOrCreate(userId).set(deviceId, payload); + } + await this.widgetApi.sendToDevice(eventType, false, (0, _utils.recursiveMapToObject)(contentMap)); + } + async encryptAndSendToDevices(userDeviceInfoArr, payload) { + // map: user Id → device Id → payload + const contentMap = new _utils.MapWithDefault(() => new Map()); + for (const { + userId, + deviceInfo: { + deviceId + } + } of userDeviceInfoArr) { + contentMap.getOrCreate(userId).set(deviceId, payload); + } + await this.widgetApi.sendToDevice(payload.type, true, (0, _utils.recursiveMapToObject)(contentMap)); + } + + // Overridden since we get TURN servers automatically over the widget API, + // and this method would otherwise complain about missing an access token + async checkTurnServers() { + return this.turnServers.length > 0; + } + + // Overridden since we 'sync' manually without the sync API + getSyncState() { + return this.syncState; + } + setSyncState(state) { + const oldState = this.syncState; + this.syncState = state; + this.emit(_client.ClientEvent.Sync, state, oldState); + } + async ack(ev) { + await this.widgetApi.transport.reply(ev.detail, {}); + } + async watchTurnServers() { + const servers = this.widgetApi.getTurnServers(); + const onClientStopped = () => { + servers.return(undefined); + }; + this.lifecycle.signal.addEventListener("abort", onClientStopped); + try { + for await (const server of servers) { + this.turnServers = [{ + urls: server.uris, + username: server.username, + credential: server.password + }]; + this.emit(_client.ClientEvent.TurnServers, this.turnServers); + _logger.logger.log(`Received TURN server: ${server.uris}`); + } + } catch (e) { + _logger.logger.warn("Error watching TURN servers", e); + } finally { + this.lifecycle.signal.removeEventListener("abort", onClientStopped); + } + } +} +exports.RoomWidgetClient = RoomWidgetClient;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/errors.js b/comm/chat/protocols/matrix/lib/matrix-sdk/errors.js new file mode 100644 index 0000000000..5ddc30b49c --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/errors.js @@ -0,0 +1,62 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.KeySignatureUploadError = exports.InvalidStoreState = exports.InvalidStoreError = exports.InvalidCryptoStoreState = exports.InvalidCryptoStoreError = void 0; +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +let InvalidStoreState = /*#__PURE__*/function (InvalidStoreState) { + InvalidStoreState[InvalidStoreState["ToggledLazyLoading"] = 0] = "ToggledLazyLoading"; + return InvalidStoreState; +}({}); +exports.InvalidStoreState = InvalidStoreState; +class InvalidStoreError extends Error { + constructor(reason, value) { + const message = `Store is invalid because ${reason}, ` + `please stop the client, delete all data and start the client again`; + super(message); + this.reason = reason; + this.value = value; + this.name = "InvalidStoreError"; + } +} +exports.InvalidStoreError = InvalidStoreError; +_defineProperty(InvalidStoreError, "TOGGLED_LAZY_LOADING", InvalidStoreState.ToggledLazyLoading); +let InvalidCryptoStoreState = /*#__PURE__*/function (InvalidCryptoStoreState) { + InvalidCryptoStoreState["TooNew"] = "TOO_NEW"; + return InvalidCryptoStoreState; +}({}); +exports.InvalidCryptoStoreState = InvalidCryptoStoreState; +class InvalidCryptoStoreError extends Error { + constructor(reason) { + const message = `Crypto store is invalid because ${reason}, ` + `please stop the client, delete all data and start the client again`; + super(message); + this.reason = reason; + this.name = "InvalidCryptoStoreError"; + } +} +exports.InvalidCryptoStoreError = InvalidCryptoStoreError; +_defineProperty(InvalidCryptoStoreError, "TOO_NEW", InvalidCryptoStoreState.TooNew); +class KeySignatureUploadError extends Error { + constructor(message, value) { + super(message); + this.value = value; + } +} +exports.KeySignatureUploadError = KeySignatureUploadError;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/event-mapper.js b/comm/chat/protocols/matrix/lib/matrix-sdk/event-mapper.js new file mode 100644 index 0000000000..a8b30881ab --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/event-mapper.js @@ -0,0 +1,86 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.eventMapperFor = eventMapperFor; +var _event = require("./models/event"); +var _event2 = require("./@types/event"); +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +function eventMapperFor(client, options) { + let preventReEmit = Boolean(options.preventReEmit); + const decrypt = options.decrypt !== false; + function mapper(plainOldJsObject) { + if (options.toDevice) { + delete plainOldJsObject.room_id; + } + const room = client.getRoom(plainOldJsObject.room_id); + let event; + // If the event is already known to the room, let's re-use the model rather than duplicating. + // We avoid doing this to state events as they may be forward or backwards looking which tweaks behaviour. + if (room && plainOldJsObject.state_key === undefined) { + event = room.findEventById(plainOldJsObject.event_id); + } + if (!event || event.status) { + event = new _event.MatrixEvent(plainOldJsObject); + } else { + // merge the latest unsigned data from the server + event.setUnsigned(_objectSpread(_objectSpread({}, event.getUnsigned()), plainOldJsObject.unsigned)); + // prevent doubling up re-emitters + preventReEmit = true; + } + + // if there is a complete edit bundled alongside the event, perform the replacement. + // (prior to MSC3925, events were automatically replaced on the server-side. MSC3925 proposes that that doesn't + // happen automatically but the server does provide us with the whole content of the edit event.) + const bundledEdit = event.getServerAggregatedRelation(_event2.RelationType.Replace); + if (bundledEdit?.content) { + const replacement = mapper(bundledEdit); + // XXX: it's worth noting that the spec says we should only respect encrypted edits if, once decrypted, the + // replacement has a `m.new_content` property. The problem is that we haven't yet decrypted the replacement + // (it should be happening in the background), so we can't enforce this. Possibly we should for decryption + // to complete, but that sounds a bit racy. For now, we just assume it's ok. + event.makeReplaced(replacement); + } + const thread = room?.findThreadForEvent(event); + if (thread) { + event.setThread(thread); + } + + // TODO: once we get rid of the old libolm-backed crypto, we can restrict this to room events (rather than + // to-device events), because the rust implementation decrypts to-device messages at a higher level. + // Generally we probably want to use a different eventMapper implementation for to-device events because + if (event.isEncrypted()) { + if (!preventReEmit) { + client.reEmitter.reEmit(event, [_event.MatrixEventEvent.Decrypted]); + } + if (decrypt) { + client.decryptEventIfNeeded(event); + } + } + if (!preventReEmit) { + client.reEmitter.reEmit(event, [_event.MatrixEventEvent.Replaced, _event.MatrixEventEvent.VisibilityChange]); + room?.reEmitter.reEmit(event, [_event.MatrixEventEvent.BeforeRedaction]); + } + return event; + } + return mapper; +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/ExtensibleEvent.js b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/ExtensibleEvent.js new file mode 100644 index 0000000000..c3578ffc35 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/ExtensibleEvent.js @@ -0,0 +1,63 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.ExtensibleEvent = void 0; +/* +Copyright 2021 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Represents an Extensible Event in Matrix. + */ +class ExtensibleEvent { + constructor(wireFormat) { + this.wireFormat = wireFormat; + } + + /** + * Shortcut to wireFormat.content + */ + get wireContent() { + return this.wireFormat.content; + } + + /** + * Serializes the event into a format which can be used to send the + * event to the room. + * @returns The serialized event. + */ + + /** + * Determines if this event is equivalent to the provided event type. + * This is recommended over `instanceof` checks due to issues in the JS + * runtime (and layering of dependencies in some projects). + * + * Implementations should pass this check off to their super classes + * if their own checks fail. Some primary implementations do not extend + * fallback classes given they support the primary type first. Thus, + * those classes may return false if asked about their fallback + * representation. + * + * Note that this only checks primary event types: legacy events, like + * m.room.message, should/will fail this check. + * @param primaryEventType - The (potentially namespaced) event + * type. + * @returns True if this event *could* be represented as the + * given type. + */ +} +exports.ExtensibleEvent = ExtensibleEvent;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/InvalidEventError.js b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/InvalidEventError.js new file mode 100644 index 0000000000..2136849a23 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/InvalidEventError.js @@ -0,0 +1,31 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.InvalidEventError = void 0; +/* +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Thrown when an event is unforgivably unparsable. + */ +class InvalidEventError extends Error { + constructor(message) { + super(message); + } +} +exports.InvalidEventError = InvalidEventError;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/MessageEvent.js b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/MessageEvent.js new file mode 100644 index 0000000000..513266a460 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/MessageEvent.js @@ -0,0 +1,138 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.MessageEvent = void 0; +var _ExtensibleEvent = require("./ExtensibleEvent"); +var _extensible_events = require("../@types/extensible_events"); +var _utilities = require("./utilities"); +var _InvalidEventError = require("./InvalidEventError"); +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/** + * Represents a message event. Message events are the simplest form of event with + * just text (optionally of different mimetypes, like HTML). + * + * Message events can additionally be an Emote or Notice, though typically those + * are represented as EmoteEvent and NoticeEvent respectively. + */ +class MessageEvent extends _ExtensibleEvent.ExtensibleEvent { + /** + * Creates a new MessageEvent from a pure format. Note that the event is + * *not* parsed here: it will be treated as a literal m.message primary + * typed event. + * @param wireFormat - The event. + */ + constructor(wireFormat) { + super(wireFormat); + /** + * The default text for the event. + */ + _defineProperty(this, "text", void 0); + /** + * The default HTML for the event, if provided. + */ + _defineProperty(this, "html", void 0); + /** + * All the different renderings of the message. Note that this is the same + * format as an m.message body but may contain elements not found directly + * in the event content: this is because this is interpreted based off the + * other information available in the event. + */ + _defineProperty(this, "renderings", void 0); + const mmessage = _extensible_events.M_MESSAGE.findIn(this.wireContent); + const mtext = _extensible_events.M_TEXT.findIn(this.wireContent); + const mhtml = _extensible_events.M_HTML.findIn(this.wireContent); + if ((0, _utilities.isProvided)(mmessage)) { + if (!Array.isArray(mmessage)) { + throw new _InvalidEventError.InvalidEventError("m.message contents must be an array"); + } + const text = mmessage.find(r => !(0, _utilities.isProvided)(r.mimetype) || r.mimetype === "text/plain"); + const html = mmessage.find(r => r.mimetype === "text/html"); + if (!text) throw new _InvalidEventError.InvalidEventError("m.message is missing a plain text representation"); + this.text = text.body; + this.html = html?.body; + this.renderings = mmessage; + } else if ((0, _utilities.isOptionalAString)(mtext)) { + this.text = mtext; + this.html = mhtml; + this.renderings = [{ + body: mtext, + mimetype: "text/plain" + }]; + if (this.html) { + this.renderings.push({ + body: this.html, + mimetype: "text/html" + }); + } + } else { + throw new _InvalidEventError.InvalidEventError("Missing textual representation for event"); + } + } + isEquivalentTo(primaryEventType) { + return (0, _extensible_events.isEventTypeSame)(primaryEventType, _extensible_events.M_MESSAGE); + } + serializeMMessageOnly() { + let messageRendering = { + [_extensible_events.M_MESSAGE.name]: this.renderings + }; + + // Use the shorthand if it's just a simple text event + if (this.renderings.length === 1) { + const mime = this.renderings[0].mimetype; + if (mime === undefined || mime === "text/plain") { + messageRendering = { + [_extensible_events.M_TEXT.name]: this.renderings[0].body + }; + } + } + return messageRendering; + } + serialize() { + return { + type: "m.room.message", + content: _objectSpread(_objectSpread({}, this.serializeMMessageOnly()), {}, { + body: this.text, + msgtype: "m.text", + format: this.html ? "org.matrix.custom.html" : undefined, + formatted_body: this.html ?? undefined + }) + }; + } + + /** + * Creates a new MessageEvent from text and HTML. + * @param text - The text. + * @param html - Optional HTML. + * @returns The representative message event. + */ + static from(text, html) { + return new MessageEvent({ + type: _extensible_events.M_MESSAGE.name, + content: { + [_extensible_events.M_TEXT.name]: text, + [_extensible_events.M_HTML.name]: html + } + }); + } +} +exports.MessageEvent = MessageEvent;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollEndEvent.js b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollEndEvent.js new file mode 100644 index 0000000000..0594146dfe --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollEndEvent.js @@ -0,0 +1,93 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.PollEndEvent = void 0; +var _extensible_events = require("../@types/extensible_events"); +var _polls = require("../@types/polls"); +var _ExtensibleEvent = require("./ExtensibleEvent"); +var _InvalidEventError = require("./InvalidEventError"); +var _MessageEvent = require("./MessageEvent"); +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/** + * Represents a poll end/closure event. + */ +class PollEndEvent extends _ExtensibleEvent.ExtensibleEvent { + /** + * Creates a new PollEndEvent from a pure format. Note that the event is *not* + * parsed here: it will be treated as a literal m.poll.response primary typed event. + * @param wireFormat - The event. + */ + constructor(wireFormat) { + super(wireFormat); + /** + * The poll start event ID referenced by the response. + */ + _defineProperty(this, "pollEventId", void 0); + /** + * The closing message for the event. + */ + _defineProperty(this, "closingMessage", void 0); + const rel = this.wireContent["m.relates_to"]; + if (!_extensible_events.REFERENCE_RELATION.matches(rel?.rel_type) || typeof rel?.event_id !== "string") { + throw new _InvalidEventError.InvalidEventError("Relationship must be a reference to an event"); + } + this.pollEventId = rel.event_id; + this.closingMessage = new _MessageEvent.MessageEvent(this.wireFormat); + } + isEquivalentTo(primaryEventType) { + return (0, _extensible_events.isEventTypeSame)(primaryEventType, _polls.M_POLL_END); + } + serialize() { + return { + type: _polls.M_POLL_END.name, + content: _objectSpread({ + "m.relates_to": { + rel_type: _extensible_events.REFERENCE_RELATION.name, + event_id: this.pollEventId + }, + [_polls.M_POLL_END.name]: {} + }, this.closingMessage.serialize().content) + }; + } + + /** + * Creates a new PollEndEvent from a poll event ID. + * @param pollEventId - The poll start event ID. + * @param message - A closing message, typically revealing the top answer. + * @returns The representative poll closure event. + */ + static from(pollEventId, message) { + return new PollEndEvent({ + type: _polls.M_POLL_END.name, + content: { + "m.relates_to": { + rel_type: _extensible_events.REFERENCE_RELATION.name, + event_id: pollEventId + }, + [_polls.M_POLL_END.name]: {}, + [_extensible_events.M_TEXT.name]: message + } + }); + } +} +exports.PollEndEvent = PollEndEvent;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollResponseEvent.js b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollResponseEvent.js new file mode 100644 index 0000000000..1c6e2bf23f --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollResponseEvent.js @@ -0,0 +1,140 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.PollResponseEvent = void 0; +var _ExtensibleEvent = require("./ExtensibleEvent"); +var _polls = require("../@types/polls"); +var _extensible_events = require("../@types/extensible_events"); +var _InvalidEventError = require("./InvalidEventError"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/** + * Represents a poll response event. + */ +class PollResponseEvent extends _ExtensibleEvent.ExtensibleEvent { + /** + * The provided answers for the poll. Note that this may be falsy/unpredictable if + * the `spoiled` property is true. + */ + get answerIds() { + return this.internalAnswerIds; + } + + /** + * The poll start event ID referenced by the response. + */ + + /** + * Whether the vote is spoiled. + */ + get spoiled() { + return this.internalSpoiled; + } + + /** + * Creates a new PollResponseEvent from a pure format. Note that the event is *not* + * parsed here: it will be treated as a literal m.poll.response primary typed event. + * + * To validate the response against a poll, call `validateAgainst` after creation. + * @param wireFormat - The event. + */ + constructor(wireFormat) { + super(wireFormat); + _defineProperty(this, "internalAnswerIds", []); + _defineProperty(this, "internalSpoiled", false); + _defineProperty(this, "pollEventId", void 0); + const rel = this.wireContent["m.relates_to"]; + if (!_extensible_events.REFERENCE_RELATION.matches(rel?.rel_type) || typeof rel?.event_id !== "string") { + throw new _InvalidEventError.InvalidEventError("Relationship must be a reference to an event"); + } + this.pollEventId = rel.event_id; + this.validateAgainst(null); + } + + /** + * Validates the poll response using the poll start event as a frame of reference. This + * is used to determine if the vote is spoiled, whether the answers are valid, etc. + * @param poll - The poll start event. + */ + validateAgainst(poll) { + const response = _polls.M_POLL_RESPONSE.findIn(this.wireContent); + if (!Array.isArray(response?.answers)) { + this.internalSpoiled = true; + this.internalAnswerIds = []; + return; + } + let answers = response?.answers ?? []; + if (answers.some(a => typeof a !== "string") || answers.length === 0) { + this.internalSpoiled = true; + this.internalAnswerIds = []; + return; + } + if (poll) { + if (answers.some(a => !poll.answers.some(pa => pa.id === a))) { + this.internalSpoiled = true; + this.internalAnswerIds = []; + return; + } + answers = answers.slice(0, poll.maxSelections); + } + this.internalAnswerIds = answers; + this.internalSpoiled = false; + } + isEquivalentTo(primaryEventType) { + return (0, _extensible_events.isEventTypeSame)(primaryEventType, _polls.M_POLL_RESPONSE); + } + serialize() { + return { + type: _polls.M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: _extensible_events.REFERENCE_RELATION.name, + event_id: this.pollEventId + }, + [_polls.M_POLL_RESPONSE.name]: { + answers: this.spoiled ? undefined : this.answerIds + } + } + }; + } + + /** + * Creates a new PollResponseEvent from a set of answers. To spoil the vote, pass an empty + * answers array. + * @param answers - The user's answers. Should be valid from a poll's answer IDs. + * @param pollEventId - The poll start event ID. + * @returns The representative poll response event. + */ + static from(answers, pollEventId) { + return new PollResponseEvent({ + type: _polls.M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: _extensible_events.REFERENCE_RELATION.name, + event_id: pollEventId + }, + [_polls.M_POLL_RESPONSE.name]: { + answers: answers + } + } + }); + } +} +exports.PollResponseEvent = PollResponseEvent;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollStartEvent.js b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollStartEvent.js new file mode 100644 index 0000000000..5a1088566b --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollStartEvent.js @@ -0,0 +1,191 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.PollStartEvent = exports.PollAnswerSubevent = void 0; +var _matrixEventsSdk = require("matrix-events-sdk"); +var _MessageEvent = require("./MessageEvent"); +var _extensible_events = require("../@types/extensible_events"); +var _polls = require("../@types/polls"); +var _InvalidEventError = require("./InvalidEventError"); +var _ExtensibleEvent = require("./ExtensibleEvent"); +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/** + * Represents a poll answer. Note that this is represented as a subtype and is + * not registered as a parsable event - it is implied for usage exclusively + * within the PollStartEvent parsing. + */ +class PollAnswerSubevent extends _MessageEvent.MessageEvent { + constructor(wireFormat) { + super(wireFormat); + /** + * The answer ID. + */ + _defineProperty(this, "id", void 0); + const id = wireFormat.content.id; + if (!id || typeof id !== "string") { + throw new _InvalidEventError.InvalidEventError("Answer ID must be a non-empty string"); + } + this.id = id; + } + serialize() { + return { + type: "org.matrix.sdk.poll.answer", + content: _objectSpread({ + id: this.id + }, this.serializeMMessageOnly()) + }; + } + + /** + * Creates a new PollAnswerSubevent from ID and text. + * @param id - The answer ID (unique within the poll). + * @param text - The text. + * @returns The representative answer. + */ + static from(id, text) { + return new PollAnswerSubevent({ + type: "org.matrix.sdk.poll.answer", + content: { + id: id, + [_extensible_events.M_TEXT.name]: text + } + }); + } +} + +/** + * Represents a poll start event. + */ +exports.PollAnswerSubevent = PollAnswerSubevent; +class PollStartEvent extends _ExtensibleEvent.ExtensibleEvent { + /** + * Creates a new PollStartEvent from a pure format. Note that the event is *not* + * parsed here: it will be treated as a literal m.poll.start primary typed event. + * @param wireFormat - The event. + */ + constructor(wireFormat) { + super(wireFormat); + /** + * The question being asked, as a MessageEvent node. + */ + _defineProperty(this, "question", void 0); + /** + * The interpreted kind of poll. Note that this will infer a value that is known to the + * SDK rather than verbatim - this means unknown types will be represented as undisclosed + * polls. + * + * To get the raw kind, use rawKind. + */ + _defineProperty(this, "kind", void 0); + /** + * The true kind as provided by the event sender. Might not be valid. + */ + _defineProperty(this, "rawKind", void 0); + /** + * The maximum number of selections a user is allowed to make. + */ + _defineProperty(this, "maxSelections", void 0); + /** + * The possible answers for the poll. + */ + _defineProperty(this, "answers", void 0); + const poll = _polls.M_POLL_START.findIn(this.wireContent); + if (!poll?.question) { + throw new _InvalidEventError.InvalidEventError("A question is required"); + } + this.question = new _MessageEvent.MessageEvent({ + type: "org.matrix.sdk.poll.question", + content: poll.question + }); + this.rawKind = poll.kind; + if (_polls.M_POLL_KIND_DISCLOSED.matches(this.rawKind)) { + this.kind = _polls.M_POLL_KIND_DISCLOSED; + } else { + this.kind = _polls.M_POLL_KIND_UNDISCLOSED; // default & assumed value + } + + this.maxSelections = Number.isFinite(poll.max_selections) && poll.max_selections > 0 ? poll.max_selections : 1; + if (!Array.isArray(poll.answers)) { + throw new _InvalidEventError.InvalidEventError("Poll answers must be an array"); + } + const answers = poll.answers.slice(0, 20).map(a => new PollAnswerSubevent({ + type: "org.matrix.sdk.poll.answer", + content: a + })); + if (answers.length <= 0) { + throw new _InvalidEventError.InvalidEventError("No answers available"); + } + this.answers = answers; + } + isEquivalentTo(primaryEventType) { + return (0, _extensible_events.isEventTypeSame)(primaryEventType, _polls.M_POLL_START); + } + serialize() { + return { + type: _polls.M_POLL_START.name, + content: { + [_polls.M_POLL_START.name]: { + question: this.question.serialize().content, + kind: this.rawKind, + max_selections: this.maxSelections, + answers: this.answers.map(a => a.serialize().content) + }, + [_extensible_events.M_TEXT.name]: `${this.question.text}\n${this.answers.map((a, i) => `${i + 1}. ${a.text}`).join("\n")}` + } + }; + } + + /** + * Creates a new PollStartEvent from question, answers, and metadata. + * @param question - The question to ask. + * @param answers - The answers. Should be unique within each other. + * @param kind - The kind of poll. + * @param maxSelections - The maximum number of selections. Must be 1 or higher. + * @returns The representative poll start event. + */ + static from(question, answers, kind, maxSelections = 1) { + return new PollStartEvent({ + type: _polls.M_POLL_START.name, + content: { + [_extensible_events.M_TEXT.name]: question, + // unused by parsing + [_polls.M_POLL_START.name]: { + question: { + [_extensible_events.M_TEXT.name]: question + }, + kind: kind instanceof _matrixEventsSdk.NamespacedValue ? kind.name : kind, + max_selections: maxSelections, + answers: answers.map(a => ({ + id: makeId(), + [_extensible_events.M_TEXT.name]: a + })) + } + } + }); + } +} +exports.PollStartEvent = PollStartEvent; +const LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +function makeId() { + return [...Array(16)].map(() => LETTERS.charAt(Math.floor(Math.random() * LETTERS.length))).join(""); +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/utilities.js b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/utilities.js new file mode 100644 index 0000000000..62e02febd7 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/utilities.js @@ -0,0 +1,40 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.isOptionalAString = isOptionalAString; +exports.isProvided = isProvided; +/* +Copyright 2021 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Determines if the given optional was provided a value. + * @param s - The optional to test. + * @returns True if the value is defined. + */ +function isProvided(s) { + return s !== null && s !== undefined; +} + +/** + * Determines if the given optional string is a defined string. + * @param s - The input string. + * @returns True if the input is a defined string. + */ +function isOptionalAString(s) { + return isProvided(s) && typeof s === "string"; +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/feature.js b/comm/chat/protocols/matrix/lib/matrix-sdk/feature.js new file mode 100644 index 0000000000..e56be89ecb --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/feature.js @@ -0,0 +1,78 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.ServerSupport = exports.Feature = void 0; +exports.buildFeatureSupportMap = buildFeatureSupportMap; +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +let ServerSupport = /*#__PURE__*/function (ServerSupport) { + ServerSupport[ServerSupport["Stable"] = 0] = "Stable"; + ServerSupport[ServerSupport["Unstable"] = 1] = "Unstable"; + ServerSupport[ServerSupport["Unsupported"] = 2] = "Unsupported"; + return ServerSupport; +}({}); +exports.ServerSupport = ServerSupport; +let Feature = /*#__PURE__*/function (Feature) { + Feature["Thread"] = "Thread"; + Feature["ThreadUnreadNotifications"] = "ThreadUnreadNotifications"; + Feature["LoginTokenRequest"] = "LoginTokenRequest"; + Feature["RelationBasedRedactions"] = "RelationBasedRedactions"; + Feature["AccountDataDeletion"] = "AccountDataDeletion"; + Feature["RelationsRecursion"] = "RelationsRecursion"; + return Feature; +}({}); +exports.Feature = Feature; +const featureSupportResolver = { + [Feature.Thread]: { + unstablePrefixes: ["org.matrix.msc3440"], + matrixVersion: "v1.3" + }, + [Feature.ThreadUnreadNotifications]: { + unstablePrefixes: ["org.matrix.msc3771", "org.matrix.msc3773"], + matrixVersion: "v1.4" + }, + [Feature.LoginTokenRequest]: { + unstablePrefixes: ["org.matrix.msc3882"] + }, + [Feature.RelationBasedRedactions]: { + unstablePrefixes: ["org.matrix.msc3912"] + }, + [Feature.AccountDataDeletion]: { + unstablePrefixes: ["org.matrix.msc3391"] + }, + [Feature.RelationsRecursion]: { + unstablePrefixes: ["org.matrix.msc3981"] + } +}; +async function buildFeatureSupportMap(versions) { + const supportMap = new Map(); + for (const [feature, supportCondition] of Object.entries(featureSupportResolver)) { + const supportMatrixVersion = versions.versions?.includes(supportCondition.matrixVersion || "") ?? false; + const supportUnstablePrefixes = supportCondition.unstablePrefixes?.every(unstablePrefix => { + return versions.unstable_features?.[unstablePrefix] === true; + }) ?? false; + if (supportMatrixVersion) { + supportMap.set(feature, ServerSupport.Stable); + } else if (supportUnstablePrefixes) { + supportMap.set(feature, ServerSupport.Unstable); + } else { + supportMap.set(feature, ServerSupport.Unsupported); + } + } + return supportMap; +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/filter-component.js b/comm/chat/protocols/matrix/lib/matrix-sdk/filter-component.js new file mode 100644 index 0000000000..7baaf0c55a --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/filter-component.js @@ -0,0 +1,171 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.FilterComponent = void 0; +var _thread = require("./models/thread"); +/* +Copyright 2016 - 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Checks if a value matches a given field value, which may be a * terminated + * wildcard pattern. + * @param actualValue - The value to be compared + * @param filterValue - The filter pattern to be compared + * @returns true if the actualValue matches the filterValue + */ +function matchesWildcard(actualValue, filterValue) { + if (filterValue.endsWith("*")) { + const typePrefix = filterValue.slice(0, -1); + return actualValue.slice(0, typePrefix.length) === typePrefix; + } else { + return actualValue === filterValue; + } +} + +/* eslint-disable camelcase */ + +/* eslint-enable camelcase */ + +/** + * FilterComponent is a section of a Filter definition which defines the + * types, rooms, senders filters etc to be applied to a particular type of resource. + * This is all ported over from synapse's Filter object. + * + * N.B. that synapse refers to these as 'Filters', and what js-sdk refers to as + * 'Filters' are referred to as 'FilterCollections'. + */ +class FilterComponent { + constructor(filterJson, userId) { + this.filterJson = filterJson; + this.userId = userId; + } + + /** + * Checks with the filter component matches the given event + * @param event - event to be checked against the filter + * @returns true if the event matches the filter + */ + check(event) { + const bundledRelationships = event.getUnsigned()?.["m.relations"] || {}; + const relations = Object.keys(bundledRelationships); + // Relation senders allows in theory a look-up of any senders + // however clients can only know about the current user participation status + // as sending a whole list of participants could be proven problematic in terms + // of performance + // This should be improved when bundled relationships solve that problem + const relationSenders = []; + if (this.userId && bundledRelationships?.[_thread.THREAD_RELATION_TYPE.name]?.current_user_participated) { + relationSenders.push(this.userId); + } + return this.checkFields(event.getRoomId(), event.getSender(), event.getType(), event.getContent() ? event.getContent().url !== undefined : false, relations, relationSenders); + } + + /** + * Converts the filter component into the form expected over the wire + */ + toJSON() { + return { + types: this.filterJson.types || null, + not_types: this.filterJson.not_types || [], + rooms: this.filterJson.rooms || null, + not_rooms: this.filterJson.not_rooms || [], + senders: this.filterJson.senders || null, + not_senders: this.filterJson.not_senders || [], + contains_url: this.filterJson.contains_url || null, + [_thread.FILTER_RELATED_BY_SENDERS.name]: this.filterJson[_thread.FILTER_RELATED_BY_SENDERS.name] || [], + [_thread.FILTER_RELATED_BY_REL_TYPES.name]: this.filterJson[_thread.FILTER_RELATED_BY_REL_TYPES.name] || [] + }; + } + + /** + * Checks whether the filter component matches the given event fields. + * @param roomId - the roomId for the event being checked + * @param sender - the sender of the event being checked + * @param eventType - the type of the event being checked + * @param containsUrl - whether the event contains a content.url field + * @param relationTypes - whether has aggregated relation of the given type + * @param relationSenders - whether one of the relation is sent by the user listed + * @returns true if the event fields match the filter + */ + checkFields(roomId, sender, eventType, containsUrl, relationTypes, relationSenders) { + const literalKeys = { + rooms: function (v) { + return roomId === v; + }, + senders: function (v) { + return sender === v; + }, + types: function (v) { + return matchesWildcard(eventType, v); + } + }; + for (const name in literalKeys) { + const matchFunc = literalKeys[name]; + const notName = "not_" + name; + const disallowedValues = this.filterJson[notName]; + if (disallowedValues?.some(matchFunc)) { + return false; + } + const allowedValues = this.filterJson[name]; + if (allowedValues && !allowedValues.some(matchFunc)) { + return false; + } + } + const containsUrlFilter = this.filterJson.contains_url; + if (containsUrlFilter !== undefined && containsUrlFilter !== containsUrl) { + return false; + } + const relationTypesFilter = this.filterJson[_thread.FILTER_RELATED_BY_REL_TYPES.name]; + if (relationTypesFilter !== undefined) { + if (!this.arrayMatchesFilter(relationTypesFilter, relationTypes)) { + return false; + } + } + const relationSendersFilter = this.filterJson[_thread.FILTER_RELATED_BY_SENDERS.name]; + if (relationSendersFilter !== undefined) { + if (!this.arrayMatchesFilter(relationSendersFilter, relationSenders)) { + return false; + } + } + return true; + } + arrayMatchesFilter(filter, values) { + return values.length > 0 && filter.every(value => { + return values.includes(value); + }); + } + + /** + * Filters a list of events down to those which match this filter component + * @param events - Events to be checked against the filter component + * @returns events which matched the filter component + */ + filter(events) { + return events.filter(this.check, this); + } + + /** + * Returns the limit field for a given filter component, providing a default of + * 10 if none is otherwise specified. Cargo-culted from Synapse. + * @returns the limit for this filter component. + */ + limit() { + return this.filterJson.limit !== undefined ? this.filterJson.limit : 10; + } +} +exports.FilterComponent = FilterComponent;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/filter.js b/comm/chat/protocols/matrix/lib/matrix-sdk/filter.js new file mode 100644 index 0000000000..2fda475ec3 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/filter.js @@ -0,0 +1,212 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.Filter = void 0; +var _sync = require("./@types/sync"); +var _filterComponent = require("./filter-component"); +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2015 - 2021 Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/** + */ +function setProp(obj, keyNesting, val) { + const nestedKeys = keyNesting.split("."); + let currentObj = obj; + for (let i = 0; i < nestedKeys.length - 1; i++) { + if (!currentObj[nestedKeys[i]]) { + currentObj[nestedKeys[i]] = {}; + } + currentObj = currentObj[nestedKeys[i]]; + } + currentObj[nestedKeys[nestedKeys.length - 1]] = val; +} + +/* eslint-disable camelcase */ + +/* eslint-enable camelcase */ + +class Filter { + /** + * Create a filter from existing data. + */ + static fromJson(userId, filterId, jsonObj) { + const filter = new Filter(userId, filterId); + filter.setDefinition(jsonObj); + return filter; + } + /** + * Construct a new Filter. + * @param userId - The user ID for this filter. + * @param filterId - The filter ID if known. + */ + constructor(userId, filterId) { + this.userId = userId; + this.filterId = filterId; + _defineProperty(this, "definition", {}); + _defineProperty(this, "roomFilter", void 0); + _defineProperty(this, "roomTimelineFilter", void 0); + } + + /** + * Get the ID of this filter on your homeserver (if known) + * @returns The filter ID + */ + getFilterId() { + return this.filterId; + } + + /** + * Get the JSON body of the filter. + * @returns The filter definition + */ + getDefinition() { + return this.definition; + } + + /** + * Set the JSON body of the filter + * @param definition - The filter definition + */ + setDefinition(definition) { + this.definition = definition; + + // This is all ported from synapse's FilterCollection() + + // definitions look something like: + // { + // "room": { + // "rooms": ["!abcde:example.com"], + // "not_rooms": ["!123456:example.com"], + // "state": { + // "types": ["m.room.*"], + // "not_rooms": ["!726s6s6q:example.com"], + // "lazy_load_members": true, + // }, + // "timeline": { + // "limit": 10, + // "types": ["m.room.message"], + // "not_rooms": ["!726s6s6q:example.com"], + // "not_senders": ["@spam:example.com"] + // "contains_url": true + // }, + // "ephemeral": { + // "types": ["m.receipt", "m.typing"], + // "not_rooms": ["!726s6s6q:example.com"], + // "not_senders": ["@spam:example.com"] + // } + // }, + // "presence": { + // "types": ["m.presence"], + // "not_senders": ["@alice:example.com"] + // }, + // "event_format": "client", + // "event_fields": ["type", "content", "sender"] + // } + + const roomFilterJson = definition.room; + + // consider the top level rooms/not_rooms filter + const roomFilterFields = {}; + if (roomFilterJson) { + if (roomFilterJson.rooms) { + roomFilterFields.rooms = roomFilterJson.rooms; + } + if (roomFilterJson.rooms) { + roomFilterFields.not_rooms = roomFilterJson.not_rooms; + } + } + this.roomFilter = new _filterComponent.FilterComponent(roomFilterFields, this.userId); + this.roomTimelineFilter = new _filterComponent.FilterComponent(roomFilterJson?.timeline || {}, this.userId); + + // don't bother porting this from synapse yet: + // this._room_state_filter = + // new FilterComponent(roomFilterJson.state || {}); + // this._room_ephemeral_filter = + // new FilterComponent(roomFilterJson.ephemeral || {}); + // this._room_account_data_filter = + // new FilterComponent(roomFilterJson.account_data || {}); + // this._presence_filter = + // new FilterComponent(definition.presence || {}); + // this._account_data_filter = + // new FilterComponent(definition.account_data || {}); + } + + /** + * Get the room.timeline filter component of the filter + * @returns room timeline filter component + */ + getRoomTimelineFilterComponent() { + return this.roomTimelineFilter; + } + + /** + * Filter the list of events based on whether they are allowed in a timeline + * based on this filter + * @param events - the list of events being filtered + * @returns the list of events which match the filter + */ + filterRoomTimeline(events) { + if (this.roomFilter) { + events = this.roomFilter.filter(events); + } + if (this.roomTimelineFilter) { + events = this.roomTimelineFilter.filter(events); + } + return events; + } + + /** + * Set the max number of events to return for each room's timeline. + * @param limit - The max number of events to return for each room. + */ + setTimelineLimit(limit) { + setProp(this.definition, "room.timeline.limit", limit); + } + + /** + * Enable threads unread notification + */ + setUnreadThreadNotifications(enabled) { + this.definition = _objectSpread(_objectSpread({}, this.definition), {}, { + room: _objectSpread(_objectSpread({}, this.definition?.room), {}, { + timeline: _objectSpread(_objectSpread({}, this.definition?.room?.timeline), {}, { + [_sync.UNREAD_THREAD_NOTIFICATIONS.name]: enabled + }) + }) + }); + } + setLazyLoadMembers(enabled) { + setProp(this.definition, "room.state.lazy_load_members", enabled); + } + + /** + * Control whether left rooms should be included in responses. + * @param includeLeave - True to make rooms the user has left appear + * in responses. + */ + setIncludeLeaveRooms(includeLeave) { + setProp(this.definition, "room.include_leave", includeLeave); + } +} +exports.Filter = Filter; +_defineProperty(Filter, "LAZY_LOADING_MESSAGES_FILTER", { + lazy_load_members: true +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/errors.js b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/errors.js new file mode 100644 index 0000000000..067053b548 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/errors.js @@ -0,0 +1,83 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.MatrixError = exports.HTTPError = exports.ConnectionError = void 0; +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Construct a generic HTTP error. This is a JavaScript Error with additional information + * specific to HTTP responses. + * @param msg - The error message to include. + * @param httpStatus - The HTTP response status code. + */ +class HTTPError extends Error { + constructor(msg, httpStatus) { + super(msg); + this.httpStatus = httpStatus; + } +} +exports.HTTPError = HTTPError; +class MatrixError extends HTTPError { + /** + * Construct a Matrix error. This is a JavaScript Error with additional + * information specific to the standard Matrix error response. + * @param errorJson - The Matrix error JSON returned from the homeserver. + * @param httpStatus - The numeric HTTP status code given + */ + constructor(errorJson = {}, httpStatus, url, event) { + let message = errorJson.error || "Unknown message"; + if (httpStatus) { + message = `[${httpStatus}] ${message}`; + } + if (url) { + message = `${message} (${url})`; + } + super(`MatrixError: ${message}`, httpStatus); + this.httpStatus = httpStatus; + this.url = url; + this.event = event; + // The Matrix 'errcode' value, e.g. "M_FORBIDDEN". + _defineProperty(this, "errcode", void 0); + // The raw Matrix error JSON used to construct this object. + _defineProperty(this, "data", void 0); + this.errcode = errorJson.errcode; + this.name = errorJson.errcode || "Unknown error code"; + this.data = errorJson; + } +} + +/** + * Construct a ConnectionError. This is a JavaScript Error indicating + * that a request failed because of some error with the connection, either + * CORS was not correctly configured on the server, the server didn't response, + * the request timed out, or the internet connection on the client side went down. + */ +exports.MatrixError = MatrixError; +class ConnectionError extends Error { + constructor(message, cause) { + super(message + (cause ? `: ${cause.message}` : "")); + } + get name() { + return "ConnectionError"; + } +} +exports.ConnectionError = ConnectionError;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/fetch.js b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/fetch.js new file mode 100644 index 0000000000..5450392423 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/fetch.js @@ -0,0 +1,265 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.FetchHttpApi = void 0; +var _utils = require("../utils"); +var _method = require("./method"); +var _errors = require("./errors"); +var _interface = require("./interface"); +var _utils2 = require("./utils"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2022 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ /** + * This is an internal module. See {@link MatrixHttpApi} for the public class. + */ +class FetchHttpApi { + constructor(eventEmitter, opts) { + this.eventEmitter = eventEmitter; + this.opts = opts; + _defineProperty(this, "abortController", new AbortController()); + (0, _utils.checkObjectHasKeys)(opts, ["baseUrl", "prefix"]); + opts.onlyData = !!opts.onlyData; + opts.useAuthorizationHeader = opts.useAuthorizationHeader ?? true; + } + abort() { + this.abortController.abort(); + this.abortController = new AbortController(); + } + fetch(resource, options) { + if (this.opts.fetchFn) { + return this.opts.fetchFn(resource, options); + } + return global.fetch(resource, options); + } + + /** + * Sets the base URL for the identity server + * @param url - The new base url + */ + setIdBaseUrl(url) { + this.opts.idBaseUrl = url; + } + idServerRequest(method, path, params, prefix, accessToken) { + if (!this.opts.idBaseUrl) { + throw new Error("No identity server base URL set"); + } + let queryParams = undefined; + let body = undefined; + if (method === _method.Method.Get) { + queryParams = params; + } else { + body = params; + } + const fullUri = this.getUrl(path, queryParams, prefix, this.opts.idBaseUrl); + const opts = { + json: true, + headers: {} + }; + if (accessToken) { + opts.headers.Authorization = `Bearer ${accessToken}`; + } + return this.requestOtherUrl(method, fullUri, body, opts); + } + + /** + * Perform an authorised request to the homeserver. + * @param method - The HTTP method e.g. "GET". + * @param path - The HTTP path <b>after</b> the supplied prefix e.g. + * "/createRoom". + * + * @param queryParams - A dict of query params (these will NOT be + * urlencoded). If unspecified, there will be no query params. + * + * @param body - The HTTP JSON body. + * + * @param opts - additional options. If a number is specified, + * this is treated as `opts.localTimeoutMs`. + * + * @returns Promise which resolves to + * ``` + * { + * data: {Object}, + * headers: {Object}, + * code: {Number}, + * } + * ``` + * If `onlyData` is set, this will resolve to the `data` object only. + * @returns Rejects with an error if a problem occurred. + * This includes network problems and Matrix-specific error JSON. + */ + authedRequest(method, path, queryParams, body, opts = {}) { + if (!queryParams) queryParams = {}; + if (this.opts.accessToken) { + if (this.opts.useAuthorizationHeader) { + if (!opts.headers) { + opts.headers = {}; + } + if (!opts.headers.Authorization) { + opts.headers.Authorization = "Bearer " + this.opts.accessToken; + } + if (queryParams.access_token) { + delete queryParams.access_token; + } + } else if (!queryParams.access_token) { + queryParams.access_token = this.opts.accessToken; + } + } + const requestPromise = this.request(method, path, queryParams, body, opts); + requestPromise.catch(err => { + if (err.errcode == "M_UNKNOWN_TOKEN" && !opts?.inhibitLogoutEmit) { + this.eventEmitter.emit(_interface.HttpApiEvent.SessionLoggedOut, err); + } else if (err.errcode == "M_CONSENT_NOT_GIVEN") { + this.eventEmitter.emit(_interface.HttpApiEvent.NoConsent, err.message, err.data.consent_uri); + } + }); + + // return the original promise, otherwise tests break due to it having to + // go around the event loop one more time to process the result of the request + return requestPromise; + } + + /** + * Perform a request to the homeserver without any credentials. + * @param method - The HTTP method e.g. "GET". + * @param path - The HTTP path <b>after</b> the supplied prefix e.g. + * "/createRoom". + * + * @param queryParams - A dict of query params (these will NOT be + * urlencoded). If unspecified, there will be no query params. + * + * @param body - The HTTP JSON body. + * + * @param opts - additional options + * + * @returns Promise which resolves to + * ``` + * { + * data: {Object}, + * headers: {Object}, + * code: {Number}, + * } + * ``` + * If `onlyData</code> is set, this will resolve to the <code>data` + * object only. + * @returns Rejects with an error if a problem + * occurred. This includes network problems and Matrix-specific error JSON. + */ + request(method, path, queryParams, body, opts) { + const fullUri = this.getUrl(path, queryParams, opts?.prefix, opts?.baseUrl); + return this.requestOtherUrl(method, fullUri, body, opts); + } + + /** + * Perform a request to an arbitrary URL. + * @param method - The HTTP method e.g. "GET". + * @param url - The HTTP URL object. + * + * @param body - The HTTP JSON body. + * + * @param opts - additional options + * + * @returns Promise which resolves to data unless `onlyData` is specified as false, + * where the resolved value will be a fetch Response object. + * @returns Rejects with an error if a problem + * occurred. This includes network problems and Matrix-specific error JSON. + */ + async requestOtherUrl(method, url, body, opts = {}) { + const headers = Object.assign({}, opts.headers || {}); + const json = opts.json ?? true; + // We can't use getPrototypeOf here as objects made in other contexts e.g. over postMessage won't have same ref + const jsonBody = json && body?.constructor?.name === Object.name; + if (json) { + if (jsonBody && !headers["Content-Type"]) { + headers["Content-Type"] = "application/json"; + } + if (!headers["Accept"]) { + headers["Accept"] = "application/json"; + } + } + const timeout = opts.localTimeoutMs ?? this.opts.localTimeoutMs; + const keepAlive = opts.keepAlive ?? false; + const signals = [this.abortController.signal]; + if (timeout !== undefined) { + signals.push((0, _utils2.timeoutSignal)(timeout)); + } + if (opts.abortSignal) { + signals.push(opts.abortSignal); + } + let data; + if (jsonBody) { + data = JSON.stringify(body); + } else { + data = body; + } + const { + signal, + cleanup + } = (0, _utils2.anySignal)(signals); + let res; + try { + res = await this.fetch(url, { + signal, + method, + body: data, + headers, + mode: "cors", + redirect: "follow", + referrer: "", + referrerPolicy: "no-referrer", + cache: "no-cache", + credentials: "omit", + // we send credentials via headers + keepalive: keepAlive + }); + } catch (e) { + if (e.name === "AbortError") { + throw e; + } + throw new _errors.ConnectionError("fetch failed", e); + } finally { + cleanup(); + } + if (!res.ok) { + throw (0, _utils2.parseErrorResponse)(res, await res.text()); + } + if (this.opts.onlyData) { + return json ? res.json() : res.text(); + } + return res; + } + + /** + * Form and return a homeserver request URL based on the given path params and prefix. + * @param path - The HTTP path <b>after</b> the supplied prefix e.g. "/createRoom". + * @param queryParams - A dict of query params (these will NOT be urlencoded). + * @param prefix - The full prefix to use e.g. "/_matrix/client/v2_alpha", defaulting to this.opts.prefix. + * @param baseUrl - The baseUrl to use e.g. "https://matrix.org", defaulting to this.opts.baseUrl. + * @returns URL + */ + getUrl(path, queryParams, prefix, baseUrl) { + const baseUrlWithFallback = baseUrl ?? this.opts.baseUrl; + const baseUrlWithoutTrailingSlash = baseUrlWithFallback.endsWith("/") ? baseUrlWithFallback.slice(0, -1) : baseUrlWithFallback; + const url = new URL(baseUrlWithoutTrailingSlash + (prefix ?? this.opts.prefix) + path); + if (queryParams) { + (0, _utils.encodeParams)(queryParams, url.searchParams); + } + return url; + } +} +exports.FetchHttpApi = FetchHttpApi;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/index.js b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/index.js new file mode 100644 index 0000000000..c9425eec60 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/index.js @@ -0,0 +1,240 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +var _exportNames = { + MatrixHttpApi: true +}; +exports.MatrixHttpApi = void 0; +var _fetch = require("./fetch"); +var _prefix = require("./prefix"); +Object.keys(_prefix).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _prefix[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _prefix[key]; + } + }); +}); +var _utils = require("../utils"); +var callbacks = _interopRequireWildcard(require("../realtime-callbacks")); +var _method = require("./method"); +Object.keys(_method).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _method[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _method[key]; + } + }); +}); +var _errors = require("./errors"); +Object.keys(_errors).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _errors[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _errors[key]; + } + }); +}); +var _utils2 = require("./utils"); +Object.keys(_utils2).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _utils2[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _utils2[key]; + } + }); +}); +var _interface = require("./interface"); +Object.keys(_interface).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _interface[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _interface[key]; + } + }); +}); +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2022 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +class MatrixHttpApi extends _fetch.FetchHttpApi { + constructor(...args) { + super(...args); + _defineProperty(this, "uploads", []); + } + /** + * Upload content to the homeserver + * + * @param file - The object to upload. On a browser, something that + * can be sent to XMLHttpRequest.send (typically a File). Under node.js, + * a Buffer, String or ReadStream. + * + * @param opts - options object + * + * @returns Promise which resolves to response object, as + * determined by this.opts.onlyData, opts.rawResponse, and + * opts.onlyContentUri. Rejects with an error (usually a MatrixError). + */ + uploadContent(file, opts = {}) { + const includeFilename = opts.includeFilename ?? true; + const abortController = opts.abortController ?? new AbortController(); + + // If the file doesn't have a mime type, use a default since the HS errors if we don't supply one. + const contentType = opts.type ?? file.type ?? "application/octet-stream"; + const fileName = opts.name ?? file.name; + const upload = { + loaded: 0, + total: 0, + abortController + }; + const deferred = (0, _utils.defer)(); + if (global.XMLHttpRequest) { + const xhr = new global.XMLHttpRequest(); + const timeoutFn = function () { + xhr.abort(); + deferred.reject(new Error("Timeout")); + }; + + // set an initial timeout of 30s; we'll advance it each time we get a progress notification + let timeoutTimer = callbacks.setTimeout(timeoutFn, 30000); + xhr.onreadystatechange = function () { + switch (xhr.readyState) { + case global.XMLHttpRequest.DONE: + callbacks.clearTimeout(timeoutTimer); + try { + if (xhr.status === 0) { + throw new DOMException(xhr.statusText, "AbortError"); // mimic fetch API + } + + if (!xhr.responseText) { + throw new Error("No response body."); + } + if (xhr.status >= 400) { + deferred.reject((0, _utils2.parseErrorResponse)(xhr, xhr.responseText)); + } else { + deferred.resolve(JSON.parse(xhr.responseText)); + } + } catch (err) { + if (err.name === "AbortError") { + deferred.reject(err); + return; + } + deferred.reject(new _errors.ConnectionError("request failed", err)); + } + break; + } + }; + xhr.upload.onprogress = ev => { + callbacks.clearTimeout(timeoutTimer); + upload.loaded = ev.loaded; + upload.total = ev.total; + timeoutTimer = callbacks.setTimeout(timeoutFn, 30000); + opts.progressHandler?.({ + loaded: ev.loaded, + total: ev.total + }); + }; + const url = this.getUrl("/upload", undefined, _prefix.MediaPrefix.R0); + if (includeFilename && fileName) { + url.searchParams.set("filename", encodeURIComponent(fileName)); + } + if (!this.opts.useAuthorizationHeader && this.opts.accessToken) { + url.searchParams.set("access_token", encodeURIComponent(this.opts.accessToken)); + } + xhr.open(_method.Method.Post, url.href); + if (this.opts.useAuthorizationHeader && this.opts.accessToken) { + xhr.setRequestHeader("Authorization", "Bearer " + this.opts.accessToken); + } + xhr.setRequestHeader("Content-Type", contentType); + xhr.send(file); + abortController.signal.addEventListener("abort", () => { + xhr.abort(); + }); + } else { + const queryParams = {}; + if (includeFilename && fileName) { + queryParams.filename = fileName; + } + const headers = { + "Content-Type": contentType + }; + this.authedRequest(_method.Method.Post, "/upload", queryParams, file, { + prefix: _prefix.MediaPrefix.R0, + headers, + abortSignal: abortController.signal + }).then(response => { + return this.opts.onlyData ? response : response.json(); + }).then(deferred.resolve, deferred.reject); + } + + // remove the upload from the list on completion + upload.promise = deferred.promise.finally(() => { + (0, _utils.removeElement)(this.uploads, elem => elem === upload); + }); + abortController.signal.addEventListener("abort", () => { + (0, _utils.removeElement)(this.uploads, elem => elem === upload); + deferred.reject(new DOMException("Aborted", "AbortError")); + }); + this.uploads.push(upload); + return upload.promise; + } + cancelUpload(promise) { + const upload = this.uploads.find(u => u.promise === promise); + if (upload) { + upload.abortController.abort(); + return true; + } + return false; + } + getCurrentUploads() { + return this.uploads; + } + + /** + * Get the content repository url with query parameters. + * @returns An object with a 'base', 'path' and 'params' for base URL, + * path and query parameters respectively. + */ + getContentUri() { + return { + base: this.opts.baseUrl, + path: _prefix.MediaPrefix.R0 + "/upload", + params: { + access_token: this.opts.accessToken + } + }; + } +} +exports.MatrixHttpApi = MatrixHttpApi;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/interface.js b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/interface.js new file mode 100644 index 0000000000..4ee57a29b0 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/interface.js @@ -0,0 +1,27 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.HttpApiEvent = void 0; +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +let HttpApiEvent = /*#__PURE__*/function (HttpApiEvent) { + HttpApiEvent["SessionLoggedOut"] = "Session.logged_out"; + HttpApiEvent["NoConsent"] = "no_consent"; + return HttpApiEvent; +}({}); +exports.HttpApiEvent = HttpApiEvent;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/method.js b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/method.js new file mode 100644 index 0000000000..cab6c3e720 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/method.js @@ -0,0 +1,29 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.Method = void 0; +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +let Method = /*#__PURE__*/function (Method) { + Method["Get"] = "GET"; + Method["Put"] = "PUT"; + Method["Post"] = "POST"; + Method["Delete"] = "DELETE"; + return Method; +}({}); +exports.Method = Method;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/prefix.js b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/prefix.js new file mode 100644 index 0000000000..3bc37083b3 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/prefix.js @@ -0,0 +1,39 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.MediaPrefix = exports.IdentityPrefix = exports.ClientPrefix = void 0; +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +let ClientPrefix = /*#__PURE__*/function (ClientPrefix) { + ClientPrefix["R0"] = "/_matrix/client/r0"; + ClientPrefix["V1"] = "/_matrix/client/v1"; + ClientPrefix["V3"] = "/_matrix/client/v3"; + ClientPrefix["Unstable"] = "/_matrix/client/unstable"; + return ClientPrefix; +}({}); +exports.ClientPrefix = ClientPrefix; +let IdentityPrefix = /*#__PURE__*/function (IdentityPrefix) { + IdentityPrefix["V2"] = "/_matrix/identity/v2"; + return IdentityPrefix; +}({}); +exports.IdentityPrefix = IdentityPrefix; +let MediaPrefix = /*#__PURE__*/function (MediaPrefix) { + MediaPrefix["R0"] = "/_matrix/media/r0"; + return MediaPrefix; +}({}); +exports.MediaPrefix = MediaPrefix;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/utils.js b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/utils.js new file mode 100644 index 0000000000..a39b6dc2bd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/utils.js @@ -0,0 +1,143 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.anySignal = anySignal; +exports.parseErrorResponse = parseErrorResponse; +exports.retryNetworkOperation = retryNetworkOperation; +exports.timeoutSignal = timeoutSignal; +var _contentType = require("content-type"); +var _logger = require("../logger"); +var _utils = require("../utils"); +var _errors = require("./errors"); +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Ponyfill for https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout +function timeoutSignal(ms) { + const controller = new AbortController(); + setTimeout(() => { + controller.abort(); + }, ms); + return controller.signal; +} +function anySignal(signals) { + const controller = new AbortController(); + function cleanup() { + for (const signal of signals) { + signal.removeEventListener("abort", onAbort); + } + } + function onAbort() { + controller.abort(); + cleanup(); + } + for (const signal of signals) { + if (signal.aborted) { + onAbort(); + break; + } + signal.addEventListener("abort", onAbort); + } + return { + signal: controller.signal, + cleanup + }; +} + +/** + * Attempt to turn an HTTP error response into a Javascript Error. + * + * If it is a JSON response, we will parse it into a MatrixError. Otherwise + * we return a generic Error. + * + * @param response - response object + * @param body - raw body of the response + * @returns + */ +function parseErrorResponse(response, body) { + let contentType; + try { + contentType = getResponseContentType(response); + } catch (e) { + return e; + } + if (contentType?.type === "application/json" && body) { + return new _errors.MatrixError(JSON.parse(body), response.status, isXhr(response) ? response.responseURL : response.url); + } + if (contentType?.type === "text/plain") { + return new _errors.HTTPError(`Server returned ${response.status} error: ${body}`, response.status); + } + return new _errors.HTTPError(`Server returned ${response.status} error`, response.status); +} +function isXhr(response) { + return "getResponseHeader" in response; +} + +/** + * extract the Content-Type header from the response object, and + * parse it to a `{type, parameters}` object. + * + * returns null if no content-type header could be found. + * + * @param response - response object + * @returns parsed content-type header, or null if not found + */ +function getResponseContentType(response) { + let contentType; + if (isXhr(response)) { + contentType = response.getResponseHeader("Content-Type"); + } else { + contentType = response.headers.get("Content-Type"); + } + if (!contentType) return null; + try { + return (0, _contentType.parse)(contentType); + } catch (e) { + throw new Error(`Error parsing Content-Type '${contentType}': ${e}`); + } +} + +/** + * Retries a network operation run in a callback. + * @param maxAttempts - maximum attempts to try + * @param callback - callback that returns a promise of the network operation. If rejected with ConnectionError, it will be retried by calling the callback again. + * @returns the result of the network operation + * @throws {@link ConnectionError} If after maxAttempts the callback still throws ConnectionError + */ +async function retryNetworkOperation(maxAttempts, callback) { + let attempts = 0; + let lastConnectionError = null; + while (attempts < maxAttempts) { + try { + if (attempts > 0) { + const timeout = 1000 * Math.pow(2, attempts); + _logger.logger.log(`network operation failed ${attempts} times, retrying in ${timeout}ms...`); + await (0, _utils.sleep)(timeout); + } + return await callback(); + } catch (err) { + if (err instanceof _errors.ConnectionError) { + attempts += 1; + lastConnectionError = err; + } else { + throw err; + } + } + } + throw lastConnectionError; +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/index.js b/comm/chat/protocols/matrix/lib/matrix-sdk/index.js new file mode 100644 index 0000000000..77e952b9c0 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/index.js @@ -0,0 +1,43 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +var _exportNames = {}; +exports.default = void 0; +var matrixcs = _interopRequireWildcard(require("./matrix")); +Object.keys(matrixcs).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === matrixcs[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return matrixcs[key]; + } + }); +}); +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +if (global.__js_sdk_entrypoint) { + throw new Error("Multiple matrix-js-sdk entrypoints detected!"); +} +global.__js_sdk_entrypoint = true; +var _default = matrixcs; +exports.default = _default;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/indexeddb-helpers.js b/comm/chat/protocols/matrix/lib/matrix-sdk/indexeddb-helpers.js new file mode 100644 index 0000000000..1fa33bee4e --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/indexeddb-helpers.js @@ -0,0 +1,56 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.exists = exists; +/* +Copyright 2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Check if an IndexedDB database exists. The only way to do so is to try opening it, so + * we do that and then delete it did not exist before. + * + * @param indexedDB - The `indexedDB` interface + * @param dbName - The database name to test for + * @returns Whether the database exists + */ +function exists(indexedDB, dbName) { + return new Promise((resolve, reject) => { + let exists = true; + const req = indexedDB.open(dbName); + req.onupgradeneeded = () => { + // Since we did not provide an explicit version when opening, this event + // should only fire if the DB did not exist before at any version. + exists = false; + }; + req.onblocked = () => reject(req.error); + req.onsuccess = () => { + const db = req.result; + db.close(); + if (!exists) { + // The DB did not exist before, but has been created as part of this + // existence check. Delete it now to restore previous state. Delete can + // actually take a while to complete in some browsers, so don't wait for + // it. This won't block future open calls that a store might issue next to + // properly set up the DB. + indexedDB.deleteDatabase(dbName); + } + resolve(exists); + }; + req.onerror = () => reject(req.error); + }); +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/indexeddb-worker.js b/comm/chat/protocols/matrix/lib/matrix-sdk/indexeddb-worker.js new file mode 100644 index 0000000000..e1eb076b70 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/indexeddb-worker.js @@ -0,0 +1,12 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +Object.defineProperty(exports, "IndexedDBStoreWorker", { + enumerable: true, + get: function () { + return _indexeddbStoreWorker.IndexedDBStoreWorker; + } +}); +var _indexeddbStoreWorker = require("./store/indexeddb-store-worker");
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/interactive-auth.js b/comm/chat/protocols/matrix/lib/matrix-sdk/interactive-auth.js new file mode 100644 index 0000000000..d810c674dd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/interactive-auth.js @@ -0,0 +1,510 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.NoAuthFlowFoundError = exports.InteractiveAuth = exports.AuthType = void 0; +var _logger = require("./logger"); +var _utils = require("./utils"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2016 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2019 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +const EMAIL_STAGE_TYPE = "m.login.email.identity"; +const MSISDN_STAGE_TYPE = "m.login.msisdn"; + +/** + * Data returned in the body of a 401 response from a UIA endpoint. + * + * @see https://spec.matrix.org/v1.6/client-server-api/#user-interactive-api-in-the-rest-api + */ +let AuthType = /*#__PURE__*/function (AuthType) { + AuthType["Password"] = "m.login.password"; + AuthType["Recaptcha"] = "m.login.recaptcha"; + AuthType["Terms"] = "m.login.terms"; + AuthType["Email"] = "m.login.email.identity"; + AuthType["Msisdn"] = "m.login.msisdn"; + AuthType["Sso"] = "m.login.sso"; + AuthType["SsoUnstable"] = "org.matrix.login.sso"; + AuthType["Dummy"] = "m.login.dummy"; + AuthType["RegistrationToken"] = "m.login.registration_token"; + AuthType["UnstableRegistrationToken"] = "org.matrix.msc3231.login.registration_token"; + return AuthType; +}({}); +/** + * The parameters which are submitted as the `auth` dict in a UIA request + * + * @see https://spec.matrix.org/v1.6/client-server-api/#authentication-types + */ +exports.AuthType = AuthType; +class NoAuthFlowFoundError extends Error { + // eslint-disable-next-line @typescript-eslint/naming-convention, camelcase + constructor(m, required_stages, flows) { + super(m); + this.required_stages = required_stages; + this.flows = flows; + _defineProperty(this, "name", "NoAuthFlowFoundError"); + } +} + +/** + * The type of an application callback to perform the user-interactive bit of UIA. + * + * It is called with a single parameter, `makeRequest`, which is a function which takes the UIA parameters and + * makes the HTTP request. + * + * The generic parameter `T` is the type of the response of the endpoint, once it is eventually successful. + */ +exports.NoAuthFlowFoundError = NoAuthFlowFoundError; +/** + * Abstracts the logic used to drive the interactive auth process. + * + * <p>Components implementing an interactive auth flow should instantiate one of + * these, passing in the necessary callbacks to the constructor. They should + * then call attemptAuth, which will return a promise which will resolve or + * reject when the interactive-auth process completes. + * + * <p>Meanwhile, calls will be made to the startAuthStage and doRequest + * callbacks, and information gathered from the user can be submitted with + * submitAuthDict. + * + * @param opts - options object + */ +class InteractiveAuth { + constructor(opts) { + _defineProperty(this, "matrixClient", void 0); + _defineProperty(this, "inputs", void 0); + _defineProperty(this, "clientSecret", void 0); + _defineProperty(this, "requestCallback", void 0); + _defineProperty(this, "busyChangedCallback", void 0); + _defineProperty(this, "stateUpdatedCallback", void 0); + _defineProperty(this, "requestEmailTokenCallback", void 0); + _defineProperty(this, "supportedStages", void 0); + _defineProperty(this, "data", void 0); + _defineProperty(this, "emailSid", void 0); + _defineProperty(this, "requestingEmailToken", false); + _defineProperty(this, "attemptAuthDeferred", null); + _defineProperty(this, "chosenFlow", null); + _defineProperty(this, "currentStage", null); + _defineProperty(this, "emailAttempt", 1); + // if we are currently trying to submit an auth dict (which includes polling) + // the promise the will resolve/reject when it completes + _defineProperty(this, "submitPromise", null); + /** + * Requests a new email token and sets the email sid for the validation session + */ + _defineProperty(this, "requestEmailToken", async () => { + if (!this.requestingEmailToken) { + _logger.logger.trace("Requesting email token. Attempt: " + this.emailAttempt); + // If we've picked a flow with email auth, we send the email + // now because we want the request to fail as soon as possible + // if the email address is not valid (ie. already taken or not + // registered, depending on what the operation is). + this.requestingEmailToken = true; + try { + const requestTokenResult = await this.requestEmailTokenCallback(this.inputs.emailAddress, this.clientSecret, this.emailAttempt++, this.data.session); + this.emailSid = requestTokenResult.sid; + _logger.logger.trace("Email token request succeeded"); + } finally { + this.requestingEmailToken = false; + } + } else { + _logger.logger.warn("Could not request email token: Already requesting"); + } + }); + this.matrixClient = opts.matrixClient; + this.data = opts.authData || {}; + this.requestCallback = opts.doRequest; + this.busyChangedCallback = opts.busyChanged; + // startAuthStage included for backwards compat + this.stateUpdatedCallback = opts.stateUpdated || opts.startAuthStage; + this.requestEmailTokenCallback = opts.requestEmailToken; + this.inputs = opts.inputs || {}; + if (opts.sessionId) this.data.session = opts.sessionId; + this.clientSecret = opts.clientSecret || this.matrixClient.generateClientSecret(); + this.emailSid = opts.emailSid; + if (opts.supportedStages !== undefined) this.supportedStages = new Set(opts.supportedStages); + } + + /** + * begin the authentication process. + * + * @returns which resolves to the response on success, + * or rejects with the error on failure. Rejects with NoAuthFlowFoundError if + * no suitable authentication flow can be found + */ + attemptAuth() { + // This promise will be quite long-lived and will resolve when the + // request is authenticated and completes successfully. + this.attemptAuthDeferred = (0, _utils.defer)(); + // pluck the promise out now, as doRequest may clear before we return + const promise = this.attemptAuthDeferred.promise; + + // if we have no flows, try a request to acquire the flows + if (!this.data?.flows) { + this.busyChangedCallback?.(true); + // use the existing sessionId, if one is present. + const auth = this.data.session ? { + session: this.data.session + } : null; + this.doRequest(auth).finally(() => { + this.busyChangedCallback?.(false); + }); + } else { + this.startNextAuthStage(); + } + return promise; + } + + /** + * Poll to check if the auth session or current stage has been + * completed out-of-band. If so, the attemptAuth promise will + * be resolved. + */ + async poll() { + if (!this.data.session) return; + // likewise don't poll if there is no auth session in progress + if (!this.attemptAuthDeferred) return; + // if we currently have a request in flight, there's no point making + // another just to check what the status is + if (this.submitPromise) return; + let authDict = {}; + if (this.currentStage == EMAIL_STAGE_TYPE) { + // The email can be validated out-of-band, but we need to provide the + // creds so the HS can go & check it. + if (this.emailSid) { + const creds = { + sid: this.emailSid, + client_secret: this.clientSecret + }; + if (await this.matrixClient.doesServerRequireIdServerParam()) { + const idServerParsedUrl = new URL(this.matrixClient.getIdentityServerUrl()); + creds.id_server = idServerParsedUrl.host; + } + authDict = { + type: EMAIL_STAGE_TYPE, + // TODO: Remove `threepid_creds` once servers support proper UIA + // See https://github.com/matrix-org/synapse/issues/5665 + // See https://github.com/matrix-org/matrix-doc/issues/2220 + threepid_creds: creds, + threepidCreds: creds + }; + } + } + this.submitAuthDict(authDict, true); + } + + /** + * get the auth session ID + * + * @returns session id + */ + getSessionId() { + return this.data?.session; + } + + /** + * get the client secret used for validation sessions + * with the identity server. + * + * @returns client secret + */ + getClientSecret() { + return this.clientSecret; + } + + /** + * get the server params for a given stage + * + * @param loginType - login type for the stage + * @returns any parameters from the server for this stage + */ + getStageParams(loginType) { + return this.data.params?.[loginType]; + } + getChosenFlow() { + return this.chosenFlow; + } + + /** + * submit a new auth dict and fire off the request. This will either + * make attemptAuth resolve/reject, or cause the startAuthStage callback + * to be called for a new stage. + * + * @param authData - new auth dict to send to the server. Should + * include a `type` property denoting the login type, as well as any + * other params for that stage. + * @param background - If true, this request failing will not result + * in the attemptAuth promise being rejected. This can be set to true + * for requests that just poll to see if auth has been completed elsewhere. + */ + async submitAuthDict(authData, background = false) { + if (!this.attemptAuthDeferred) { + throw new Error("submitAuthDict() called before attemptAuth()"); + } + if (!background) { + this.busyChangedCallback?.(true); + } + + // if we're currently trying a request, wait for it to finish + // as otherwise we can get multiple 200 responses which can mean + // things like multiple logins for register requests. + // (but discard any exceptions as we only care when its done, + // not whether it worked or not) + while (this.submitPromise) { + try { + await this.submitPromise; + } catch (e) {} + } + + // use the sessionid from the last request, if one is present. + let auth; + if (this.data.session) { + auth = { + session: this.data.session + }; + Object.assign(auth, authData); + } else { + auth = authData; + } + try { + // NB. the 'background' flag is deprecated by the busyChanged + // callback and is here for backwards compat + this.submitPromise = this.doRequest(auth, background); + await this.submitPromise; + } finally { + this.submitPromise = null; + if (!background) { + this.busyChangedCallback?.(false); + } + } + } + + /** + * Gets the sid for the email validation session + * Specific to m.login.email.identity + * + * @returns The sid of the email auth session + */ + getEmailSid() { + return this.emailSid; + } + + /** + * Sets the sid for the email validation session + * This must be set in order to successfully poll for completion + * of the email validation. + * Specific to m.login.email.identity + * + * @param sid - The sid for the email validation session + */ + setEmailSid(sid) { + this.emailSid = sid; + } + /** + * Fire off a request, and either resolve the promise, or call + * startAuthStage. + * + * @internal + * @param auth - new auth dict, including session id + * @param background - If true, this request is a background poll, so it + * failing will not result in the attemptAuth promise being rejected. + * This can be set to true for requests that just poll to see if auth has + * been completed elsewhere. + */ + async doRequest(auth, background = false) { + try { + const result = await this.requestCallback(auth, background); + this.attemptAuthDeferred.resolve(result); + this.attemptAuthDeferred = null; + } catch (error) { + // sometimes UI auth errors don't come with flows + const errorFlows = error.data?.flows ?? null; + const haveFlows = this.data.flows || Boolean(errorFlows); + if (error.httpStatus !== 401 || !error.data || !haveFlows) { + // doesn't look like an interactive-auth failure. + if (!background) { + this.attemptAuthDeferred?.reject(error); + } else { + // We ignore all failures here (even non-UI auth related ones) + // since we don't want to suddenly fail if the internet connection + // had a blip whilst we were polling + _logger.logger.log("Background poll request failed doing UI auth: ignoring", error); + } + } + if (!error.data) { + error.data = {}; + } + // if the error didn't come with flows, completed flows or session ID, + // copy over the ones we have. Synapse sometimes sends responses without + // any UI auth data (eg. when polling for email validation, if the email + // has not yet been validated). This appears to be a Synapse bug, which + // we workaround here. + if (!error.data.flows && !error.data.completed && !error.data.session) { + error.data.flows = this.data.flows; + error.data.completed = this.data.completed; + error.data.session = this.data.session; + } + this.data = error.data; + try { + this.startNextAuthStage(); + } catch (e) { + this.attemptAuthDeferred.reject(e); + this.attemptAuthDeferred = null; + return; + } + if (!this.emailSid && this.chosenFlow?.stages.includes(AuthType.Email)) { + try { + await this.requestEmailToken(); + // NB. promise is not resolved here - at some point, doRequest + // will be called again and if the user has jumped through all + // the hoops correctly, auth will be complete and the request + // will succeed. + // Also, we should expose the fact that this request has compledted + // so clients can know that the email has actually been sent. + } catch (e) { + // we failed to request an email token, so fail the request. + // This could be due to the email already beeing registered + // (or not being registered, depending on what we're trying + // to do) or it could be a network failure. Either way, pass + // the failure up as the user can't complete auth if we can't + // send the email, for whatever reason. + this.attemptAuthDeferred.reject(e); + this.attemptAuthDeferred = null; + } + } + } + } + + /** + * Pick the next stage and call the callback + * + * @internal + * @throws {@link NoAuthFlowFoundError} If no suitable authentication flow can be found + */ + startNextAuthStage() { + const nextStage = this.chooseStage(); + if (!nextStage) { + throw new Error("No incomplete flows from the server"); + } + this.currentStage = nextStage; + if (nextStage === AuthType.Dummy) { + this.submitAuthDict({ + type: "m.login.dummy" + }); + return; + } + if (this.data?.errcode || this.data?.error) { + this.stateUpdatedCallback(nextStage, { + errcode: this.data?.errcode || "", + error: this.data?.error || "" + }); + return; + } + this.stateUpdatedCallback(nextStage, nextStage === EMAIL_STAGE_TYPE ? { + emailSid: this.emailSid + } : {}); + } + + /** + * Pick the next auth stage + * + * @internal + * @returns login type + * @throws {@link NoAuthFlowFoundError} If no suitable authentication flow can be found + */ + chooseStage() { + if (this.chosenFlow === null) { + this.chosenFlow = this.chooseFlow(); + } + _logger.logger.log("Active flow => %s", JSON.stringify(this.chosenFlow)); + const nextStage = this.firstUncompletedStage(this.chosenFlow); + _logger.logger.log("Next stage: %s", nextStage); + return nextStage; + } + + // Returns a low number for flows we consider best. Counts increase for longer flows and even more so + // for flows which contain stages not listed in `supportedStages`. + scoreFlow(flow) { + let score = flow.stages.length; + if (this.supportedStages !== undefined) { + // Add 10 points to the score for each unsupported stage in the flow. + score += flow.stages.filter(stage => !this.supportedStages.has(stage)).length * 10; + } + return score; + } + + /** + * Pick one of the flows from the returned list + * If a flow using all of the inputs is found, it will + * be returned, otherwise, null will be returned. + * + * Only flows using all given inputs are chosen because it + * is likely to be surprising if the user provides a + * credential and it is not used. For example, for registration, + * this could result in the email not being used which would leave + * the account with no means to reset a password. + * + * @internal + * @returns flow + * @throws {@link NoAuthFlowFoundError} If no suitable authentication flow can be found + */ + chooseFlow() { + const flows = this.data.flows || []; + + // we've been given an email or we've already done an email part + const haveEmail = Boolean(this.inputs.emailAddress) || Boolean(this.emailSid); + const haveMsisdn = Boolean(this.inputs.phoneCountry) && Boolean(this.inputs.phoneNumber); + + // Flows are not represented in a significant order, so we can choose any we support best + // Sort flows based on how many unsupported stages they contain ascending + flows.sort((a, b) => this.scoreFlow(a) - this.scoreFlow(b)); + for (const flow of flows) { + let flowHasEmail = false; + let flowHasMsisdn = false; + for (const stage of flow.stages) { + if (stage === EMAIL_STAGE_TYPE) { + flowHasEmail = true; + } else if (stage == MSISDN_STAGE_TYPE) { + flowHasMsisdn = true; + } + } + if (flowHasEmail == haveEmail && flowHasMsisdn == haveMsisdn) { + return flow; + } + } + const requiredStages = []; + if (haveEmail) requiredStages.push(EMAIL_STAGE_TYPE); + if (haveMsisdn) requiredStages.push(MSISDN_STAGE_TYPE); + // Throw an error with a fairly generic description, but with more + // information such that the app can give a better one if so desired. + throw new NoAuthFlowFoundError("No appropriate authentication flow found", requiredStages, flows); + } + + /** + * Get the first uncompleted stage in the given flow + * + * @internal + * @returns login type + */ + firstUncompletedStage(flow) { + const completed = this.data.completed || []; + return flow.stages.find(stageType => !completed.includes(stageType)); + } +} +exports.InteractiveAuth = InteractiveAuth;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/logger.js b/comm/chat/protocols/matrix/lib/matrix-sdk/logger.js new file mode 100644 index 0000000000..5946410754 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/logger.js @@ -0,0 +1,80 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.logger = void 0; +var _loglevel = _interopRequireDefault(require("loglevel")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +/* +Copyright 2018 André Jaenisch +Copyright 2019, 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// This is to demonstrate, that you can use any namespace you want. +// Namespaces allow you to turn on/off the logging for specific parts of the +// application. +// An idea would be to control this via an environment variable (on Node.js). +// See https://www.npmjs.com/package/debug to see how this could be implemented +// Part of #332 is introducing a logging library in the first place. +const DEFAULT_NAMESPACE = "matrix"; + +// because rageshakes in react-sdk hijack the console log, also at module load time, +// initializing the logger here races with the initialization of rageshakes. +// to avoid the issue, we override the methodFactory of loglevel that binds to the +// console methods at initialization time by a factory that looks up the console methods +// when logging so we always get the current value of console methods. +_loglevel.default.methodFactory = function (methodName, logLevel, loggerName) { + return function (...args) { + /* eslint-disable @typescript-eslint/no-invalid-this */ + if (this.prefix) { + args.unshift(this.prefix); + } + /* eslint-enable @typescript-eslint/no-invalid-this */ + const supportedByConsole = methodName === "error" || methodName === "warn" || methodName === "trace" || methodName === "info"; + /* eslint-disable no-console */ + if (supportedByConsole) { + return console[methodName](...args); + } else { + return console.log(...args); + } + /* eslint-enable no-console */ + }; +}; + +/** + * Drop-in replacement for `console` using {@link https://www.npmjs.com/package/loglevel|loglevel}. + * Can be tailored down to specific use cases if needed. + */ +const logger = _loglevel.default.getLogger(DEFAULT_NAMESPACE); +exports.logger = logger; +logger.setLevel(_loglevel.default.levels.DEBUG, false); +function extendLogger(logger) { + logger.withPrefix = function (prefix) { + const existingPrefix = this.prefix || ""; + return getPrefixedLogger(existingPrefix + prefix); + }; +} +extendLogger(logger); +function getPrefixedLogger(prefix) { + const prefixLogger = _loglevel.default.getLogger(`${DEFAULT_NAMESPACE}-${prefix}`); + if (prefixLogger.prefix !== prefix) { + // Only do this setup work the first time through, as loggers are saved by name. + extendLogger(prefixLogger); + prefixLogger.prefix = prefix; + prefixLogger.setLevel(_loglevel.default.levels.DEBUG, false); + } + return prefixLogger; +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/matrix.js b/comm/chat/protocols/matrix/lib/matrix-sdk/matrix.js new file mode 100644 index 0000000000..0e01b8eaeb --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/matrix.js @@ -0,0 +1,546 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +var _exportNames = { + setCryptoStoreFactory: true, + createClient: true, + createRoomWidgetClient: true, + ContentHelpers: true, + SecretStorage: true, + createNewMatrixCall: true, + GroupCallEvent: true, + GroupCallIntent: true, + GroupCallState: true, + GroupCallType: true, + CryptoEvent: true, + DeviceVerificationStatus: true, + Crypto: true +}; +exports.Crypto = exports.ContentHelpers = void 0; +Object.defineProperty(exports, "CryptoEvent", { + enumerable: true, + get: function () { + return _crypto.CryptoEvent; + } +}); +Object.defineProperty(exports, "DeviceVerificationStatus", { + enumerable: true, + get: function () { + return _Crypto.DeviceVerificationStatus; + } +}); +Object.defineProperty(exports, "GroupCallEvent", { + enumerable: true, + get: function () { + return _groupCall.GroupCallEvent; + } +}); +Object.defineProperty(exports, "GroupCallIntent", { + enumerable: true, + get: function () { + return _groupCall.GroupCallIntent; + } +}); +Object.defineProperty(exports, "GroupCallState", { + enumerable: true, + get: function () { + return _groupCall.GroupCallState; + } +}); +Object.defineProperty(exports, "GroupCallType", { + enumerable: true, + get: function () { + return _groupCall.GroupCallType; + } +}); +exports.SecretStorage = void 0; +exports.createClient = createClient; +Object.defineProperty(exports, "createNewMatrixCall", { + enumerable: true, + get: function () { + return _call.createNewMatrixCall; + } +}); +exports.createRoomWidgetClient = createRoomWidgetClient; +exports.setCryptoStoreFactory = setCryptoStoreFactory; +var _memoryCryptoStore = require("./crypto/store/memory-crypto-store"); +Object.keys(_memoryCryptoStore).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _memoryCryptoStore[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _memoryCryptoStore[key]; + } + }); +}); +var _memory = require("./store/memory"); +Object.keys(_memory).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _memory[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _memory[key]; + } + }); +}); +var _scheduler = require("./scheduler"); +Object.keys(_scheduler).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _scheduler[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _scheduler[key]; + } + }); +}); +var _client = require("./client"); +Object.keys(_client).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _client[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _client[key]; + } + }); +}); +var _embedded = require("./embedded"); +Object.keys(_embedded).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _embedded[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _embedded[key]; + } + }); +}); +var _httpApi = require("./http-api"); +Object.keys(_httpApi).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _httpApi[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _httpApi[key]; + } + }); +}); +var _autodiscovery = require("./autodiscovery"); +Object.keys(_autodiscovery).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _autodiscovery[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _autodiscovery[key]; + } + }); +}); +var _syncAccumulator = require("./sync-accumulator"); +Object.keys(_syncAccumulator).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _syncAccumulator[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _syncAccumulator[key]; + } + }); +}); +var _errors = require("./errors"); +Object.keys(_errors).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _errors[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _errors[key]; + } + }); +}); +var _beacon = require("./models/beacon"); +Object.keys(_beacon).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _beacon[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _beacon[key]; + } + }); +}); +var _event = require("./models/event"); +Object.keys(_event).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _event[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _event[key]; + } + }); +}); +var _room = require("./models/room"); +Object.keys(_room).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _room[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _room[key]; + } + }); +}); +var _eventTimeline = require("./models/event-timeline"); +Object.keys(_eventTimeline).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _eventTimeline[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _eventTimeline[key]; + } + }); +}); +var _eventTimelineSet = require("./models/event-timeline-set"); +Object.keys(_eventTimelineSet).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _eventTimelineSet[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _eventTimelineSet[key]; + } + }); +}); +var _poll = require("./models/poll"); +Object.keys(_poll).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _poll[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _poll[key]; + } + }); +}); +var _roomMember = require("./models/room-member"); +Object.keys(_roomMember).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _roomMember[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _roomMember[key]; + } + }); +}); +var _roomState = require("./models/room-state"); +Object.keys(_roomState).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _roomState[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _roomState[key]; + } + }); +}); +var _typedEventEmitter = require("./models/typed-event-emitter"); +Object.keys(_typedEventEmitter).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _typedEventEmitter[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _typedEventEmitter[key]; + } + }); +}); +var _user = require("./models/user"); +Object.keys(_user).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _user[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _user[key]; + } + }); +}); +var _device = require("./models/device"); +Object.keys(_device).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _device[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _device[key]; + } + }); +}); +var _filter = require("./filter"); +Object.keys(_filter).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _filter[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _filter[key]; + } + }); +}); +var _timelineWindow = require("./timeline-window"); +Object.keys(_timelineWindow).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _timelineWindow[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _timelineWindow[key]; + } + }); +}); +var _interactiveAuth = require("./interactive-auth"); +Object.keys(_interactiveAuth).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _interactiveAuth[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _interactiveAuth[key]; + } + }); +}); +var _serviceTypes = require("./service-types"); +Object.keys(_serviceTypes).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _serviceTypes[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _serviceTypes[key]; + } + }); +}); +var _indexeddb = require("./store/indexeddb"); +Object.keys(_indexeddb).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _indexeddb[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _indexeddb[key]; + } + }); +}); +var _indexeddbCryptoStore = require("./crypto/store/indexeddb-crypto-store"); +Object.keys(_indexeddbCryptoStore).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _indexeddbCryptoStore[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _indexeddbCryptoStore[key]; + } + }); +}); +var _contentRepo = require("./content-repo"); +Object.keys(_contentRepo).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _contentRepo[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _contentRepo[key]; + } + }); +}); +var _event2 = require("./@types/event"); +Object.keys(_event2).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _event2[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _event2[key]; + } + }); +}); +var _PushRules = require("./@types/PushRules"); +Object.keys(_PushRules).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _PushRules[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _PushRules[key]; + } + }); +}); +var _partials = require("./@types/partials"); +Object.keys(_partials).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _partials[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _partials[key]; + } + }); +}); +var _requests = require("./@types/requests"); +Object.keys(_requests).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _requests[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _requests[key]; + } + }); +}); +var _search = require("./@types/search"); +Object.keys(_search).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _search[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _search[key]; + } + }); +}); +var _roomSummary = require("./models/room-summary"); +Object.keys(_roomSummary).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _roomSummary[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _roomSummary[key]; + } + }); +}); +var _ContentHelpers = _interopRequireWildcard(require("./content-helpers")); +exports.ContentHelpers = _ContentHelpers; +var _SecretStorage = _interopRequireWildcard(require("./secret-storage")); +exports.SecretStorage = _SecretStorage; +var _call = require("./webrtc/call"); +var _groupCall = require("./webrtc/groupCall"); +var _crypto = require("./crypto"); +var _Crypto = _interopRequireWildcard(require("./crypto-api")); +exports.Crypto = _Crypto; +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } +/* +Copyright 2015-2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// used to be located here + +/** + * Types supporting cryptography. + * + * The most important is {@link Crypto.CryptoApi}, an instance of which can be retrieved via + * {@link MatrixClient.getCrypto}. + */ + +/** + * Backwards compatibility re-export + * @internal + * @deprecated use {@link Crypto.CryptoApi} + */ + +/** + * Backwards compatibility re-export + * @internal + * @deprecated use {@link Crypto.DeviceVerificationStatus} + */ + +let cryptoStoreFactory = () => new _memoryCryptoStore.MemoryCryptoStore(); + +/** + * Configure a different factory to be used for creating crypto stores + * + * @param fac - a function which will return a new `CryptoStore` + */ +function setCryptoStoreFactory(fac) { + cryptoStoreFactory = fac; +} +function amendClientOpts(opts) { + opts.store = opts.store ?? new _memory.MemoryStore({ + localStorage: global.localStorage + }); + opts.scheduler = opts.scheduler ?? new _scheduler.MatrixScheduler(); + opts.cryptoStore = opts.cryptoStore ?? cryptoStoreFactory(); + return opts; +} + +/** + * Construct a Matrix Client. Similar to {@link MatrixClient} + * except that the 'request', 'store' and 'scheduler' dependencies are satisfied. + * @param opts - The configuration options for this client. These configuration + * options will be passed directly to {@link MatrixClient}. + * + * @returns A new matrix client. + * @see {@link MatrixClient} for the full list of options for + * `opts`. + */ +function createClient(opts) { + return new _client.MatrixClient(amendClientOpts(opts)); +} +function createRoomWidgetClient(widgetApi, capabilities, roomId, opts) { + return new _embedded.RoomWidgetClient(widgetApi, capabilities, roomId, amendClientOpts(opts)); +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/MSC3089Branch.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/MSC3089Branch.js new file mode 100644 index 0000000000..61560cde27 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/MSC3089Branch.js @@ -0,0 +1,227 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.MSC3089Branch = void 0; +var _event = require("../@types/event"); +var _eventTimeline = require("./event-timeline"); +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/** + * Represents a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) branch - a reference + * to a file (leaf) in the tree. Note that this is UNSTABLE and subject to breaking changes + * without notice. + */ +class MSC3089Branch { + constructor(client, indexEvent, directory) { + this.client = client; + this.indexEvent = indexEvent; + this.directory = directory; + } // Nothing to do + + /** + * The file ID. + */ + get id() { + const stateKey = this.indexEvent.getStateKey(); + if (!stateKey) { + throw new Error("State key not found for branch"); + } + return stateKey; + } + + /** + * Whether this branch is active/valid. + */ + get isActive() { + return this.indexEvent.getContent()["active"] === true; + } + + /** + * Version for the file, one-indexed. + */ + get version() { + return this.indexEvent.getContent()["version"] ?? 1; + } + get roomId() { + return this.indexEvent.getRoomId(); + } + + /** + * Deletes the file from the tree, including all prior edits/versions. + * @returns Promise which resolves when complete. + */ + async delete() { + await this.client.sendStateEvent(this.roomId, _event.UNSTABLE_MSC3089_BRANCH.name, {}, this.id); + await this.client.redactEvent(this.roomId, this.id); + const nextVersion = (await this.getVersionHistory())[1]; // [0] will be us + if (nextVersion) await nextVersion.delete(); // implicit recursion + } + + /** + * Gets the name for this file. + * @returns The name, or "Unnamed File" if unknown. + */ + getName() { + return this.indexEvent.getContent()["name"] || "Unnamed File"; + } + + /** + * Sets the name for this file. + * @param name - The new name for this file. + * @returns Promise which resolves when complete. + */ + async setName(name) { + await this.client.sendStateEvent(this.roomId, _event.UNSTABLE_MSC3089_BRANCH.name, _objectSpread(_objectSpread({}, this.indexEvent.getContent()), {}, { + name: name + }), this.id); + } + + /** + * Gets whether or not a file is locked. + * @returns True if locked, false otherwise. + */ + isLocked() { + return this.indexEvent.getContent()["locked"] || false; + } + + /** + * Sets a file as locked or unlocked. + * @param locked - True to lock the file, false otherwise. + * @returns Promise which resolves when complete. + */ + async setLocked(locked) { + await this.client.sendStateEvent(this.roomId, _event.UNSTABLE_MSC3089_BRANCH.name, _objectSpread(_objectSpread({}, this.indexEvent.getContent()), {}, { + locked: locked + }), this.id); + } + + /** + * Gets information about the file needed to download it. + * @returns Information about the file. + */ + async getFileInfo() { + const event = await this.getFileEvent(); + const file = event.getOriginalContent()["file"]; + const httpUrl = this.client.mxcUrlToHttp(file["url"]); + if (!httpUrl) { + throw new Error(`No HTTP URL available for ${file["url"]}`); + } + return { + info: file, + httpUrl: httpUrl + }; + } + + /** + * Gets the event the file points to. + * @returns Promise which resolves to the file's event. + */ + async getFileEvent() { + const room = this.client.getRoom(this.roomId); + if (!room) throw new Error("Unknown room"); + let event = room.getUnfilteredTimelineSet().findEventById(this.id); + + // keep scrolling back if needed until we find the event or reach the start of the room: + while (!event && room.getLiveTimeline().getState(_eventTimeline.EventTimeline.BACKWARDS).paginationToken) { + await this.client.scrollback(room, 100); + event = room.getUnfilteredTimelineSet().findEventById(this.id); + } + if (!event) throw new Error("Failed to find event"); + + // Sometimes the event isn't decrypted for us, so do that. We specifically set `emit: true` + // to ensure that the relations system in the sdk will function. + await this.client.decryptEventIfNeeded(event, { + emit: true, + isRetry: true + }); + return event; + } + + /** + * Creates a new version of this file with contents in a type that is compatible with MatrixClient.uploadContent(). + * @param name - The name of the file. + * @param encryptedContents - The encrypted contents. + * @param info - The encrypted file information. + * @param additionalContent - Optional event content fields to include in the message. + * @returns Promise which resolves to the file event's sent response. + */ + async createNewVersion(name, encryptedContents, info, additionalContent) { + const fileEventResponse = await this.directory.createFile(name, encryptedContents, info, _objectSpread(_objectSpread({}, additionalContent ?? {}), {}, { + "m.new_content": true, + "m.relates_to": { + rel_type: _event.RelationType.Replace, + event_id: this.id + } + })); + + // Update the version of the new event + await this.client.sendStateEvent(this.roomId, _event.UNSTABLE_MSC3089_BRANCH.name, { + active: true, + name: name, + version: this.version + 1 + }, fileEventResponse["event_id"]); + + // Deprecate ourselves + await this.client.sendStateEvent(this.roomId, _event.UNSTABLE_MSC3089_BRANCH.name, _objectSpread(_objectSpread({}, this.indexEvent.getContent()), {}, { + active: false + }), this.id); + return fileEventResponse; + } + + /** + * Gets the file's version history, starting at this file. + * @returns Promise which resolves to the file's version history, with the + * first element being the current version and the last element being the first version. + */ + async getVersionHistory() { + const fileHistory = []; + fileHistory.push(this); // start with ourselves + + const room = this.client.getRoom(this.roomId); + if (!room) throw new Error("Invalid or unknown room"); + + // Clone the timeline to reverse it, getting most-recent-first ordering, hopefully + // shortening the awful loop below. Without the clone, we can unintentionally mutate + // the timeline. + const timelineEvents = [...room.getLiveTimeline().getEvents()].reverse(); + + // XXX: This is a very inefficient search, but it's the best we can do with the + // relations structure we have in the SDK. As of writing, it is not worth the + // investment in improving the structure. + let childEvent; + let parentEvent = await this.getFileEvent(); + do { + childEvent = timelineEvents.find(e => e.replacingEventId() === parentEvent.getId()); + if (childEvent) { + const branch = this.directory.getFile(childEvent.getId()); + if (branch) { + fileHistory.push(branch); + parentEvent = childEvent; + } else { + break; // prevent infinite loop + } + } + } while (childEvent); + return fileHistory; + } +} +exports.MSC3089Branch = MSC3089Branch;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/MSC3089TreeSpace.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/MSC3089TreeSpace.js new file mode 100644 index 0000000000..e73ef3f12a --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/MSC3089TreeSpace.js @@ -0,0 +1,508 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.TreePermissions = exports.MSC3089TreeSpace = exports.DEFAULT_TREE_POWER_LEVELS_TEMPLATE = void 0; +var _pRetry = _interopRequireDefault(require("p-retry")); +var _event = require("../@types/event"); +var _logger = require("../logger"); +var _utils = require("../utils"); +var _MSC3089Branch = require("./MSC3089Branch"); +var _megolm = require("../crypto/algorithms/megolm"); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/** + * The recommended defaults for a tree space's power levels. Note that this + * is UNSTABLE and subject to breaking changes without notice. + */ +const DEFAULT_TREE_POWER_LEVELS_TEMPLATE = { + // Owner + invite: 100, + kick: 100, + ban: 100, + // Editor + redact: 50, + state_default: 50, + events_default: 50, + // Viewer + users_default: 0, + // Mixed + events: { + [_event.EventType.RoomPowerLevels]: 100, + [_event.EventType.RoomHistoryVisibility]: 100, + [_event.EventType.RoomTombstone]: 100, + [_event.EventType.RoomEncryption]: 100, + [_event.EventType.RoomName]: 50, + [_event.EventType.RoomMessage]: 50, + [_event.EventType.RoomMessageEncrypted]: 50, + [_event.EventType.Sticker]: 50 + }, + users: {} // defined by calling code +}; + +/** + * Ease-of-use representation for power levels represented as simple roles. + * Note that this is UNSTABLE and subject to breaking changes without notice. + */ +exports.DEFAULT_TREE_POWER_LEVELS_TEMPLATE = DEFAULT_TREE_POWER_LEVELS_TEMPLATE; +let TreePermissions = /*#__PURE__*/function (TreePermissions) { + TreePermissions["Viewer"] = "viewer"; + TreePermissions["Editor"] = "editor"; + TreePermissions["Owner"] = "owner"; + return TreePermissions; +}({}); // "Admin" or PL100 +/** + * Represents a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) + * file tree Space. Note that this is UNSTABLE and subject to breaking changes + * without notice. + */ +exports.TreePermissions = TreePermissions; +class MSC3089TreeSpace { + constructor(client, roomId) { + this.client = client; + this.roomId = roomId; + _defineProperty(this, "room", void 0); + this.room = this.client.getRoom(this.roomId); + if (!this.room) throw new Error("Unknown room"); + } + + /** + * Syntactic sugar for room ID of the Space. + */ + get id() { + return this.roomId; + } + + /** + * Whether or not this is a top level space. + */ + get isTopLevel() { + // XXX: This is absolutely not how you find out if the space is top level + // but is safe for a managed usecase like we offer in the SDK. + const parentEvents = this.room.currentState.getStateEvents(_event.EventType.SpaceParent); + if (!parentEvents?.length) return true; + return parentEvents.every(e => !e.getContent()?.["via"]); + } + + /** + * Sets the name of the tree space. + * @param name - The new name for the space. + * @returns Promise which resolves when complete. + */ + async setName(name) { + await this.client.sendStateEvent(this.roomId, _event.EventType.RoomName, { + name + }, ""); + } + + /** + * Invites a user to the tree space. They will be given the default Viewer + * permission level unless specified elsewhere. + * @param userId - The user ID to invite. + * @param andSubspaces - True (default) to invite the user to all + * directories/subspaces too, recursively. + * @param shareHistoryKeys - True (default) to share encryption keys + * with the invited user. This will allow them to decrypt the events (files) + * in the tree. Keys will not be shared if the room is lacking appropriate + * history visibility (by default, history visibility is "shared" in trees, + * which is an appropriate visibility for these purposes). + * @returns Promise which resolves when complete. + */ + async invite(userId, andSubspaces = true, shareHistoryKeys = true) { + const promises = [this.retryInvite(userId)]; + if (andSubspaces) { + promises.push(...this.getDirectories().map(d => d.invite(userId, andSubspaces, shareHistoryKeys))); + } + return Promise.all(promises).then(() => { + // Note: key sharing is default on because for file trees it is relatively important that the invite + // target can actually decrypt the files. The implied use case is that by inviting a user to the tree + // it means the sender would like the receiver to view/download the files contained within, much like + // sharing a folder in other circles. + if (shareHistoryKeys && (0, _megolm.isRoomSharedHistory)(this.room)) { + // noinspection JSIgnoredPromiseFromCall - we aren't concerned as much if this fails. + this.client.sendSharedHistoryKeys(this.roomId, [userId]); + } + }); + } + retryInvite(userId) { + return (0, _utils.simpleRetryOperation)(async () => { + await this.client.invite(this.roomId, userId).catch(e => { + // We don't want to retry permission errors forever... + if (e?.errcode === "M_FORBIDDEN") { + throw new _pRetry.default.AbortError(e); + } + throw e; + }); + }); + } + + /** + * Sets the permissions of a user to the given role. Note that if setting a user + * to Owner then they will NOT be able to be demoted. If the user does not have + * permission to change the power level of the target, an error will be thrown. + * @param userId - The user ID to change the role of. + * @param role - The role to assign. + * @returns Promise which resolves when complete. + */ + async setPermissions(userId, role) { + const currentPls = this.room.currentState.getStateEvents(_event.EventType.RoomPowerLevels, ""); + if (Array.isArray(currentPls)) throw new Error("Unexpected return type for power levels"); + const pls = currentPls?.getContent() || {}; + const viewLevel = pls["users_default"] || 0; + const editLevel = pls["events_default"] || 50; + const adminLevel = pls["events"]?.[_event.EventType.RoomPowerLevels] || 100; + const users = pls["users"] || {}; + switch (role) { + case TreePermissions.Viewer: + users[userId] = viewLevel; + break; + case TreePermissions.Editor: + users[userId] = editLevel; + break; + case TreePermissions.Owner: + users[userId] = adminLevel; + break; + default: + throw new Error("Invalid role: " + role); + } + pls["users"] = users; + await this.client.sendStateEvent(this.roomId, _event.EventType.RoomPowerLevels, pls, ""); + } + + /** + * Gets the current permissions of a user. Note that any users missing explicit permissions (or not + * in the space) will be considered Viewers. Appropriate membership checks need to be performed + * elsewhere. + * @param userId - The user ID to check permissions of. + * @returns The permissions for the user, defaulting to Viewer. + */ + getPermissions(userId) { + const currentPls = this.room.currentState.getStateEvents(_event.EventType.RoomPowerLevels, ""); + if (Array.isArray(currentPls)) throw new Error("Unexpected return type for power levels"); + const pls = currentPls?.getContent() || {}; + const viewLevel = pls["users_default"] || 0; + const editLevel = pls["events_default"] || 50; + const adminLevel = pls["events"]?.[_event.EventType.RoomPowerLevels] || 100; + const userLevel = pls["users"]?.[userId] || viewLevel; + if (userLevel >= adminLevel) return TreePermissions.Owner; + if (userLevel >= editLevel) return TreePermissions.Editor; + return TreePermissions.Viewer; + } + + /** + * Creates a directory under this tree space, represented as another tree space. + * @param name - The name for the directory. + * @returns Promise which resolves to the created directory. + */ + async createDirectory(name) { + const directory = await this.client.unstableCreateFileTree(name); + await this.client.sendStateEvent(this.roomId, _event.EventType.SpaceChild, { + via: [this.client.getDomain()] + }, directory.roomId); + await this.client.sendStateEvent(directory.roomId, _event.EventType.SpaceParent, { + via: [this.client.getDomain()] + }, this.roomId); + return directory; + } + + /** + * Gets a list of all known immediate subdirectories to this tree space. + * @returns The tree spaces (directories). May be empty, but not null. + */ + getDirectories() { + const trees = []; + const children = this.room.currentState.getStateEvents(_event.EventType.SpaceChild); + for (const child of children) { + try { + const stateKey = child.getStateKey(); + if (stateKey) { + const tree = this.client.unstableGetFileTreeSpace(stateKey); + if (tree) trees.push(tree); + } + } catch (e) { + _logger.logger.warn("Unable to create tree space instance for listing. Are we joined?", e); + } + } + return trees; + } + + /** + * Gets a subdirectory of a given ID under this tree space. Note that this will not recurse + * into children and instead only look one level deep. + * @param roomId - The room ID (directory ID) to find. + * @returns The directory, or undefined if not found. + */ + getDirectory(roomId) { + return this.getDirectories().find(r => r.roomId === roomId); + } + + /** + * Deletes the tree, kicking all members and deleting **all subdirectories**. + * @returns Promise which resolves when complete. + */ + async delete() { + const subdirectories = this.getDirectories(); + for (const dir of subdirectories) { + await dir.delete(); + } + const kickMemberships = ["invite", "knock", "join"]; + const members = this.room.currentState.getStateEvents(_event.EventType.RoomMember); + for (const member of members) { + const isNotUs = member.getStateKey() !== this.client.getUserId(); + if (isNotUs && kickMemberships.includes(member.getContent().membership)) { + const stateKey = member.getStateKey(); + if (!stateKey) { + throw new Error("State key not found for branch"); + } + await this.client.kick(this.roomId, stateKey, "Room deleted"); + } + } + await this.client.leave(this.roomId); + } + getOrderedChildren(children) { + const ordered = children.map(c => ({ + roomId: c.getStateKey(), + order: c.getContent()["order"] + })).filter(c => c.roomId); + ordered.sort((a, b) => { + if (a.order && !b.order) { + return -1; + } else if (!a.order && b.order) { + return 1; + } else if (!a.order && !b.order) { + const roomA = this.client.getRoom(a.roomId); + const roomB = this.client.getRoom(b.roomId); + if (!roomA || !roomB) { + // just don't bother trying to do more partial sorting + return (0, _utils.lexicographicCompare)(a.roomId, b.roomId); + } + const createTsA = roomA.currentState.getStateEvents(_event.EventType.RoomCreate, "")?.getTs() ?? 0; + const createTsB = roomB.currentState.getStateEvents(_event.EventType.RoomCreate, "")?.getTs() ?? 0; + if (createTsA === createTsB) { + return (0, _utils.lexicographicCompare)(a.roomId, b.roomId); + } + return createTsA - createTsB; + } else { + // both not-null orders + return (0, _utils.lexicographicCompare)(a.order, b.order); + } + }); + return ordered; + } + getParentRoom() { + const parents = this.room.currentState.getStateEvents(_event.EventType.SpaceParent); + const parent = parents[0]; // XXX: Wild assumption + if (!parent) throw new Error("Expected to have a parent in a non-top level space"); + + // XXX: We are assuming the parent is a valid tree space. + // We probably don't need to validate the parent room state for this usecase though. + const stateKey = parent.getStateKey(); + if (!stateKey) throw new Error("No state key found for parent"); + const parentRoom = this.client.getRoom(stateKey); + if (!parentRoom) throw new Error("Unable to locate room for parent"); + return parentRoom; + } + + /** + * Gets the current order index for this directory. Note that if this is the top level space + * then -1 will be returned. + * @returns The order index of this space. + */ + getOrder() { + if (this.isTopLevel) return -1; + const parentRoom = this.getParentRoom(); + const children = parentRoom.currentState.getStateEvents(_event.EventType.SpaceChild); + const ordered = this.getOrderedChildren(children); + return ordered.findIndex(c => c.roomId === this.roomId); + } + + /** + * Sets the order index for this directory within its parent. Note that if this is a top level + * space then an error will be thrown. -1 can be used to move the child to the start, and numbers + * larger than the number of children can be used to move the child to the end. + * @param index - The new order index for this space. + * @returns Promise which resolves when complete. + * @throws Throws if this is a top level space. + */ + async setOrder(index) { + if (this.isTopLevel) throw new Error("Cannot set order of top level spaces currently"); + const parentRoom = this.getParentRoom(); + const children = parentRoom.currentState.getStateEvents(_event.EventType.SpaceChild); + const ordered = this.getOrderedChildren(children); + index = Math.max(Math.min(index, ordered.length - 1), 0); + const currentIndex = this.getOrder(); + const movingUp = currentIndex < index; + if (movingUp && index === ordered.length - 1) { + index--; + } else if (!movingUp && index === 0) { + index++; + } + const prev = ordered[movingUp ? index : index - 1]; + const next = ordered[movingUp ? index + 1 : index]; + let newOrder = _utils.DEFAULT_ALPHABET[0]; + let ensureBeforeIsSane = false; + if (!prev) { + // Move to front + if (next?.order) { + newOrder = (0, _utils.prevString)(next.order); + } + } else if (index === ordered.length - 1) { + // Move to back + if (next?.order) { + newOrder = (0, _utils.nextString)(next.order); + } + } else { + // Move somewhere in the middle + const startOrder = prev?.order; + const endOrder = next?.order; + if (startOrder && endOrder) { + if (startOrder === endOrder) { + // Error case: just move +1 to break out of awful math + newOrder = (0, _utils.nextString)(startOrder); + } else { + newOrder = (0, _utils.averageBetweenStrings)(startOrder, endOrder); + } + } else { + if (startOrder) { + // We're at the end (endOrder is null, so no explicit order) + newOrder = (0, _utils.nextString)(startOrder); + } else if (endOrder) { + // We're at the start (startOrder is null, so nothing before us) + newOrder = (0, _utils.prevString)(endOrder); + } else { + // Both points are unknown. We're likely in a range where all the children + // don't have particular order values, so we may need to update them too. + // The other possibility is there's only us as a child, but we should have + // shown up in the other states. + ensureBeforeIsSane = true; + } + } + } + if (ensureBeforeIsSane) { + // We were asked by the order algorithm to prepare the moving space for a landing + // in the undefined order part of the order array, which means we need to update the + // spaces that come before it with a stable order value. + let lastOrder; + for (let i = 0; i <= index; i++) { + const target = ordered[i]; + if (i === 0) { + lastOrder = target.order; + } + if (!target.order) { + // XXX: We should be creating gaps to avoid conflicts + lastOrder = lastOrder ? (0, _utils.nextString)(lastOrder) : _utils.DEFAULT_ALPHABET[0]; + const currentChild = parentRoom.currentState.getStateEvents(_event.EventType.SpaceChild, target.roomId); + const content = currentChild?.getContent() ?? { + via: [this.client.getDomain()] + }; + await this.client.sendStateEvent(parentRoom.roomId, _event.EventType.SpaceChild, _objectSpread(_objectSpread({}, content), {}, { + order: lastOrder + }), target.roomId); + } else { + lastOrder = target.order; + } + } + if (lastOrder) { + newOrder = (0, _utils.nextString)(lastOrder); + } + } + + // TODO: Deal with order conflicts by reordering + + // Now we can finally update our own order state + const currentChild = parentRoom.currentState.getStateEvents(_event.EventType.SpaceChild, this.roomId); + const content = currentChild?.getContent() ?? { + via: [this.client.getDomain()] + }; + await this.client.sendStateEvent(parentRoom.roomId, _event.EventType.SpaceChild, _objectSpread(_objectSpread({}, content), {}, { + // TODO: Safely constrain to 50 character limit required by spaces. + order: newOrder + }), this.roomId); + } + + /** + * Creates (uploads) a new file to this tree. The file must have already been encrypted for the room. + * The file contents are in a type that is compatible with MatrixClient.uploadContent(). + * @param name - The name of the file. + * @param encryptedContents - The encrypted contents. + * @param info - The encrypted file information. + * @param additionalContent - Optional event content fields to include in the message. + * @returns Promise which resolves to the file event's sent response. + */ + async createFile(name, encryptedContents, info, additionalContent) { + const { + content_uri: mxc + } = await this.client.uploadContent(encryptedContents, { + includeFilename: false + }); + info.url = mxc; + const fileContent = { + msgtype: _event.MsgType.File, + body: name, + url: mxc, + file: info + }; + additionalContent = additionalContent ?? {}; + if (additionalContent["m.new_content"]) { + // We do the right thing according to the spec, but due to how relations are + // handled we also end up duplicating this information to the regular `content` + // as well. + additionalContent["m.new_content"] = fileContent; + } + const res = await this.client.sendMessage(this.roomId, _objectSpread(_objectSpread(_objectSpread({}, additionalContent), fileContent), {}, { + [_event.UNSTABLE_MSC3089_LEAF.name]: {} + })); + await this.client.sendStateEvent(this.roomId, _event.UNSTABLE_MSC3089_BRANCH.name, { + active: true, + name: name + }, res["event_id"]); + return res; + } + + /** + * Retrieves a file from the tree. + * @param fileEventId - The event ID of the file. + * @returns The file, or null if not found. + */ + getFile(fileEventId) { + const branch = this.room.currentState.getStateEvents(_event.UNSTABLE_MSC3089_BRANCH.name, fileEventId); + return branch ? new _MSC3089Branch.MSC3089Branch(this.client, branch, this) : null; + } + + /** + * Gets an array of all known files for the tree. + * @returns The known files. May be empty, but not null. + */ + listFiles() { + return this.listAllFiles().filter(b => b.isActive); + } + + /** + * Gets an array of all known files for the tree, including inactive/invalid ones. + * @returns The known files. May be empty, but not null. + */ + listAllFiles() { + const branches = this.room.currentState.getStateEvents(_event.UNSTABLE_MSC3089_BRANCH.name) ?? []; + return branches.map(e => new _MSC3089Branch.MSC3089Branch(this.client, e, this)); + } +} +exports.MSC3089TreeSpace = MSC3089TreeSpace;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/ToDeviceMessage.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/ToDeviceMessage.js new file mode 100644 index 0000000000..430afc16cd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/ToDeviceMessage.js @@ -0,0 +1,5 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/beacon.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/beacon.js new file mode 100644 index 0000000000..6b3cf3c509 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/beacon.js @@ -0,0 +1,181 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.isTimestampInDuration = exports.getBeaconInfoIdentifier = exports.BeaconEvent = exports.Beacon = void 0; +var _contentHelpers = require("../content-helpers"); +var _utils = require("../utils"); +var _typedEventEmitter = require("./typed-event-emitter"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2022 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +let BeaconEvent = /*#__PURE__*/function (BeaconEvent) { + BeaconEvent["New"] = "Beacon.new"; + BeaconEvent["Update"] = "Beacon.update"; + BeaconEvent["LivenessChange"] = "Beacon.LivenessChange"; + BeaconEvent["Destroy"] = "Beacon.Destroy"; + BeaconEvent["LocationUpdate"] = "Beacon.LocationUpdate"; + return BeaconEvent; +}({}); +exports.BeaconEvent = BeaconEvent; +const isTimestampInDuration = (startTimestamp, durationMs, timestamp) => timestamp >= startTimestamp && startTimestamp + durationMs >= timestamp; + +// beacon info events are uniquely identified by +// `<roomId>_<state_key>` +exports.isTimestampInDuration = isTimestampInDuration; +const getBeaconInfoIdentifier = event => `${event.getRoomId()}_${event.getStateKey()}`; + +// https://github.com/matrix-org/matrix-spec-proposals/pull/3672 +exports.getBeaconInfoIdentifier = getBeaconInfoIdentifier; +class Beacon extends _typedEventEmitter.TypedEventEmitter { + constructor(rootEvent) { + super(); + this.rootEvent = rootEvent; + _defineProperty(this, "roomId", void 0); + // beaconInfo is assigned by setBeaconInfo in the constructor + // ! to make tsc believe it is definitely assigned + _defineProperty(this, "_beaconInfo", void 0); + _defineProperty(this, "_isLive", void 0); + _defineProperty(this, "livenessWatchTimeout", void 0); + _defineProperty(this, "_latestLocationEvent", void 0); + _defineProperty(this, "clearLatestLocation", () => { + this._latestLocationEvent = undefined; + this.emit(BeaconEvent.LocationUpdate, this.latestLocationState); + }); + this.roomId = this.rootEvent.getRoomId(); + this.setBeaconInfo(this.rootEvent); + } + get isLive() { + return !!this._isLive; + } + get identifier() { + return getBeaconInfoIdentifier(this.rootEvent); + } + get beaconInfoId() { + return this.rootEvent.getId(); + } + get beaconInfoOwner() { + return this.rootEvent.getStateKey(); + } + get beaconInfoEventType() { + return this.rootEvent.getType(); + } + get beaconInfo() { + return this._beaconInfo; + } + get latestLocationState() { + return this._latestLocationEvent && (0, _contentHelpers.parseBeaconContent)(this._latestLocationEvent.getContent()); + } + get latestLocationEvent() { + return this._latestLocationEvent; + } + update(beaconInfoEvent) { + if (getBeaconInfoIdentifier(beaconInfoEvent) !== this.identifier) { + throw new Error("Invalid updating event"); + } + // don't update beacon with an older event + if (beaconInfoEvent.getTs() < this.rootEvent.getTs()) { + return; + } + this.rootEvent = beaconInfoEvent; + this.setBeaconInfo(this.rootEvent); + this.emit(BeaconEvent.Update, beaconInfoEvent, this); + this.clearLatestLocation(); + } + destroy() { + if (this.livenessWatchTimeout) { + clearTimeout(this.livenessWatchTimeout); + } + this._isLive = false; + this.emit(BeaconEvent.Destroy, this.identifier); + } + + /** + * Monitor liveness of a beacon + * Emits BeaconEvent.LivenessChange when beacon expires + */ + monitorLiveness() { + if (this.livenessWatchTimeout) { + clearTimeout(this.livenessWatchTimeout); + } + this.checkLiveness(); + if (!this.beaconInfo) return; + if (this.isLive) { + const expiryInMs = this.beaconInfo.timestamp + this.beaconInfo.timeout - Date.now(); + if (expiryInMs > 1) { + this.livenessWatchTimeout = setTimeout(() => { + this.monitorLiveness(); + }, expiryInMs); + } + } else if (this.beaconInfo.timestamp > Date.now()) { + // beacon start timestamp is in the future + // check liveness again then + this.livenessWatchTimeout = setTimeout(() => { + this.monitorLiveness(); + }, this.beaconInfo.timestamp - Date.now()); + } + } + + /** + * Process Beacon locations + * Emits BeaconEvent.LocationUpdate + */ + addLocations(beaconLocationEvents) { + // discard locations for beacons that are not live + if (!this.isLive) { + return; + } + const validLocationEvents = beaconLocationEvents.filter(event => { + const content = event.getContent(); + const parsed = (0, _contentHelpers.parseBeaconContent)(content); + if (!parsed.uri || !parsed.timestamp) return false; // we won't be able to process these + const { + timestamp + } = parsed; + return this._beaconInfo.timestamp && + // only include positions that were taken inside the beacon's live period + isTimestampInDuration(this._beaconInfo.timestamp, this._beaconInfo.timeout, timestamp) && ( + // ignore positions older than our current latest location + !this.latestLocationState || timestamp > this.latestLocationState.timestamp); + }); + const latestLocationEvent = validLocationEvents.sort(_utils.sortEventsByLatestContentTimestamp)?.[0]; + if (latestLocationEvent) { + this._latestLocationEvent = latestLocationEvent; + this.emit(BeaconEvent.LocationUpdate, this.latestLocationState); + } + } + setBeaconInfo(event) { + this._beaconInfo = (0, _contentHelpers.parseBeaconInfoContent)(event.getContent()); + this.checkLiveness(); + } + checkLiveness() { + const prevLiveness = this.isLive; + + // element web sets a beacon's start timestamp to the senders local current time + // when Alice's system clock deviates slightly from Bob's a beacon Alice intended to be live + // may have a start timestamp in the future from Bob's POV + // handle this by adding 6min of leniency to the start timestamp when it is in the future + if (!this.beaconInfo) return; + const startTimestamp = this.beaconInfo.timestamp > Date.now() ? this.beaconInfo.timestamp - 360000 /* 6min */ : this.beaconInfo.timestamp; + this._isLive = !!this._beaconInfo.live && !!startTimestamp && isTimestampInDuration(startTimestamp, this._beaconInfo.timeout, Date.now()); + if (prevLiveness !== this.isLive) { + this.emit(BeaconEvent.LivenessChange, this.isLive, this); + } + } +} +exports.Beacon = Beacon;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/device.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/device.js new file mode 100644 index 0000000000..fb91d0865a --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/device.js @@ -0,0 +1,80 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.DeviceVerification = exports.Device = void 0; +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +/** State of the verification of the device. */ +let DeviceVerification = /*#__PURE__*/function (DeviceVerification) { + DeviceVerification[DeviceVerification["Blocked"] = -1] = "Blocked"; + DeviceVerification[DeviceVerification["Unverified"] = 0] = "Unverified"; + DeviceVerification[DeviceVerification["Verified"] = 1] = "Verified"; + return DeviceVerification; +}({}); +/** A map from user ID to device ID to Device */ +exports.DeviceVerification = DeviceVerification; +/** + * Information on a user's device, as returned by {@link Crypto.CryptoApi.getUserDeviceInfo}. + */ +class Device { + constructor(opts) { + /** id of the device */ + _defineProperty(this, "deviceId", void 0); + /** id of the user that owns the device */ + _defineProperty(this, "userId", void 0); + /** list of algorithms supported by this device */ + _defineProperty(this, "algorithms", void 0); + /** a map from `<key type>:<id> -> <base64-encoded key>` */ + _defineProperty(this, "keys", void 0); + /** whether the device has been verified/blocked by the user */ + _defineProperty(this, "verified", void 0); + /** a map `<userId, map<algorithm:device_id, signature>>` */ + _defineProperty(this, "signatures", void 0); + /** display name of the device */ + _defineProperty(this, "displayName", void 0); + this.deviceId = opts.deviceId; + this.userId = opts.userId; + this.algorithms = opts.algorithms; + this.keys = opts.keys; + this.verified = opts.verified || DeviceVerification.Unverified; + this.signatures = opts.signatures || new Map(); + this.displayName = opts.displayName; + } + + /** + * Get the fingerprint for this device (ie, the Ed25519 key) + * + * @returns base64-encoded fingerprint of this device + */ + getFingerprint() { + return this.keys.get(`ed25519:${this.deviceId}`); + } + + /** + * Get the identity key for this device (ie, the Curve25519 key) + * + * @returns base64-encoded identity key of this device + */ + getIdentityKey() { + return this.keys.get(`curve25519:${this.deviceId}`); + } +} +exports.Device = Device;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-context.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-context.js new file mode 100644 index 0000000000..29f9223674 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-context.js @@ -0,0 +1,116 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.EventContext = void 0; +var _eventTimeline = require("./event-timeline"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2015 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +class EventContext { + /** + * Construct a new EventContext + * + * An eventcontext is used for circumstances such as search results, when we + * have a particular event of interest, and a bunch of events before and after + * it. + * + * It also stores pagination tokens for going backwards and forwards in the + * timeline. + * + * @param ourEvent - the event at the centre of this context + */ + constructor(ourEvent) { + this.ourEvent = ourEvent; + _defineProperty(this, "timeline", void 0); + _defineProperty(this, "ourEventIndex", 0); + _defineProperty(this, "paginateTokens", { + [_eventTimeline.Direction.Backward]: null, + [_eventTimeline.Direction.Forward]: null + }); + this.timeline = [ourEvent]; + } + + /** + * Get the main event of interest + * + * This is a convenience function for getTimeline()[getOurEventIndex()]. + * + * @returns The event at the centre of this context. + */ + getEvent() { + return this.timeline[this.ourEventIndex]; + } + + /** + * Get the list of events in this context + * + * @returns An array of MatrixEvents + */ + getTimeline() { + return this.timeline; + } + + /** + * Get the index in the timeline of our event + */ + getOurEventIndex() { + return this.ourEventIndex; + } + + /** + * Get a pagination token. + * + * @param backwards - true to get the pagination token for going + */ + getPaginateToken(backwards = false) { + return this.paginateTokens[backwards ? _eventTimeline.Direction.Backward : _eventTimeline.Direction.Forward]; + } + + /** + * Set a pagination token. + * + * Generally this will be used only by the matrix js sdk. + * + * @param token - pagination token + * @param backwards - true to set the pagination token for going + * backwards in time + */ + setPaginateToken(token, backwards = false) { + this.paginateTokens[backwards ? _eventTimeline.Direction.Backward : _eventTimeline.Direction.Forward] = token ?? null; + } + + /** + * Add more events to the timeline + * + * @param events - new events, in timeline order + * @param atStart - true to insert new events at the start + */ + addEvents(events, atStart = false) { + // TODO: should we share logic with Room.addEventsToTimeline? + // Should Room even use EventContext? + + if (atStart) { + this.timeline = events.concat(this.timeline); + this.ourEventIndex += events.length; + } else { + this.timeline = this.timeline.concat(events); + } + } +} +exports.EventContext = EventContext;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-status.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-status.js new file mode 100644 index 0000000000..5618c09aca --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-status.js @@ -0,0 +1,35 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.EventStatus = void 0; +/* +Copyright 2015 - 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +/** + * Enum for event statuses. + * @readonly + */ +let EventStatus = /*#__PURE__*/function (EventStatus) { + EventStatus["NOT_SENT"] = "not_sent"; + EventStatus["ENCRYPTING"] = "encrypting"; + EventStatus["SENDING"] = "sending"; + EventStatus["QUEUED"] = "queued"; + EventStatus["SENT"] = "sent"; + EventStatus["CANCELLED"] = "cancelled"; + return EventStatus; +}({}); +exports.EventStatus = EventStatus;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-timeline-set.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-timeline-set.js new file mode 100644 index 0000000000..3452bbfaee --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-timeline-set.js @@ -0,0 +1,809 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.EventTimelineSet = exports.DuplicateStrategy = void 0; +var _eventTimeline = require("./event-timeline"); +var _logger = require("../logger"); +var _room = require("./room"); +var _typedEventEmitter = require("./typed-event-emitter"); +var _relationsContainer = require("./relations-container"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2016 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +const DEBUG = true; + +/* istanbul ignore next */ +let debuglog; +if (DEBUG) { + // using bind means that we get to keep useful line numbers in the console + debuglog = _logger.logger.log.bind(_logger.logger); +} else { + /* istanbul ignore next */ + debuglog = function () {}; +} +let DuplicateStrategy = /*#__PURE__*/function (DuplicateStrategy) { + DuplicateStrategy["Ignore"] = "ignore"; + DuplicateStrategy["Replace"] = "replace"; + return DuplicateStrategy; +}({}); +exports.DuplicateStrategy = DuplicateStrategy; +class EventTimelineSet extends _typedEventEmitter.TypedEventEmitter { + /** + * Construct a set of EventTimeline objects, typically on behalf of a given + * room. A room may have multiple EventTimelineSets for different levels + * of filtering. The global notification list is also an EventTimelineSet, but + * lacks a room. + * + * <p>This is an ordered sequence of timelines, which may or may not + * be continuous. Each timeline lists a series of events, as well as tracking + * the room state at the start and the end of the timeline (if appropriate). + * It also tracks forward and backward pagination tokens, as well as containing + * links to the next timeline in the sequence. + * + * <p>There is one special timeline - the 'live' timeline, which represents the + * timeline to which events are being added in real-time as they are received + * from the /sync API. Note that you should not retain references to this + * timeline - even if it is the current timeline right now, it may not remain + * so if the server gives us a timeline gap in /sync. + * + * <p>In order that we can find events from their ids later, we also maintain a + * map from event_id to timeline and index. + * + * @param room - Room for this timelineSet. May be null for non-room cases, such as the + * notification timeline. + * @param opts - Options inherited from Room. + * @param client - the Matrix client which owns this EventTimelineSet, + * can be omitted if room is specified. + * @param thread - the thread to which this timeline set relates. + * @param isThreadTimeline - Whether this timeline set relates to a thread list timeline + * (e.g., All threads or My threads) + */ + constructor(room, opts = {}, client, thread, threadListType = null) { + super(); + this.room = room; + this.thread = thread; + this.threadListType = threadListType; + _defineProperty(this, "relations", void 0); + _defineProperty(this, "timelineSupport", void 0); + _defineProperty(this, "displayPendingEvents", void 0); + _defineProperty(this, "liveTimeline", void 0); + _defineProperty(this, "timelines", void 0); + _defineProperty(this, "_eventIdToTimeline", new Map()); + _defineProperty(this, "filter", void 0); + this.timelineSupport = Boolean(opts.timelineSupport); + this.liveTimeline = new _eventTimeline.EventTimeline(this); + this.displayPendingEvents = opts.pendingEvents !== false; + + // just a list - *not* ordered. + this.timelines = [this.liveTimeline]; + this._eventIdToTimeline = new Map(); + this.filter = opts.filter; + this.relations = this.room?.relations ?? new _relationsContainer.RelationsContainer(room?.client ?? client); + } + + /** + * Get all the timelines in this set + * @returns the timelines in this set + */ + getTimelines() { + return this.timelines; + } + + /** + * Get the filter object this timeline set is filtered on, if any + * @returns the optional filter for this timelineSet + */ + getFilter() { + return this.filter; + } + + /** + * Set the filter object this timeline set is filtered on + * (passed to the server when paginating via /messages). + * @param filter - the filter for this timelineSet + */ + setFilter(filter) { + this.filter = filter; + } + + /** + * Get the list of pending sent events for this timelineSet's room, filtered + * by the timelineSet's filter if appropriate. + * + * @returns A list of the sent events + * waiting for remote echo. + * + * @throws If `opts.pendingEventOrdering` was not 'detached' + */ + getPendingEvents() { + if (!this.room || !this.displayPendingEvents) { + return []; + } + return this.room.getPendingEvents(); + } + /** + * Get the live timeline for this room. + * + * @returns live timeline + */ + getLiveTimeline() { + return this.liveTimeline; + } + + /** + * Set the live timeline for this room. + * + * @returns live timeline + */ + setLiveTimeline(timeline) { + this.liveTimeline = timeline; + } + + /** + * Return the timeline (if any) this event is in. + * @param eventId - the eventId being sought + * @returns timeline + */ + eventIdToTimeline(eventId) { + return this._eventIdToTimeline.get(eventId); + } + + /** + * Track a new event as if it were in the same timeline as an old event, + * replacing it. + * @param oldEventId - event ID of the original event + * @param newEventId - event ID of the replacement event + */ + replaceEventId(oldEventId, newEventId) { + const existingTimeline = this._eventIdToTimeline.get(oldEventId); + if (existingTimeline) { + this._eventIdToTimeline.delete(oldEventId); + this._eventIdToTimeline.set(newEventId, existingTimeline); + } + } + + /** + * Reset the live timeline, and start a new one. + * + * <p>This is used when /sync returns a 'limited' timeline. + * + * @param backPaginationToken - token for back-paginating the new timeline + * @param forwardPaginationToken - token for forward-paginating the old live timeline, + * if absent or null, all timelines are reset. + * + * @remarks + * Fires {@link RoomEvent.TimelineReset} + */ + resetLiveTimeline(backPaginationToken, forwardPaginationToken) { + // Each EventTimeline has RoomState objects tracking the state at the start + // and end of that timeline. The copies at the end of the live timeline are + // special because they will have listeners attached to monitor changes to + // the current room state, so we move this RoomState from the end of the + // current live timeline to the end of the new one and, if necessary, + // replace it with a newly created one. We also make a copy for the start + // of the new timeline. + + // if timeline support is disabled, forget about the old timelines + const resetAllTimelines = !this.timelineSupport || !forwardPaginationToken; + const oldTimeline = this.liveTimeline; + const newTimeline = resetAllTimelines ? oldTimeline.forkLive(_eventTimeline.EventTimeline.FORWARDS) : oldTimeline.fork(_eventTimeline.EventTimeline.FORWARDS); + if (resetAllTimelines) { + this.timelines = [newTimeline]; + this._eventIdToTimeline = new Map(); + } else { + this.timelines.push(newTimeline); + } + if (forwardPaginationToken) { + // Now set the forward pagination token on the old live timeline + // so it can be forward-paginated. + oldTimeline.setPaginationToken(forwardPaginationToken, _eventTimeline.EventTimeline.FORWARDS); + } + + // make sure we set the pagination token before firing timelineReset, + // otherwise clients which start back-paginating will fail, and then get + // stuck without realising that they *can* back-paginate. + newTimeline.setPaginationToken(backPaginationToken ?? null, _eventTimeline.EventTimeline.BACKWARDS); + + // Now we can swap the live timeline to the new one. + this.liveTimeline = newTimeline; + this.emit(_room.RoomEvent.TimelineReset, this.room, this, resetAllTimelines); + } + + /** + * Get the timeline which contains the given event, if any + * + * @param eventId - event ID to look for + * @returns timeline containing + * the given event, or null if unknown + */ + getTimelineForEvent(eventId) { + if (eventId === null || eventId === undefined) { + return null; + } + const res = this._eventIdToTimeline.get(eventId); + return res === undefined ? null : res; + } + + /** + * Get an event which is stored in our timelines + * + * @param eventId - event ID to look for + * @returns the given event, or undefined if unknown + */ + findEventById(eventId) { + const tl = this.getTimelineForEvent(eventId); + if (!tl) { + return undefined; + } + return tl.getEvents().find(function (ev) { + return ev.getId() == eventId; + }); + } + + /** + * Add a new timeline to this timeline list + * + * @returns newly-created timeline + */ + addTimeline() { + if (!this.timelineSupport) { + throw new Error("timeline support is disabled. Set the 'timelineSupport'" + " parameter to true when creating MatrixClient to enable" + " it."); + } + const timeline = new _eventTimeline.EventTimeline(this); + this.timelines.push(timeline); + return timeline; + } + + /** + * Add events to a timeline + * + * <p>Will fire "Room.timeline" for each event added. + * + * @param events - A list of events to add. + * + * @param toStartOfTimeline - True to add these events to the start + * (oldest) instead of the end (newest) of the timeline. If true, the oldest + * event will be the <b>last</b> element of 'events'. + * + * @param timeline - timeline to + * add events to. + * + * @param paginationToken - token for the next batch of events + * + * @remarks + * Fires {@link RoomEvent.Timeline} + * + */ + addEventsToTimeline(events, toStartOfTimeline, timeline, paginationToken) { + if (!timeline) { + throw new Error("'timeline' not specified for EventTimelineSet.addEventsToTimeline"); + } + if (!toStartOfTimeline && timeline == this.liveTimeline) { + throw new Error("EventTimelineSet.addEventsToTimeline cannot be used for adding events to " + "the live timeline - use Room.addLiveEvents instead"); + } + if (this.filter) { + events = this.filter.filterRoomTimeline(events); + if (!events.length) { + return; + } + } + const direction = toStartOfTimeline ? _eventTimeline.EventTimeline.BACKWARDS : _eventTimeline.EventTimeline.FORWARDS; + const inverseDirection = toStartOfTimeline ? _eventTimeline.EventTimeline.FORWARDS : _eventTimeline.EventTimeline.BACKWARDS; + + // Adding events to timelines can be quite complicated. The following + // illustrates some of the corner-cases. + // + // Let's say we start by knowing about four timelines. timeline3 and + // timeline4 are neighbours: + // + // timeline1 timeline2 timeline3 timeline4 + // [M] [P] [S] <------> [T] + // + // Now we paginate timeline1, and get the following events from the server: + // [M, N, P, R, S, T, U]. + // + // 1. First, we ignore event M, since we already know about it. + // + // 2. Next, we append N to timeline 1. + // + // 3. Next, we don't add event P, since we already know about it, + // but we do link together the timelines. We now have: + // + // timeline1 timeline2 timeline3 timeline4 + // [M, N] <---> [P] [S] <------> [T] + // + // 4. Now we add event R to timeline2: + // + // timeline1 timeline2 timeline3 timeline4 + // [M, N] <---> [P, R] [S] <------> [T] + // + // Note that we have switched the timeline we are working on from + // timeline1 to timeline2. + // + // 5. We ignore event S, but again join the timelines: + // + // timeline1 timeline2 timeline3 timeline4 + // [M, N] <---> [P, R] <---> [S] <------> [T] + // + // 6. We ignore event T, and the timelines are already joined, so there + // is nothing to do. + // + // 7. Finally, we add event U to timeline4: + // + // timeline1 timeline2 timeline3 timeline4 + // [M, N] <---> [P, R] <---> [S] <------> [T, U] + // + // The important thing to note in the above is what happened when we + // already knew about a given event: + // + // - if it was appropriate, we joined up the timelines (steps 3, 5). + // - in any case, we started adding further events to the timeline which + // contained the event we knew about (steps 3, 5, 6). + // + // + // So much for adding events to the timeline. But what do we want to do + // with the pagination token? + // + // In the case above, we will be given a pagination token which tells us how to + // get events beyond 'U' - in this case, it makes sense to store this + // against timeline4. But what if timeline4 already had 'U' and beyond? in + // that case, our best bet is to throw away the pagination token we were + // given and stick with whatever token timeline4 had previously. In short, + // we want to only store the pagination token if the last event we receive + // is one we didn't previously know about. + // + // We make an exception for this if it turns out that we already knew about + // *all* of the events, and we weren't able to join up any timelines. When + // that happens, it means our existing pagination token is faulty, since it + // is only telling us what we already know. Rather than repeatedly + // paginating with the same token, we might as well use the new pagination + // token in the hope that we eventually work our way out of the mess. + + let didUpdate = false; + let lastEventWasNew = false; + for (const event of events) { + const eventId = event.getId(); + const existingTimeline = this._eventIdToTimeline.get(eventId); + if (!existingTimeline) { + // we don't know about this event yet. Just add it to the timeline. + this.addEventToTimeline(event, timeline, { + toStartOfTimeline + }); + lastEventWasNew = true; + didUpdate = true; + continue; + } + lastEventWasNew = false; + if (existingTimeline == timeline) { + debuglog("Event " + eventId + " already in timeline " + timeline); + continue; + } + const neighbour = timeline.getNeighbouringTimeline(direction); + if (neighbour) { + // this timeline already has a neighbour in the relevant direction; + // let's assume the timelines are already correctly linked up, and + // skip over to it. + // + // there's probably some edge-case here where we end up with an + // event which is in a timeline a way down the chain, and there is + // a break in the chain somewhere. But I can't really imagine how + // that would happen, so I'm going to ignore it for now. + // + if (existingTimeline == neighbour) { + debuglog("Event " + eventId + " in neighbouring timeline - " + "switching to " + existingTimeline); + } else { + debuglog("Event " + eventId + " already in a different " + "timeline " + existingTimeline); + } + timeline = existingTimeline; + continue; + } + + // time to join the timelines. + _logger.logger.info("Already have timeline for " + eventId + " - joining timeline " + timeline + " to " + existingTimeline); + + // Variables to keep the line length limited below. + const existingIsLive = existingTimeline === this.liveTimeline; + const timelineIsLive = timeline === this.liveTimeline; + const backwardsIsLive = direction === _eventTimeline.EventTimeline.BACKWARDS && existingIsLive; + const forwardsIsLive = direction === _eventTimeline.EventTimeline.FORWARDS && timelineIsLive; + if (backwardsIsLive || forwardsIsLive) { + // The live timeline should never be spliced into a non-live position. + // We use independent logging to better discover the problem at a glance. + if (backwardsIsLive) { + _logger.logger.warn("Refusing to set a preceding existingTimeLine on our " + "timeline as the existingTimeLine is live (" + existingTimeline + ")"); + } + if (forwardsIsLive) { + _logger.logger.warn("Refusing to set our preceding timeline on a existingTimeLine " + "as our timeline is live (" + timeline + ")"); + } + continue; // abort splicing - try next event + } + + timeline.setNeighbouringTimeline(existingTimeline, direction); + existingTimeline.setNeighbouringTimeline(timeline, inverseDirection); + timeline = existingTimeline; + didUpdate = true; + } + + // see above - if the last event was new to us, or if we didn't find any + // new information, we update the pagination token for whatever + // timeline we ended up on. + if (lastEventWasNew || !didUpdate) { + if (direction === _eventTimeline.EventTimeline.FORWARDS && timeline === this.liveTimeline) { + _logger.logger.warn({ + lastEventWasNew, + didUpdate + }); // for debugging + _logger.logger.warn(`Refusing to set forwards pagination token of live timeline ` + `${timeline} to ${paginationToken}`); + return; + } + timeline.setPaginationToken(paginationToken ?? null, direction); + } + } + + /** + * Add an event to the end of this live timeline. + * + * @param event - Event to be added + * @param options - addLiveEvent options + */ + + /** + * @deprecated In favor of the overload with `IAddLiveEventOptions` + */ + + addLiveEvent(event, duplicateStrategyOrOpts, fromCache = false, roomState) { + let duplicateStrategy = duplicateStrategyOrOpts || DuplicateStrategy.Ignore; + let timelineWasEmpty; + if (typeof duplicateStrategyOrOpts === "object") { + ({ + duplicateStrategy = DuplicateStrategy.Ignore, + fromCache = false, + roomState, + timelineWasEmpty + } = duplicateStrategyOrOpts); + } else if (duplicateStrategyOrOpts !== undefined) { + // Deprecation warning + // FIXME: Remove after 2023-06-01 (technical debt) + _logger.logger.warn("Overload deprecated: " + "`EventTimelineSet.addLiveEvent(event, duplicateStrategy?, fromCache?, roomState?)` " + "is deprecated in favor of the overload with " + "`EventTimelineSet.addLiveEvent(event, IAddLiveEventOptions)`"); + } + if (this.filter) { + const events = this.filter.filterRoomTimeline([event]); + if (!events.length) { + return; + } + } + const timeline = this._eventIdToTimeline.get(event.getId()); + if (timeline) { + if (duplicateStrategy === DuplicateStrategy.Replace) { + debuglog("EventTimelineSet.addLiveEvent: replacing duplicate event " + event.getId()); + const tlEvents = timeline.getEvents(); + for (let j = 0; j < tlEvents.length; j++) { + if (tlEvents[j].getId() === event.getId()) { + // still need to set the right metadata on this event + if (!roomState) { + roomState = timeline.getState(_eventTimeline.EventTimeline.FORWARDS); + } + _eventTimeline.EventTimeline.setEventMetadata(event, roomState, false); + tlEvents[j] = event; + + // XXX: we need to fire an event when this happens. + break; + } + } + } else { + debuglog("EventTimelineSet.addLiveEvent: ignoring duplicate event " + event.getId()); + } + return; + } + this.addEventToTimeline(event, this.liveTimeline, { + toStartOfTimeline: false, + fromCache, + roomState, + timelineWasEmpty + }); + } + + /** + * Add event to the given timeline, and emit Room.timeline. Assumes + * we have already checked we don't know about this event. + * + * Will fire "Room.timeline" for each event added. + * + * @param options - addEventToTimeline options + * + * @remarks + * Fires {@link RoomEvent.Timeline} + */ + + /** + * @deprecated In favor of the overload with `IAddEventToTimelineOptions` + */ + + addEventToTimeline(event, timeline, toStartOfTimelineOrOpts, fromCache = false, roomState) { + let toStartOfTimeline = !!toStartOfTimelineOrOpts; + let timelineWasEmpty; + if (typeof toStartOfTimelineOrOpts === "object") { + ({ + toStartOfTimeline, + fromCache = false, + roomState, + timelineWasEmpty + } = toStartOfTimelineOrOpts); + } else if (toStartOfTimelineOrOpts !== undefined) { + // Deprecation warning + // FIXME: Remove after 2023-06-01 (technical debt) + _logger.logger.warn("Overload deprecated: " + "`EventTimelineSet.addEventToTimeline(event, timeline, toStartOfTimeline, fromCache?, roomState?)` " + "is deprecated in favor of the overload with " + "`EventTimelineSet.addEventToTimeline(event, timeline, IAddEventToTimelineOptions)`"); + } + if (timeline.getTimelineSet() !== this) { + throw new Error(`EventTimelineSet.addEventToTimeline: Timeline=${timeline.toString()} does not belong " + + "in timelineSet(threadId=${this.thread?.id})`); + } + + // Make sure events don't get mixed in timelines they shouldn't be in (e.g. a + // threaded message should not be in the main timeline). + // + // We can only run this check for timelines with a `room` because `canContain` + // requires it + if (this.room && !this.canContain(event)) { + let eventDebugString = `event=${event.getId()}`; + if (event.threadRootId) { + eventDebugString += `(belongs to thread=${event.threadRootId})`; + } + _logger.logger.warn(`EventTimelineSet.addEventToTimeline: Ignoring ${eventDebugString} that does not belong ` + `in timeline=${timeline.toString()} timelineSet(threadId=${this.thread?.id})`); + return; + } + const eventId = event.getId(); + timeline.addEvent(event, { + toStartOfTimeline, + roomState, + timelineWasEmpty + }); + this._eventIdToTimeline.set(eventId, timeline); + this.relations.aggregateParentEvent(event); + this.relations.aggregateChildEvent(event, this); + const data = { + timeline: timeline, + liveEvent: !toStartOfTimeline && timeline == this.liveTimeline && !fromCache + }; + this.emit(_room.RoomEvent.Timeline, event, this.room, Boolean(toStartOfTimeline), false, data); + } + + /** + * Insert event to the given timeline, and emit Room.timeline. Assumes + * we have already checked we don't know about this event. + * + * TEMPORARY: until we have recursive relations, we need this function + * to exist to allow us to insert events in timeline order, which is our + * best guess for Sync Order. + * This is a copy of addEventToTimeline above, modified to insert the event + * after the event it relates to, and before any event with a later + * timestamp. This is our best guess at Sync Order. + * + * Will fire "Room.timeline" for each event added. + * + * @internal + * + * @param options - addEventToTimeline options + * + * @remarks + * Fires {@link RoomEvent.Timeline} + */ + insertEventIntoTimeline(event, timeline, roomState) { + if (timeline.getTimelineSet() !== this) { + throw new Error(`EventTimelineSet.addEventToTimeline: Timeline=${timeline.toString()} does not belong " + + "in timelineSet(threadId=${this.thread?.id})`); + } + + // Make sure events don't get mixed in timelines they shouldn't be in (e.g. a + // threaded message should not be in the main timeline). + // + // We can only run this check for timelines with a `room` because `canContain` + // requires it + if (this.room && !this.canContain(event)) { + let eventDebugString = `event=${event.getId()}`; + if (event.threadRootId) { + eventDebugString += `(belongs to thread=${event.threadRootId})`; + } + _logger.logger.warn(`EventTimelineSet.addEventToTimeline: Ignoring ${eventDebugString} that does not belong ` + `in timeline=${timeline.toString()} timelineSet(threadId=${this.thread?.id})`); + return; + } + + // Find the event that this event is related to - the "parent" + const parentEventId = event.relationEventId; + if (!parentEventId) { + // Not related to anything - we just append + this.addEventToTimeline(event, timeline, { + toStartOfTimeline: false, + fromCache: false, + timelineWasEmpty: false, + roomState + }); + return; + } + const parentEvent = this.findEventById(parentEventId); + const timelineEvents = timeline.getEvents(); + + // Start searching from the parent event, or if it's not loaded, start + // at the beginning and insert purely using timestamp order. + const parentIndex = parentEvent !== undefined ? timelineEvents.indexOf(parentEvent) : 0; + let insertIndex = parentIndex; + for (; insertIndex < timelineEvents.length; insertIndex++) { + const nextEvent = timelineEvents[insertIndex]; + if (nextEvent.getTs() > event.getTs()) { + // We found an event later than ours, so insert before that. + break; + } + } + // If we got to the end of the loop, insertIndex points at the end of + // the list. + + const eventId = event.getId(); + timeline.insertEvent(event, insertIndex, roomState); + this._eventIdToTimeline.set(eventId, timeline); + this.relations.aggregateParentEvent(event); + this.relations.aggregateChildEvent(event, this); + const data = { + timeline: timeline, + liveEvent: timeline == this.liveTimeline + }; + this.emit(_room.RoomEvent.Timeline, event, this.room, false, false, data); + } + + /** + * Replaces event with ID oldEventId with one with newEventId, if oldEventId is + * recognised. Otherwise, add to the live timeline. Used to handle remote echos. + * + * @param localEvent - the new event to be added to the timeline + * @param oldEventId - the ID of the original event + * @param newEventId - the ID of the replacement event + * + * @remarks + * Fires {@link RoomEvent.Timeline} + */ + handleRemoteEcho(localEvent, oldEventId, newEventId) { + // XXX: why don't we infer newEventId from localEvent? + const existingTimeline = this._eventIdToTimeline.get(oldEventId); + if (existingTimeline) { + this._eventIdToTimeline.delete(oldEventId); + this._eventIdToTimeline.set(newEventId, existingTimeline); + } else if (!this.filter || this.filter.filterRoomTimeline([localEvent]).length) { + this.addEventToTimeline(localEvent, this.liveTimeline, { + toStartOfTimeline: false + }); + } + } + + /** + * Removes a single event from this room. + * + * @param eventId - The id of the event to remove + * + * @returns the removed event, or null if the event was not found + * in this room. + */ + removeEvent(eventId) { + const timeline = this._eventIdToTimeline.get(eventId); + if (!timeline) { + return null; + } + const removed = timeline.removeEvent(eventId); + if (removed) { + this._eventIdToTimeline.delete(eventId); + const data = { + timeline: timeline + }; + this.emit(_room.RoomEvent.Timeline, removed, this.room, undefined, true, data); + } + return removed; + } + + /** + * Determine where two events appear in the timeline relative to one another + * + * @param eventId1 - The id of the first event + * @param eventId2 - The id of the second event + * @returns a number less than zero if eventId1 precedes eventId2, and + * greater than zero if eventId1 succeeds eventId2. zero if they are the + * same event; null if we can't tell (either because we don't know about one + * of the events, or because they are in separate timelines which don't join + * up). + */ + compareEventOrdering(eventId1, eventId2) { + if (eventId1 == eventId2) { + // optimise this case + return 0; + } + const timeline1 = this._eventIdToTimeline.get(eventId1); + const timeline2 = this._eventIdToTimeline.get(eventId2); + if (timeline1 === undefined) { + return null; + } + if (timeline2 === undefined) { + return null; + } + if (timeline1 === timeline2) { + // both events are in the same timeline - figure out their relative indices + let idx1 = undefined; + let idx2 = undefined; + const events = timeline1.getEvents(); + for (let idx = 0; idx < events.length && (idx1 === undefined || idx2 === undefined); idx++) { + const evId = events[idx].getId(); + if (evId == eventId1) { + idx1 = idx; + } + if (evId == eventId2) { + idx2 = idx; + } + } + return idx1 - idx2; + } + + // the events are in different timelines. Iterate through the + // linkedlist to see which comes first. + + // first work forwards from timeline1 + let tl = timeline1; + while (tl) { + if (tl === timeline2) { + // timeline1 is before timeline2 + return -1; + } + tl = tl.getNeighbouringTimeline(_eventTimeline.EventTimeline.FORWARDS); + } + + // now try backwards from timeline1 + tl = timeline1; + while (tl) { + if (tl === timeline2) { + // timeline2 is before timeline1 + return 1; + } + tl = tl.getNeighbouringTimeline(_eventTimeline.EventTimeline.BACKWARDS); + } + + // the timelines are not contiguous. + return null; + } + + /** + * Determine whether a given event can sanely be added to this event timeline set, + * for timeline sets relating to a thread, only return true for events in the same + * thread timeline, for timeline sets not relating to a thread only return true + * for events which should be shown in the main room timeline. + * Requires the `room` property to have been set at EventTimelineSet construction time. + * + * @param event - the event to check whether it belongs to this timeline set. + * @throws Error if `room` was not set when constructing this timeline set. + * @returns whether the event belongs to this timeline set. + */ + canContain(event) { + if (!this.room) { + throw new Error("Cannot call `EventTimelineSet::canContain without a `room` set. " + "Set the room when creating the EventTimelineSet to call this method."); + } + const { + threadId, + shouldLiveInRoom + } = this.room.eventShouldLiveIn(event); + if (this.thread) { + return this.thread.id === threadId; + } + return shouldLiveInRoom; + } +} +exports.EventTimelineSet = EventTimelineSet;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-timeline.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-timeline.js new file mode 100644 index 0000000000..d5e9b4ac40 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-timeline.js @@ -0,0 +1,469 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.EventTimeline = exports.Direction = void 0; +var _logger = require("../logger"); +var _roomState = require("./room-state"); +var _event = require("../@types/event"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2016 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +let Direction = /*#__PURE__*/function (Direction) { + Direction["Backward"] = "b"; + Direction["Forward"] = "f"; + return Direction; +}({}); +exports.Direction = Direction; +class EventTimeline { + /** + * Static helper method to set sender and target properties + * + * @param event - the event whose metadata is to be set + * @param stateContext - the room state to be queried + * @param toStartOfTimeline - if true the event's forwardLooking flag is set false + */ + static setEventMetadata(event, stateContext, toStartOfTimeline) { + // When we try to generate a sentinel member before we have that member + // in the members object, we still generate a sentinel but it doesn't + // have a membership event, so test to see if events.member is set. We + // check this to avoid overriding non-sentinel members by sentinel ones + // when adding the event to a filtered timeline + if (!event.sender?.events?.member) { + event.sender = stateContext.getSentinelMember(event.getSender()); + } + if (!event.target?.events?.member && event.getType() === _event.EventType.RoomMember) { + event.target = stateContext.getSentinelMember(event.getStateKey()); + } + if (event.isState()) { + // room state has no concept of 'old' or 'current', but we want the + // room state to regress back to previous values if toStartOfTimeline + // is set, which means inspecting prev_content if it exists. This + // is done by toggling the forwardLooking flag. + if (toStartOfTimeline) { + event.forwardLooking = false; + } + } + } + /** + * Construct a new EventTimeline + * + * <p>An EventTimeline represents a contiguous sequence of events in a room. + * + * <p>As well as keeping track of the events themselves, it stores the state of + * the room at the beginning and end of the timeline, and pagination tokens for + * going backwards and forwards in the timeline. + * + * <p>In order that clients can meaningfully maintain an index into a timeline, + * the EventTimeline object tracks a 'baseIndex'. This starts at zero, but is + * incremented when events are prepended to the timeline. The index of an event + * relative to baseIndex therefore remains constant. + * + * <p>Once a timeline joins up with its neighbour, they are linked together into a + * doubly-linked list. + * + * @param eventTimelineSet - the set of timelines this is part of + */ + constructor(eventTimelineSet) { + this.eventTimelineSet = eventTimelineSet; + _defineProperty(this, "roomId", void 0); + _defineProperty(this, "name", void 0); + _defineProperty(this, "events", []); + _defineProperty(this, "baseIndex", 0); + _defineProperty(this, "startState", void 0); + _defineProperty(this, "endState", void 0); + // If we have a roomId then we delegate pagination token storage to the room state objects `startState` and + // `endState`, but for things like the notification timeline which mix multiple rooms we store the tokens ourselves. + _defineProperty(this, "startToken", null); + _defineProperty(this, "endToken", null); + _defineProperty(this, "prevTimeline", null); + _defineProperty(this, "nextTimeline", null); + _defineProperty(this, "paginationRequests", { + [Direction.Backward]: null, + [Direction.Forward]: null + }); + this.roomId = eventTimelineSet.room?.roomId ?? null; + if (this.roomId) { + this.startState = new _roomState.RoomState(this.roomId); + this.endState = new _roomState.RoomState(this.roomId); + } + + // this is used by client.js + this.paginationRequests = { + b: null, + f: null + }; + this.name = this.roomId + ":" + new Date().toISOString(); + } + + /** + * Initialise the start and end state with the given events + * + * <p>This can only be called before any events are added. + * + * @param stateEvents - list of state events to initialise the + * state with. + * @throws Error if an attempt is made to call this after addEvent is called. + */ + initialiseState(stateEvents, { + timelineWasEmpty + } = {}) { + if (this.events.length > 0) { + throw new Error("Cannot initialise state after events are added"); + } + this.startState?.setStateEvents(stateEvents, { + timelineWasEmpty + }); + this.endState?.setStateEvents(stateEvents, { + timelineWasEmpty + }); + } + + /** + * 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 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. + * + * @returns the new timeline + */ + forkLive(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 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. + * + * @returns the new timeline + */ + fork(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 + * @returns room ID + */ + getRoomId() { + return this.roomId; + } + + /** + * Get the filter for this timeline's timelineSet (if any) + * @returns filter + */ + getFilter() { + return this.eventTimelineSet.getFilter(); + } + + /** + * Get the timelineSet for this timeline + * @returns timelineSet + */ + getTimelineSet() { + return this.eventTimelineSet; + } + + /** + * Get the base index. + * + * <p>This is an index which is incremented when events are prepended to the + * timeline. An individual event therefore stays at the same index in the array + * relative to the base index (although note that a given event's index may + * well be less than the base index, thus giving that event a negative relative + * index). + */ + getBaseIndex() { + return this.baseIndex; + } + + /** + * Get the list of events in this context + * + * @returns An array of MatrixEvents + */ + getEvents() { + return this.events; + } + + /** + * Get the room state at the start/end of the timeline + * + * @param 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. + * + * @returns state at the start/end of the timeline + */ + getState(direction) { + if (direction == EventTimeline.BACKWARDS) { + return this.startState; + } else if (direction == EventTimeline.FORWARDS) { + return this.endState; + } else { + throw new Error("Invalid direction '" + direction + "'"); + } + } + + /** + * Get a pagination token + * + * @param direction - EventTimeline.BACKWARDS to get the pagination + * token for going backwards in time; EventTimeline.FORWARDS to get the + * pagination token for going forwards in time. + * + * @returns pagination token + */ + getPaginationToken(direction) { + if (this.roomId) { + return this.getState(direction).paginationToken; + } else if (direction === Direction.Backward) { + return this.startToken; + } else { + return this.endToken; + } + } + + /** + * Set a pagination token + * + * @param token - pagination token + * + * @param direction - EventTimeline.BACKWARDS to set the pagination + * token for going backwards in time; EventTimeline.FORWARDS to set the + * pagination token for going forwards in time. + */ + setPaginationToken(token, direction) { + if (this.roomId) { + this.getState(direction).paginationToken = token; + } else if (direction === Direction.Backward) { + this.startToken = token; + } else { + this.endToken = token; + } + } + + /** + * Get the next timeline in the series + * + * @param direction - EventTimeline.BACKWARDS to get the previous + * timeline; EventTimeline.FORWARDS to get the next timeline. + * + * @returns previous or following timeline, if they have been + * joined up. + */ + getNeighbouringTimeline(direction) { + if (direction == EventTimeline.BACKWARDS) { + return this.prevTimeline; + } else if (direction == EventTimeline.FORWARDS) { + return this.nextTimeline; + } else { + throw new Error("Invalid direction '" + direction + "'"); + } + } + + /** + * Set the next timeline in the series + * + * @param neighbour - previous/following timeline + * + * @param direction - EventTimeline.BACKWARDS to set the previous + * timeline; EventTimeline.FORWARDS to set the next timeline. + * + * @throws Error if an attempt is made to set the neighbouring timeline when + * it is already set. + */ + setNeighbouringTimeline(neighbour, direction) { + if (this.getNeighbouringTimeline(direction)) { + throw new Error("timeline already has a neighbouring timeline - " + "cannot reset neighbour (direction: " + direction + ")"); + } + if (direction == EventTimeline.BACKWARDS) { + this.prevTimeline = neighbour; + } else if (direction == EventTimeline.FORWARDS) { + this.nextTimeline = neighbour; + } else { + throw new Error("Invalid direction '" + direction + "'"); + } + + // make sure we don't try to paginate this timeline + this.setPaginationToken(null, direction); + } + + /** + * Add a new event to the timeline, and update the state + * + * @param event - new event + * @param options - addEvent options + */ + + /** + * @deprecated In favor of the overload with `IAddEventOptions` + */ + + addEvent(event, toStartOfTimelineOrOpts, roomState) { + let toStartOfTimeline = !!toStartOfTimelineOrOpts; + let timelineWasEmpty; + if (typeof toStartOfTimelineOrOpts === "object") { + ({ + toStartOfTimeline, + roomState, + timelineWasEmpty + } = toStartOfTimelineOrOpts); + } else if (toStartOfTimelineOrOpts !== undefined) { + // Deprecation warning + // FIXME: Remove after 2023-06-01 (technical debt) + _logger.logger.warn("Overload deprecated: " + "`EventTimeline.addEvent(event, toStartOfTimeline, roomState?)` " + "is deprecated in favor of the overload with `EventTimeline.addEvent(event, IAddEventOptions)`"); + } + if (!roomState) { + roomState = toStartOfTimeline ? this.startState : this.endState; + } + const timelineSet = this.getTimelineSet(); + if (timelineSet.room) { + EventTimeline.setEventMetadata(event, roomState, toStartOfTimeline); + + // modify state but only on unfiltered timelineSets + if (event.isState() && timelineSet.room.getUnfilteredTimelineSet() === timelineSet) { + roomState?.setStateEvents([event], { + timelineWasEmpty + }); + // it is possible that the act of setting the state event means we + // can set more metadata (specifically sender/target props), so try + // it again if the prop wasn't previously set. It may also mean that + // the sender/target is updated (if the event set was a room member event) + // so we want to use the *updated* member (new avatar/name) instead. + // + // However, we do NOT want to do this on member events if we're going + // back in time, else we'll set the .sender value for BEFORE the given + // member event, whereas we want to set the .sender value for the ACTUAL + // member event itself. + if (!event.sender || event.getType() === _event.EventType.RoomMember && !toStartOfTimeline) { + EventTimeline.setEventMetadata(event, roomState, toStartOfTimeline); + } + } + } + let insertIndex; + if (toStartOfTimeline) { + insertIndex = 0; + } else { + insertIndex = this.events.length; + } + this.events.splice(insertIndex, 0, event); // insert element + if (toStartOfTimeline) { + this.baseIndex++; + } + } + + /** + * Insert a new event into the timeline, and update the state. + * + * TEMPORARY: until we have recursive relations, we need this function + * to exist to allow us to insert events in timeline order, which is our + * best guess for Sync Order. + * This is a copy of addEvent above, modified to allow inserting an event at + * a specific index. + * + * @internal + */ + insertEvent(event, insertIndex, roomState) { + const timelineSet = this.getTimelineSet(); + if (timelineSet.room) { + EventTimeline.setEventMetadata(event, roomState, false); + + // modify state but only on unfiltered timelineSets + if (event.isState() && timelineSet.room.getUnfilteredTimelineSet() === timelineSet) { + roomState.setStateEvents([event], {}); + // it is possible that the act of setting the state event means we + // can set more metadata (specifically sender/target props), so try + // it again if the prop wasn't previously set. It may also mean that + // the sender/target is updated (if the event set was a room member event) + // so we want to use the *updated* member (new avatar/name) instead. + // + // However, we do NOT want to do this on member events if we're going + // back in time, else we'll set the .sender value for BEFORE the given + // member event, whereas we want to set the .sender value for the ACTUAL + // member event itself. + if (!event.sender || event.getType() === _event.EventType.RoomMember) { + EventTimeline.setEventMetadata(event, roomState, false); + } + } + } + this.events.splice(insertIndex, 0, event); // insert element + } + + /** + * Remove an event from the timeline + * + * @param eventId - ID of event to be removed + * @returns removed event, or null if not found + */ + removeEvent(eventId) { + for (let i = this.events.length - 1; i >= 0; i--) { + const ev = this.events[i]; + if (ev.getId() == eventId) { + this.events.splice(i, 1); + if (i < this.baseIndex) { + this.baseIndex--; + } + return ev; + } + } + return null; + } + + /** + * Return a string to identify this timeline, for debugging + * + * @returns name for this timeline + */ + toString() { + return this.name; + } +} +exports.EventTimeline = EventTimeline; +/** + * Symbolic constant for methods which take a 'direction' argument: + * refers to the start of the timeline, or backwards in time. + */ +_defineProperty(EventTimeline, "BACKWARDS", Direction.Backward); +/** + * Symbolic constant for methods which take a 'direction' argument: + * refers to the end of the timeline, or forwards in time. + */ +_defineProperty(EventTimeline, "FORWARDS", Direction.Forward);
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/event.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/event.js new file mode 100644 index 0000000000..eb89ee148d --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/event.js @@ -0,0 +1,1442 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +Object.defineProperty(exports, "EventStatus", { + enumerable: true, + get: function () { + return _eventStatus.EventStatus; + } +}); +exports.MatrixEventEvent = exports.MatrixEvent = void 0; +var _matrixEventsSdk = require("matrix-events-sdk"); +var _logger = require("../logger"); +var _event = require("../@types/event"); +var _utils = require("../utils"); +var _thread = require("./thread"); +var _ReEmitter = require("../ReEmitter"); +var _typedEventEmitter = require("./typed-event-emitter"); +var _algorithms = require("../crypto/algorithms"); +var _OlmDevice = require("../crypto/OlmDevice"); +var _eventStatus = require("./event-status"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2015 - 2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ /** + * This is an internal module. See {@link MatrixEvent} and {@link RoomEvent} for + * the public classes. + */ +/* eslint-disable camelcase */ + +/** + * When an event is a visibility change event, as per MSC3531, + * the visibility change implied by the event. + */ + +/* eslint-enable camelcase */ + +/** + * Message hiding, as specified by https://github.com/matrix-org/matrix-doc/pull/3531. + */ + +/** + * Variant of `MessageVisibility` for the case in which the message should be displayed. + */ + +/** + * Variant of `MessageVisibility` for the case in which the message should be hidden. + */ + +// A singleton implementing `IMessageVisibilityVisible`. +const MESSAGE_VISIBLE = Object.freeze({ + visible: true +}); +let MatrixEventEvent = /*#__PURE__*/function (MatrixEventEvent) { + MatrixEventEvent["Decrypted"] = "Event.decrypted"; + MatrixEventEvent["BeforeRedaction"] = "Event.beforeRedaction"; + MatrixEventEvent["VisibilityChange"] = "Event.visibilityChange"; + MatrixEventEvent["LocalEventIdReplaced"] = "Event.localEventIdReplaced"; + MatrixEventEvent["Status"] = "Event.status"; + MatrixEventEvent["Replaced"] = "Event.replaced"; + MatrixEventEvent["RelationsCreated"] = "Event.relationsCreated"; + return MatrixEventEvent; +}({}); +exports.MatrixEventEvent = MatrixEventEvent; +class MatrixEvent extends _typedEventEmitter.TypedEventEmitter { + /** + * Construct a Matrix Event object + * + * @param event - The raw (possibly encrypted) event. <b>Do not access + * this property</b> directly unless you absolutely have to. Prefer the getter + * methods defined on this class. Using the getter methods shields your app + * from changes to event JSON between Matrix versions. + */ + constructor(event = {}) { + super(); + + // intern the values of matrix events to force share strings and reduce the + // amount of needless string duplication. This can save moderate amounts of + // memory (~10% on a 350MB heap). + // 'membership' at the event level (rather than the content level) is a legacy + // field that Element never otherwise looks at, but it will still take up a lot + // of space if we don't intern it. + this.event = event; + // applied push rule and action for this event + _defineProperty(this, "pushDetails", {}); + _defineProperty(this, "_replacingEvent", null); + _defineProperty(this, "_localRedactionEvent", null); + _defineProperty(this, "_isCancelled", false); + _defineProperty(this, "clearEvent", void 0); + /* Message hiding, as specified by https://github.com/matrix-org/matrix-doc/pull/3531. + Note: We're returning this object, so any value stored here MUST be frozen. + */ + _defineProperty(this, "visibility", MESSAGE_VISIBLE); + // Not all events will be extensible-event compatible, so cache a flag in + // addition to a falsy cached event value. We check the flag later on in + // a public getter to decide if the cache is valid. + _defineProperty(this, "_hasCachedExtEv", false); + _defineProperty(this, "_cachedExtEv", undefined); + /* curve25519 key which we believe belongs to the sender of the event. See + * getSenderKey() + */ + _defineProperty(this, "senderCurve25519Key", null); + /* ed25519 key which the sender of this event (for olm) or the creator of + * the megolm session (for megolm) claims to own. See getClaimedEd25519Key() + */ + _defineProperty(this, "claimedEd25519Key", null); + /* curve25519 keys of devices involved in telling us about the + * senderCurve25519Key and claimedEd25519Key. + * See getForwardingCurve25519KeyChain(). + */ + _defineProperty(this, "forwardingCurve25519KeyChain", []); + /* where the decryption key is untrusted + */ + _defineProperty(this, "untrusted", null); + /* if we have a process decrypting this event, a Promise which resolves + * when it is finished. Normally null. + */ + _defineProperty(this, "decryptionPromise", null); + /* flag to indicate if we should retry decrypting this event after the + * first attempt (eg, we have received new data which means that a second + * attempt may succeed) + */ + _defineProperty(this, "retryDecryption", false); + /* The txnId with which this event was sent if it was during this session, + * allows for a unique ID which does not change when the event comes back down sync. + */ + _defineProperty(this, "txnId", void 0); + /** + * A reference to the thread this event belongs to + */ + _defineProperty(this, "thread", void 0); + _defineProperty(this, "threadId", void 0); + /* + * True if this event is an encrypted event which we failed to decrypt, the receiver's device is unverified and + * the sender has disabled encrypting to unverified devices. + */ + _defineProperty(this, "encryptedDisabledForUnverifiedDevices", false); + /* Set an approximate timestamp for the event relative the local clock. + * This will inherently be approximate because it doesn't take into account + * the time between the server putting the 'age' field on the event as it sent + * it to us and the time we're now constructing this event, but that's better + * than assuming the local clock is in sync with the origin HS's clock. + */ + _defineProperty(this, "localTimestamp", void 0); + /** + * The room member who sent this event, or null e.g. + * this is a presence event. This is only guaranteed to be set for events that + * appear in a timeline, ie. do not guarantee that it will be set on state + * events. + * @privateRemarks + * Should be read-only + */ + _defineProperty(this, "sender", null); + /** + * The room member who is the target of this event, e.g. + * the invitee, the person being banned, etc. + * @privateRemarks + * Should be read-only + */ + _defineProperty(this, "target", null); + /** + * The sending status of the event. + * @privateRemarks + * Should be read-only + */ + _defineProperty(this, "status", null); + /** + * most recent error associated with sending the event, if any + * @privateRemarks + * Should be read-only + */ + _defineProperty(this, "error", null); + /** + * True if this event is 'forward looking', meaning + * that getDirectionalContent() will return event.content and not event.prev_content. + * Only state events may be backwards looking + * Default: true. <strong>This property is experimental and may change.</strong> + * @privateRemarks + * Should be read-only + */ + _defineProperty(this, "forwardLooking", true); + /* If the event is a `m.key.verification.request` (or to_device `m.key.verification.start`) event, + * `Crypto` will set this the `VerificationRequest` for the event + * so it can be easily accessed from the timeline. + */ + _defineProperty(this, "verificationRequest", void 0); + _defineProperty(this, "reEmitter", void 0); + ["state_key", "type", "sender", "room_id", "membership"].forEach(prop => { + if (typeof event[prop] !== "string") return; + event[prop] = (0, _utils.internaliseString)(event[prop]); + }); + ["membership", "avatar_url", "displayname"].forEach(prop => { + if (typeof event.content?.[prop] !== "string") return; + event.content[prop] = (0, _utils.internaliseString)(event.content[prop]); + }); + ["rel_type"].forEach(prop => { + if (typeof event.content?.["m.relates_to"]?.[prop] !== "string") return; + event.content["m.relates_to"][prop] = (0, _utils.internaliseString)(event.content["m.relates_to"][prop]); + }); + this.txnId = event.txn_id; + this.localTimestamp = Date.now() - (this.getAge() ?? 0); + this.reEmitter = new _ReEmitter.TypedReEmitter(this); + } + + /** + * Unstable getter to try and get an extensible event. Note that this might + * return a falsy value if the event could not be parsed as an extensible + * event. + * + * @deprecated Use stable functions where possible. + */ + get unstableExtensibleEvent() { + if (!this._hasCachedExtEv) { + this._cachedExtEv = _matrixEventsSdk.ExtensibleEvents.parse(this.getEffectiveEvent()); + } + return this._cachedExtEv; + } + invalidateExtensibleEvent() { + // just reset the flag - that'll trick the getter into parsing a new event + this._hasCachedExtEv = false; + } + + /** + * Gets the event as though it would appear unencrypted. If the event is already not + * encrypted, it is simply returned as-is. + * @returns The event in wire format. + */ + getEffectiveEvent() { + const content = Object.assign({}, this.getContent()); // clone for mutation + + if (this.getWireType() === _event.EventType.RoomMessageEncrypted) { + // Encrypted events sometimes aren't symmetrical on the `content` so we'll copy + // that over too, but only for missing properties. We don't copy over mismatches + // between the plain and decrypted copies of `content` because we assume that the + // app is relying on the decrypted version, so we want to expose that as a source + // of truth here too. + for (const [key, value] of Object.entries(this.getWireContent())) { + // Skip fields from the encrypted event schema though - we don't want to leak + // these. + if (["algorithm", "ciphertext", "device_id", "sender_key", "session_id"].includes(key)) { + continue; + } + if (content[key] === undefined) content[key] = value; + } + } + + // clearEvent doesn't have all the fields, so we'll copy what we can from this.event. + // We also copy over our "fixed" content key. + return Object.assign({}, this.event, this.clearEvent, { + content + }); + } + + /** + * Get the event_id for this event. + * @returns The event ID, e.g. <code>$143350589368169JsLZx:localhost + * </code> + */ + getId() { + return this.event.event_id; + } + + /** + * Get the user_id for this event. + * @returns The user ID, e.g. `@alice:matrix.org` + */ + getSender() { + return this.event.sender || this.event.user_id; // v2 / v1 + } + + /** + * Get the (decrypted, if necessary) type of event. + * + * @returns The event type, e.g. `m.room.message` + */ + getType() { + if (this.clearEvent) { + return this.clearEvent.type; + } + return this.event.type; + } + + /** + * Get the (possibly encrypted) type of the event that will be sent to the + * homeserver. + * + * @returns The event type. + */ + getWireType() { + return this.event.type; + } + + /** + * Get the room_id for this event. This will return `undefined` + * for `m.presence` events. + * @returns The room ID, e.g. <code>!cURbafjkfsMDVwdRDQ:matrix.org + * </code> + */ + getRoomId() { + return this.event.room_id; + } + + /** + * Get the timestamp of this event. + * @returns The event timestamp, e.g. `1433502692297` + */ + getTs() { + return this.event.origin_server_ts; + } + + /** + * Get the timestamp of this event, as a Date object. + * @returns The event date, e.g. `new Date(1433502692297)` + */ + getDate() { + return this.event.origin_server_ts ? new Date(this.event.origin_server_ts) : null; + } + + /** + * Get a string containing details of this event + * + * This is intended for logging, to help trace errors. Example output: + * + * @example + * ``` + * id=$HjnOHV646n0SjLDAqFrgIjim7RCpB7cdMXFrekWYAn type=m.room.encrypted + * sender=@user:example.com room=!room:example.com ts=2022-10-25T17:30:28.404Z + * ``` + */ + getDetails() { + let details = `id=${this.getId()} type=${this.getWireType()} sender=${this.getSender()}`; + const room = this.getRoomId(); + if (room) { + details += ` room=${room}`; + } + const date = this.getDate(); + if (date) { + details += ` ts=${date.toISOString()}`; + } + return details; + } + + /** + * Get the (decrypted, if necessary) event content JSON, even if the event + * was replaced by another event. + * + * @returns The event content JSON, or an empty object. + */ + getOriginalContent() { + if (this._localRedactionEvent) { + return {}; + } + if (this.clearEvent) { + return this.clearEvent.content || {}; + } + return this.event.content || {}; + } + + /** + * Get the (decrypted, if necessary) event content JSON, + * or the content from the replacing event, if any. + * See `makeReplaced`. + * + * @returns The event content JSON, or an empty object. + */ + getContent() { + if (this._localRedactionEvent) { + return {}; + } else if (this._replacingEvent) { + return this._replacingEvent.getContent()["m.new_content"] || {}; + } else { + return this.getOriginalContent(); + } + } + + /** + * Get the (possibly encrypted) event content JSON that will be sent to the + * homeserver. + * + * @returns The event content JSON, or an empty object. + */ + getWireContent() { + return this.event.content || {}; + } + + /** + * Get the event ID of the thread head + */ + get threadRootId() { + const relatesTo = this.getWireContent()?.["m.relates_to"]; + if (relatesTo?.rel_type === _thread.THREAD_RELATION_TYPE.name) { + return relatesTo.event_id; + } else { + return this.getThread()?.id || this.threadId; + } + } + + /** + * A helper to check if an event is a thread's head or not + */ + get isThreadRoot() { + const threadDetails = this.getServerAggregatedRelation(_thread.THREAD_RELATION_TYPE.name); + + // Bundled relationships only returned when the sync response is limited + // hence us having to check both bundled relation and inspect the thread + // model + return !!threadDetails || this.getThread()?.id === this.getId(); + } + get replyEventId() { + return this.getWireContent()["m.relates_to"]?.["m.in_reply_to"]?.event_id; + } + get relationEventId() { + return this.getWireContent()?.["m.relates_to"]?.event_id; + } + + /** + * Get the previous event content JSON. This will only return something for + * state events which exist in the timeline. + * @returns The previous event content JSON, or an empty object. + */ + getPrevContent() { + // v2 then v1 then default + return this.getUnsigned().prev_content || this.event.prev_content || {}; + } + + /** + * Get either 'content' or 'prev_content' depending on if this event is + * 'forward-looking' or not. This can be modified via event.forwardLooking. + * In practice, this means we get the chronologically earlier content value + * for this event (this method should surely be called getEarlierContent) + * <strong>This method is experimental and may change.</strong> + * @returns event.content if this event is forward-looking, else + * event.prev_content. + */ + getDirectionalContent() { + return this.forwardLooking ? this.getContent() : this.getPrevContent(); + } + + /** + * Get the age of this event. This represents the age of the event when the + * event arrived at the device, and not the age of the event when this + * function was called. + * Can only be returned once the server has echo'ed back + * @returns The age of this event in milliseconds. + */ + getAge() { + return this.getUnsigned().age || this.event.age; // v2 / v1 + } + + /** + * Get the age of the event when this function was called. + * This is the 'age' field adjusted according to how long this client has + * had the event. + * @returns The age of this event in milliseconds. + */ + getLocalAge() { + return Date.now() - this.localTimestamp; + } + + /** + * Get the event state_key if it has one. This will return <code>undefined + * </code> for message events. + * @returns The event's `state_key`. + */ + getStateKey() { + return this.event.state_key; + } + + /** + * Check if this event is a state event. + * @returns True if this is a state event. + */ + isState() { + return this.event.state_key !== undefined; + } + + /** + * Replace the content of this event with encrypted versions. + * (This is used when sending an event; it should not be used by applications). + * + * @internal + * + * @param cryptoType - type of the encrypted event - typically + * <tt>"m.room.encrypted"</tt> + * + * @param cryptoContent - raw 'content' for the encrypted event. + * + * @param senderCurve25519Key - curve25519 key to record for the + * sender of this event. + * See {@link MatrixEvent#getSenderKey}. + * + * @param claimedEd25519Key - claimed ed25519 key to record for the + * sender if this event. + * See {@link MatrixEvent#getClaimedEd25519Key} + */ + makeEncrypted(cryptoType, cryptoContent, senderCurve25519Key, claimedEd25519Key) { + // keep the plain-text data for 'view source' + this.clearEvent = { + type: this.event.type, + content: this.event.content + }; + this.event.type = cryptoType; + this.event.content = cryptoContent; + this.senderCurve25519Key = senderCurve25519Key; + this.claimedEd25519Key = claimedEd25519Key; + } + + /** + * Check if this event is currently being decrypted. + * + * @returns True if this event is currently being decrypted, else false. + */ + isBeingDecrypted() { + return this.decryptionPromise != null; + } + getDecryptionPromise() { + return this.decryptionPromise; + } + + /** + * Check if this event is an encrypted event which we failed to decrypt + * + * (This implies that we might retry decryption at some point in the future) + * + * @returns True if this event is an encrypted event which we + * couldn't decrypt. + */ + isDecryptionFailure() { + return this.clearEvent?.content?.msgtype === "m.bad.encrypted"; + } + + /* + * True if this event is an encrypted event which we failed to decrypt, the receiver's device is unverified and + * the sender has disabled encrypting to unverified devices. + */ + get isEncryptedDisabledForUnverifiedDevices() { + return this.isDecryptionFailure() && this.encryptedDisabledForUnverifiedDevices; + } + shouldAttemptDecryption() { + if (this.isRedacted()) return false; + if (this.isBeingDecrypted()) return false; + if (this.clearEvent) return false; + if (!this.isEncrypted()) return false; + return true; + } + + /** + * Start the process of trying to decrypt this event. + * + * (This is used within the SDK: it isn't intended for use by applications) + * + * @internal + * + * @param crypto - crypto module + * + * @returns promise which resolves (to undefined) when the decryption + * attempt is completed. + */ + async attemptDecryption(crypto, options = {}) { + // start with a couple of sanity checks. + if (!this.isEncrypted()) { + throw new Error("Attempt to decrypt event which isn't encrypted"); + } + const alreadyDecrypted = this.clearEvent && !this.isDecryptionFailure(); + const forceRedecrypt = options.forceRedecryptIfUntrusted && this.isKeySourceUntrusted(); + if (alreadyDecrypted && !forceRedecrypt) { + // we may want to just ignore this? let's start with rejecting it. + throw new Error("Attempt to decrypt event which has already been decrypted"); + } + + // if we already have a decryption attempt in progress, then it may + // fail because it was using outdated info. We now have reason to + // succeed where it failed before, but we don't want to have multiple + // attempts going at the same time, so just set a flag that says we have + // new info. + // + if (this.decryptionPromise) { + _logger.logger.log(`Event ${this.getId()} already being decrypted; queueing a retry`); + this.retryDecryption = true; + return this.decryptionPromise; + } + this.decryptionPromise = this.decryptionLoop(crypto, options); + return this.decryptionPromise; + } + + /** + * Cancel any room key request for this event and resend another. + * + * @param crypto - crypto module + * @param userId - the user who received this event + * + * @returns a promise that resolves when the request is queued + */ + cancelAndResendKeyRequest(crypto, userId) { + const wireContent = this.getWireContent(); + return crypto.requestRoomKey({ + algorithm: wireContent.algorithm, + room_id: this.getRoomId(), + session_id: wireContent.session_id, + sender_key: wireContent.sender_key + }, this.getKeyRequestRecipients(userId), true); + } + + /** + * Calculate the recipients for keyshare requests. + * + * @param userId - the user who received this event. + * + * @returns array of recipients + */ + getKeyRequestRecipients(userId) { + // send the request to all of our own devices + const recipients = [{ + userId, + deviceId: "*" + }]; + return recipients; + } + async decryptionLoop(crypto, options = {}) { + // make sure that this method never runs completely synchronously. + // (doing so would mean that we would clear decryptionPromise *before* + // it is set in attemptDecryption - and hence end up with a stuck + // `decryptionPromise`). + await Promise.resolve(); + + // eslint-disable-next-line no-constant-condition + while (true) { + this.retryDecryption = false; + let res; + let err = undefined; + try { + if (!crypto) { + res = this.badEncryptedMessage("Encryption not enabled"); + } else { + res = await crypto.decryptEvent(this); + if (options.isRetry === true) { + _logger.logger.info(`Decrypted event on retry (${this.getDetails()})`); + } + } + } catch (e) { + const detailedError = e instanceof _algorithms.DecryptionError ? e.detailedString : String(e); + err = e; + + // see if we have a retry queued. + // + // NB: make sure to keep this check in the same tick of the + // event loop as `decryptionPromise = null` below - otherwise we + // risk a race: + // + // * A: we check retryDecryption here and see that it is + // false + // * B: we get a second call to attemptDecryption, which sees + // that decryptionPromise is set so sets + // retryDecryption + // * A: we continue below, clear decryptionPromise, and + // never do the retry. + // + if (this.retryDecryption) { + // decryption error, but we have a retry queued. + _logger.logger.log(`Error decrypting event (${this.getDetails()}), but retrying: ${detailedError}`); + continue; + } + + // decryption error, no retries queued. Warn about the error and + // set it to m.bad.encrypted. + // + // the detailedString already includes the name and message of the error, and the stack isn't much use, + // so we don't bother to log `e` separately. + _logger.logger.warn(`Error decrypting event (${this.getDetails()}): ${detailedError}`); + res = this.badEncryptedMessage(String(e)); + } + + // at this point, we've either successfully decrypted the event, or have given up + // (and set res to a 'badEncryptedMessage'). Either way, we can now set the + // cleartext of the event and raise Event.decrypted. + // + // make sure we clear 'decryptionPromise' before sending the 'Event.decrypted' event, + // otherwise the app will be confused to see `isBeingDecrypted` still set when + // there isn't an `Event.decrypted` on the way. + // + // see also notes on retryDecryption above. + // + this.decryptionPromise = null; + this.retryDecryption = false; + this.setClearData(res); + + // Before we emit the event, clear the push actions so that they can be recalculated + // by relevant code. We do this because the clear event has now changed, making it + // so that existing rules can be re-run over the applicable properties. Stuff like + // highlighting when the user's name is mentioned rely on this happening. We also want + // to set the push actions before emitting so that any notification listeners don't + // pick up the wrong contents. + this.setPushDetails(); + if (options.emit !== false) { + this.emit(MatrixEventEvent.Decrypted, this, err); + } + return; + } + } + badEncryptedMessage(reason) { + return { + clearEvent: { + type: _event.EventType.RoomMessage, + content: { + msgtype: "m.bad.encrypted", + body: "** Unable to decrypt: " + reason + " **" + } + }, + encryptedDisabledForUnverifiedDevices: reason === `DecryptionError: ${_OlmDevice.WITHHELD_MESSAGES["m.unverified"]}` + }; + } + + /** + * Update the cleartext data on this event. + * + * (This is used after decrypting an event; it should not be used by applications). + * + * @internal + * + * @param decryptionResult - the decryption result, including the plaintext and some key info + * + * @remarks + * Fires {@link MatrixEventEvent.Decrypted} + */ + setClearData(decryptionResult) { + this.clearEvent = decryptionResult.clearEvent; + this.senderCurve25519Key = decryptionResult.senderCurve25519Key ?? null; + this.claimedEd25519Key = decryptionResult.claimedEd25519Key ?? null; + this.forwardingCurve25519KeyChain = decryptionResult.forwardingCurve25519KeyChain || []; + this.untrusted = decryptionResult.untrusted || false; + this.encryptedDisabledForUnverifiedDevices = decryptionResult.encryptedDisabledForUnverifiedDevices || false; + this.invalidateExtensibleEvent(); + } + + /** + * Gets the cleartext content for this event. If the event is not encrypted, + * or encryption has not been completed, this will return null. + * + * @returns The cleartext (decrypted) content for the event + */ + getClearContent() { + return this.clearEvent ? this.clearEvent.content : null; + } + + /** + * Check if the event is encrypted. + * @returns True if this event is encrypted. + */ + isEncrypted() { + return !this.isState() && this.event.type === _event.EventType.RoomMessageEncrypted; + } + + /** + * The curve25519 key for the device that we think sent this event + * + * For an Olm-encrypted event, this is inferred directly from the DH + * exchange at the start of the session: the curve25519 key is involved in + * the DH exchange, so only a device which holds the private part of that + * key can establish such a session. + * + * For a megolm-encrypted event, it is inferred from the Olm message which + * established the megolm session + */ + getSenderKey() { + return this.senderCurve25519Key; + } + + /** + * The additional keys the sender of this encrypted event claims to possess. + * + * Just a wrapper for #getClaimedEd25519Key (q.v.) + */ + getKeysClaimed() { + if (!this.claimedEd25519Key) return {}; + return { + ed25519: this.claimedEd25519Key + }; + } + + /** + * Get the ed25519 the sender of this event claims to own. + * + * For Olm messages, this claim is encoded directly in the plaintext of the + * event itself. For megolm messages, it is implied by the m.room_key event + * which established the megolm session. + * + * Until we download the device list of the sender, it's just a claim: the + * device list gives a proof that the owner of the curve25519 key used for + * this event (and returned by #getSenderKey) also owns the ed25519 key by + * signing the public curve25519 key with the ed25519 key. + * + * In general, applications should not use this method directly, but should + * instead use MatrixClient.getEventSenderDeviceInfo. + */ + getClaimedEd25519Key() { + return this.claimedEd25519Key; + } + + /** + * Get the curve25519 keys of the devices which were involved in telling us + * about the claimedEd25519Key and sender curve25519 key. + * + * Normally this will be empty, but in the case of a forwarded megolm + * session, the sender keys are sent to us by another device (the forwarding + * device), which we need to trust to do this. In that case, the result will + * be a list consisting of one entry. + * + * If the device that sent us the key (A) got it from another device which + * it wasn't prepared to vouch for (B), the result will be [A, B]. And so on. + * + * @returns base64-encoded curve25519 keys, from oldest to newest. + */ + getForwardingCurve25519KeyChain() { + return this.forwardingCurve25519KeyChain; + } + + /** + * Whether the decryption key was obtained from an untrusted source. If so, + * we cannot verify the authenticity of the message. + */ + isKeySourceUntrusted() { + return !!this.untrusted; + } + getUnsigned() { + return this.event.unsigned || {}; + } + setUnsigned(unsigned) { + this.event.unsigned = unsigned; + } + unmarkLocallyRedacted() { + const value = this._localRedactionEvent; + this._localRedactionEvent = null; + if (this.event.unsigned) { + this.event.unsigned.redacted_because = undefined; + } + return !!value; + } + markLocallyRedacted(redactionEvent) { + if (this._localRedactionEvent) return; + this.emit(MatrixEventEvent.BeforeRedaction, this, redactionEvent); + this._localRedactionEvent = redactionEvent; + if (!this.event.unsigned) { + this.event.unsigned = {}; + } + this.event.unsigned.redacted_because = redactionEvent.event; + } + + /** + * Change the visibility of an event, as per https://github.com/matrix-org/matrix-doc/pull/3531 . + * + * @param visibilityChange - event holding a hide/unhide payload, or nothing + * if the event is being reset to its original visibility (presumably + * by a visibility event being redacted). + * + * @remarks + * Fires {@link MatrixEventEvent.VisibilityChange} if `visibilityEvent` + * caused a change in the actual visibility of this event, either by making it + * visible (if it was hidden), by making it hidden (if it was visible) or by + * changing the reason (if it was hidden). + */ + applyVisibilityEvent(visibilityChange) { + const visible = visibilityChange?.visible ?? true; + const reason = visibilityChange?.reason ?? null; + let change = false; + if (this.visibility.visible !== visible) { + change = true; + } else if (!this.visibility.visible && this.visibility["reason"] !== reason) { + change = true; + } + if (change) { + if (visible) { + this.visibility = MESSAGE_VISIBLE; + } else { + this.visibility = Object.freeze({ + visible: false, + reason + }); + } + this.emit(MatrixEventEvent.VisibilityChange, this, visible); + } + } + + /** + * Return instructions to display or hide the message. + * + * @returns Instructions determining whether the message + * should be displayed. + */ + messageVisibility() { + // Note: We may return `this.visibility` without fear, as + // this is a shallow frozen object. + return this.visibility; + } + + /** + * Update the content of an event in the same way it would be by the server + * if it were redacted before it was sent to us + * + * @param redactionEvent - event causing the redaction + */ + makeRedacted(redactionEvent) { + // quick sanity-check + if (!redactionEvent.event) { + throw new Error("invalid redactionEvent in makeRedacted"); + } + this._localRedactionEvent = null; + this.emit(MatrixEventEvent.BeforeRedaction, this, redactionEvent); + this._replacingEvent = null; + // we attempt to replicate what we would see from the server if + // the event had been redacted before we saw it. + // + // The server removes (most of) the content of the event, and adds a + // "redacted_because" key to the unsigned section containing the + // redacted event. + if (!this.event.unsigned) { + this.event.unsigned = {}; + } + this.event.unsigned.redacted_because = redactionEvent.event; + for (const key in this.event) { + if (this.event.hasOwnProperty(key) && !REDACT_KEEP_KEYS.has(key)) { + delete this.event[key]; + } + } + + // If the event is encrypted prune the decrypted bits + if (this.isEncrypted()) { + this.clearEvent = undefined; + } + const keeps = this.getType() in REDACT_KEEP_CONTENT_MAP ? REDACT_KEEP_CONTENT_MAP[this.getType()] : {}; + const content = this.getContent(); + for (const key in content) { + if (content.hasOwnProperty(key) && !keeps[key]) { + delete content[key]; + } + } + this.invalidateExtensibleEvent(); + } + + /** + * Check if this event has been redacted + * + * @returns True if this event has been redacted + */ + isRedacted() { + return Boolean(this.getUnsigned().redacted_because); + } + + /** + * Check if this event is a redaction of another event + * + * @returns True if this event is a redaction + */ + isRedaction() { + return this.getType() === _event.EventType.RoomRedaction; + } + + /** + * Return the visibility change caused by this event, + * as per https://github.com/matrix-org/matrix-doc/pull/3531. + * + * @returns If the event is a well-formed visibility change event, + * an instance of `IVisibilityChange`, otherwise `null`. + */ + asVisibilityChange() { + if (!_event.EVENT_VISIBILITY_CHANGE_TYPE.matches(this.getType())) { + // Not a visibility change event. + return null; + } + const relation = this.getRelation(); + if (!relation || relation.rel_type != "m.reference") { + // Ill-formed, ignore this event. + return null; + } + const eventId = relation.event_id; + if (!eventId) { + // Ill-formed, ignore this event. + return null; + } + const content = this.getWireContent(); + const visible = !!content.visible; + const reason = content.reason; + if (reason && typeof reason != "string") { + // Ill-formed, ignore this event. + return null; + } + // Well-formed visibility change event. + return { + visible, + reason, + eventId + }; + } + + /** + * Check if this event alters the visibility of another event, + * as per https://github.com/matrix-org/matrix-doc/pull/3531. + * + * @returns True if this event alters the visibility + * of another event. + */ + isVisibilityEvent() { + return _event.EVENT_VISIBILITY_CHANGE_TYPE.matches(this.getType()); + } + + /** + * Get the (decrypted, if necessary) redaction event JSON + * if event was redacted + * + * @returns The redaction event JSON, or an empty object + */ + getRedactionEvent() { + if (!this.isRedacted()) return null; + if (this.clearEvent?.unsigned) { + return this.clearEvent?.unsigned.redacted_because ?? null; + } else if (this.event.unsigned?.redacted_because) { + return this.event.unsigned.redacted_because; + } else { + return {}; + } + } + + /** + * Get the push actions, if known, for this event + * + * @returns push actions + */ + getPushActions() { + return this.pushDetails.actions || null; + } + + /** + * Get the push details, if known, for this event + * + * @returns push actions + */ + getPushDetails() { + return this.pushDetails; + } + + /** + * Set the push actions for this event. + * Clears rule from push details if present + * @deprecated use `setPushDetails` + * + * @param pushActions - push actions + */ + setPushActions(pushActions) { + this.pushDetails = { + actions: pushActions || undefined + }; + } + + /** + * Set the push details for this event. + * + * @param pushActions - push actions + * @param rule - the executed push rule + */ + setPushDetails(pushActions, rule) { + this.pushDetails = { + actions: pushActions, + rule + }; + } + + /** + * Replace the `event` property and recalculate any properties based on it. + * @param event - the object to assign to the `event` property + */ + handleRemoteEcho(event) { + const oldUnsigned = this.getUnsigned(); + const oldId = this.getId(); + this.event = event; + // if this event was redacted before it was sent, it's locally marked as redacted. + // At this point, we've received the remote echo for the event, but not yet for + // the redaction that we are sending ourselves. Preserve the locally redacted + // state by copying over redacted_because so we don't get a flash of + // redacted, not-redacted, redacted as remote echos come in + if (oldUnsigned.redacted_because) { + if (!this.event.unsigned) { + this.event.unsigned = {}; + } + this.event.unsigned.redacted_because = oldUnsigned.redacted_because; + } + // successfully sent. + this.setStatus(null); + if (this.getId() !== oldId) { + // emit the event if it changed + this.emit(MatrixEventEvent.LocalEventIdReplaced, this); + } + this.localTimestamp = Date.now() - this.getAge(); + } + + /** + * Whether the event is in any phase of sending, send failure, waiting for + * remote echo, etc. + */ + isSending() { + return !!this.status; + } + + /** + * Update the event's sending status and emit an event as well. + * + * @param status - The new status + */ + setStatus(status) { + this.status = status; + this.emit(MatrixEventEvent.Status, this, status); + } + replaceLocalEventId(eventId) { + this.event.event_id = eventId; + this.emit(MatrixEventEvent.LocalEventIdReplaced, this); + } + + /** + * Get whether the event is a relation event, and of a given type if + * `relType` is passed in. State events cannot be relation events + * + * @param relType - if given, checks that the relation is of the + * given type + */ + isRelation(relType) { + // Relation info is lifted out of the encrypted content when sent to + // encrypted rooms, so we have to check `getWireContent` for this. + const relation = this.getWireContent()?.["m.relates_to"]; + if (this.isState() && relation?.rel_type === _event.RelationType.Replace) { + // State events cannot be m.replace relations + return false; + } + return !!(relation?.rel_type && relation.event_id && (relType ? relation.rel_type === relType : true)); + } + + /** + * Get relation info for the event, if any. + */ + getRelation() { + if (!this.isRelation()) { + return null; + } + return this.getWireContent()["m.relates_to"] ?? null; + } + + /** + * Set an event that replaces the content of this event, through an m.replace relation. + * + * @param newEvent - the event with the replacing content, if any. + * + * @remarks + * Fires {@link MatrixEventEvent.Replaced} + */ + makeReplaced(newEvent) { + // don't allow redacted events to be replaced. + // if newEvent is null we allow to go through though, + // as with local redaction, the replacing event might get + // cancelled, which should be reflected on the target event. + if (this.isRedacted() && newEvent) { + return; + } + // don't allow state events to be replaced using this mechanism as per MSC2676 + if (this.isState()) { + return; + } + if (this._replacingEvent !== newEvent) { + this._replacingEvent = newEvent ?? null; + this.emit(MatrixEventEvent.Replaced, this); + this.invalidateExtensibleEvent(); + } + } + + /** + * Returns the status of any associated edit or redaction + * (not for reactions/annotations as their local echo doesn't affect the original event), + * or else the status of the event. + */ + getAssociatedStatus() { + if (this._replacingEvent) { + return this._replacingEvent.status; + } else if (this._localRedactionEvent) { + return this._localRedactionEvent.status; + } + return this.status; + } + getServerAggregatedRelation(relType) { + return this.getUnsigned()["m.relations"]?.[relType]; + } + + /** + * Returns the event ID of the event replacing the content of this event, if any. + */ + replacingEventId() { + const replaceRelation = this.getServerAggregatedRelation(_event.RelationType.Replace); + if (replaceRelation) { + return replaceRelation.event_id; + } else if (this._replacingEvent) { + return this._replacingEvent.getId(); + } + } + + /** + * Returns the event replacing the content of this event, if any. + * Replacements are aggregated on the server, so this would only + * return an event in case it came down the sync, or for local echo of edits. + */ + replacingEvent() { + return this._replacingEvent; + } + + /** + * Returns the origin_server_ts of the event replacing the content of this event, if any. + */ + replacingEventDate() { + const replaceRelation = this.getServerAggregatedRelation(_event.RelationType.Replace); + if (replaceRelation) { + const ts = replaceRelation.origin_server_ts; + if (Number.isFinite(ts)) { + return new Date(ts); + } + } else if (this._replacingEvent) { + return this._replacingEvent.getDate() ?? undefined; + } + } + + /** + * Returns the event that wants to redact this event, but hasn't been sent yet. + * @returns the event + */ + localRedactionEvent() { + return this._localRedactionEvent; + } + + /** + * For relations and redactions, returns the event_id this event is referring to. + */ + getAssociatedId() { + const relation = this.getRelation(); + if (this.replyEventId) { + return this.replyEventId; + } else if (relation) { + return relation.event_id; + } else if (this.isRedaction()) { + return this.event.redacts; + } + } + + /** + * Checks if this event is associated with another event. See `getAssociatedId`. + * @deprecated use hasAssociation instead. + */ + hasAssocation() { + return !!this.getAssociatedId(); + } + + /** + * Checks if this event is associated with another event. See `getAssociatedId`. + */ + hasAssociation() { + return !!this.getAssociatedId(); + } + + /** + * Update the related id with a new one. + * + * Used to replace a local id with remote one before sending + * an event with a related id. + * + * @param eventId - the new event id + */ + updateAssociatedId(eventId) { + const relation = this.getRelation(); + if (relation) { + relation.event_id = eventId; + } else if (this.isRedaction()) { + this.event.redacts = eventId; + } + } + + /** + * Flags an event as cancelled due to future conditions. For example, a verification + * request event in the same sync transaction may be flagged as cancelled to warn + * listeners that a cancellation event is coming down the same pipe shortly. + * @param cancelled - Whether the event is to be cancelled or not. + */ + flagCancelled(cancelled = true) { + this._isCancelled = cancelled; + } + + /** + * Gets whether or not the event is flagged as cancelled. See flagCancelled() for + * more information. + * @returns True if the event is cancelled, false otherwise. + */ + isCancelled() { + return this._isCancelled; + } + + /** + * Get a copy/snapshot of this event. The returned copy will be loosely linked + * back to this instance, though will have "frozen" event information. Other + * properties of this MatrixEvent instance will be copied verbatim, which can + * mean they are in reference to this instance despite being on the copy too. + * The reference the snapshot uses does not change, however members aside from + * the underlying event will not be deeply cloned, thus may be mutated internally. + * For example, the sender profile will be copied over at snapshot time, and + * the sender profile internally may mutate without notice to the consumer. + * + * This is meant to be used to snapshot the event details themselves, not the + * features (such as sender) surrounding the event. + * @returns A snapshot of this event. + */ + toSnapshot() { + const ev = new MatrixEvent(JSON.parse(JSON.stringify(this.event))); + for (const [p, v] of Object.entries(this)) { + if (p !== "event") { + // exclude the thing we just cloned + // @ts-ignore - XXX: this is just nasty + ev[p] = v; + } + } + return ev; + } + + /** + * Determines if this event is equivalent to the given event. This only checks + * the event object itself, not the other properties of the event. Intended for + * use with toSnapshot() to identify events changing. + * @param otherEvent - The other event to check against. + * @returns True if the events are the same, false otherwise. + */ + isEquivalentTo(otherEvent) { + if (!otherEvent) return false; + if (otherEvent === this) return true; + const myProps = (0, _utils.deepSortedObjectEntries)(this.event); + const theirProps = (0, _utils.deepSortedObjectEntries)(otherEvent.event); + return JSON.stringify(myProps) === JSON.stringify(theirProps); + } + + /** + * Summarise the event as JSON. This is currently used by React SDK's view + * event source feature and Seshat's event indexing, so take care when + * adjusting the output here. + * + * If encrypted, include both the decrypted and encrypted view of the event. + * + * This is named `toJSON` for use with `JSON.stringify` which checks objects + * for functions named `toJSON` and will call them to customise the output + * if they are defined. + */ + toJSON() { + const event = this.getEffectiveEvent(); + if (!this.isEncrypted()) { + return event; + } + return { + decrypted: event, + encrypted: this.event + }; + } + setVerificationRequest(request) { + this.verificationRequest = request; + } + setTxnId(txnId) { + this.txnId = txnId; + } + getTxnId() { + return this.txnId; + } + + /** + * Set the instance of a thread associated with the current event + * @param thread - the thread + */ + setThread(thread) { + if (this.thread) { + this.reEmitter.stopReEmitting(this.thread, [_thread.ThreadEvent.Update]); + } + this.thread = thread; + this.setThreadId(thread?.id); + if (thread) { + this.reEmitter.reEmit(thread, [_thread.ThreadEvent.Update]); + } + } + + /** + * Get the instance of the thread associated with the current event + */ + getThread() { + return this.thread; + } + setThreadId(threadId) { + this.threadId = threadId; + } +} + +/* REDACT_KEEP_KEYS gives the keys we keep when an event is redacted + * + * This is specified here: + * http://matrix.org/speculator/spec/HEAD/client_server/latest.html#redactions + * + * Also: + * - We keep 'unsigned' since that is created by the local server + * - We keep user_id for backwards-compat with v1 + */ +exports.MatrixEvent = MatrixEvent; +const REDACT_KEEP_KEYS = new Set(["event_id", "type", "room_id", "user_id", "sender", "state_key", "prev_state", "content", "unsigned", "origin_server_ts"]); + +// a map from state event type to the .content keys we keep when an event is redacted +const REDACT_KEEP_CONTENT_MAP = { + [_event.EventType.RoomMember]: { + membership: 1 + }, + [_event.EventType.RoomCreate]: { + creator: 1 + }, + [_event.EventType.RoomJoinRules]: { + join_rule: 1 + }, + [_event.EventType.RoomPowerLevels]: { + ban: 1, + events: 1, + events_default: 1, + kick: 1, + redact: 1, + state_default: 1, + users: 1, + users_default: 1 + } +};
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/invites-ignorer.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/invites-ignorer.js new file mode 100644 index 0000000000..b49d37c372 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/invites-ignorer.js @@ -0,0 +1,358 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.PolicyScope = exports.POLICIES_ACCOUNT_EVENT_TYPE = exports.IgnoredInvites = exports.IGNORE_INVITES_ACCOUNT_EVENT_KEY = void 0; +var _matrixEventsSdk = require("matrix-events-sdk"); +var _eventTimeline = require("./event-timeline"); +var _partials = require("../@types/partials"); +var _utils = require("../utils"); +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// The event type storing the user's individual policies. +/// +/// Exported for testing purposes. +const POLICIES_ACCOUNT_EVENT_TYPE = new _matrixEventsSdk.UnstableValue("m.policies", "org.matrix.msc3847.policies"); + +/// The key within the user's individual policies storing the user's ignored invites. +/// +/// Exported for testing purposes. +exports.POLICIES_ACCOUNT_EVENT_TYPE = POLICIES_ACCOUNT_EVENT_TYPE; +const IGNORE_INVITES_ACCOUNT_EVENT_KEY = new _matrixEventsSdk.UnstableValue("m.ignore.invites", "org.matrix.msc3847.ignore.invites"); + +/// The types of recommendations understood. +exports.IGNORE_INVITES_ACCOUNT_EVENT_KEY = IGNORE_INVITES_ACCOUNT_EVENT_KEY; +var PolicyRecommendation = /*#__PURE__*/function (PolicyRecommendation) { + PolicyRecommendation["Ban"] = "m.ban"; + return PolicyRecommendation; +}(PolicyRecommendation || {}); +/** + * The various scopes for policies. + */ +let PolicyScope = /*#__PURE__*/function (PolicyScope) { + PolicyScope["User"] = "m.policy.user"; + PolicyScope["Room"] = "m.policy.room"; + PolicyScope["Server"] = "m.policy.server"; + return PolicyScope; +}({}); +/** + * A container for ignored invites. + * + * # Performance + * + * This implementation is extremely naive. It expects that we are dealing + * with a very short list of sources (e.g. only one). If real-world + * applications turn out to require longer lists, we may need to rework + * our data structures. + */ +exports.PolicyScope = PolicyScope; +class IgnoredInvites { + constructor(client) { + this.client = client; + } + + /** + * Add a new rule. + * + * @param scope - The scope for this rule. + * @param entity - The entity covered by this rule. Globs are supported. + * @param reason - A human-readable reason for introducing this new rule. + * @returns The event id for the new rule. + */ + async addRule(scope, entity, reason) { + const target = await this.getOrCreateTargetRoom(); + const response = await this.client.sendStateEvent(target.roomId, scope, { + entity, + reason, + recommendation: PolicyRecommendation.Ban + }); + return response.event_id; + } + + /** + * Remove a rule. + */ + async removeRule(event) { + await this.client.redactEvent(event.getRoomId(), event.getId()); + } + + /** + * Add a new room to the list of sources. If the user isn't a member of the + * room, attempt to join it. + * + * @param roomId - A valid room id. If this room is already in the list + * of sources, it will not be duplicated. + * @returns `true` if the source was added, `false` if it was already present. + * @throws If `roomId` isn't the id of a room that the current user is already + * member of or can join. + * + * # Safety + * + * This method will rewrite the `Policies` object in the user's account data. + * This rewrite is inherently racy and could overwrite or be overwritten by + * other concurrent rewrites of the same object. + */ + async addSource(roomId) { + // We attempt to join the room *before* calling + // `await this.getOrCreateSourceRooms()` to decrease the duration + // of the racy section. + await this.client.joinRoom(roomId); + // Race starts. + const sources = (await this.getOrCreateSourceRooms()).map(room => room.roomId); + if (sources.includes(roomId)) { + return false; + } + sources.push(roomId); + await this.withIgnoreInvitesPolicies(ignoreInvitesPolicies => { + ignoreInvitesPolicies.sources = sources; + }); + + // Race ends. + return true; + } + + /** + * Find out whether an invite should be ignored. + * + * @param sender - The user id for the user who issued the invite. + * @param roomId - The room to which the user is invited. + * @returns A rule matching the entity, if any was found, `null` otherwise. + */ + async getRuleForInvite({ + sender, + roomId + }) { + // In this implementation, we perform a very naive lookup: + // - search in each policy room; + // - turn each (potentially glob) rule entity into a regexp. + // + // Real-world testing will tell us whether this is performant enough. + // In the (unfortunately likely) case it isn't, there are several manners + // in which we could optimize this: + // - match several entities per go; + // - pre-compile each rule entity into a regexp; + // - pre-compile entire rooms into a single regexp. + const policyRooms = await this.getOrCreateSourceRooms(); + const senderServer = sender.split(":")[1]; + const roomServer = roomId.split(":")[1]; + for (const room of policyRooms) { + const state = room.getUnfilteredTimelineSet().getLiveTimeline().getState(_eventTimeline.EventTimeline.FORWARDS); + for (const { + scope, + entities + } of [{ + scope: PolicyScope.Room, + entities: [roomId] + }, { + scope: PolicyScope.User, + entities: [sender] + }, { + scope: PolicyScope.Server, + entities: [senderServer, roomServer] + }]) { + const events = state.getStateEvents(scope); + for (const event of events) { + const content = event.getContent(); + if (content?.recommendation != PolicyRecommendation.Ban) { + // Ignoring invites only looks at `m.ban` recommendations. + continue; + } + const glob = content?.entity; + if (!glob) { + // Invalid event. + continue; + } + let regexp; + try { + regexp = new RegExp((0, _utils.globToRegexp)(glob)); + } catch (ex) { + // Assume invalid event. + continue; + } + for (const entity of entities) { + if (entity && regexp.test(entity)) { + return event; + } + } + // No match. + } + } + } + + return null; + } + + /** + * Get the target room, i.e. the room in which any new rule should be written. + * + * If there is no target room setup, a target room is created. + * + * Note: This method is public for testing reasons. Most clients should not need + * to call it directly. + * + * # Safety + * + * This method will rewrite the `Policies` object in the user's account data. + * This rewrite is inherently racy and could overwrite or be overwritten by + * other concurrent rewrites of the same object. + */ + async getOrCreateTargetRoom() { + const ignoreInvitesPolicies = this.getIgnoreInvitesPolicies(); + let target = ignoreInvitesPolicies.target; + // Validate `target`. If it is invalid, trash out the current `target` + // and create a new room. + if (typeof target !== "string") { + target = null; + } + if (target) { + // Check that the room exists and is valid. + const room = this.client.getRoom(target); + if (room) { + return room; + } else { + target = null; + } + } + // We need to create our own policy room for ignoring invites. + target = (await this.client.createRoom({ + name: "Individual Policy Room", + preset: _partials.Preset.PrivateChat + })).room_id; + await this.withIgnoreInvitesPolicies(ignoreInvitesPolicies => { + ignoreInvitesPolicies.target = target; + }); + + // Since we have just called `createRoom`, `getRoom` should not be `null`. + return this.client.getRoom(target); + } + + /** + * Get the list of source rooms, i.e. the rooms from which rules need to be read. + * + * If no source rooms are setup, the target room is used as sole source room. + * + * Note: This method is public for testing reasons. Most clients should not need + * to call it directly. + * + * # Safety + * + * This method will rewrite the `Policies` object in the user's account data. + * This rewrite is inherently racy and could overwrite or be overwritten by + * other concurrent rewrites of the same object. + */ + async getOrCreateSourceRooms() { + const ignoreInvitesPolicies = this.getIgnoreInvitesPolicies(); + let sources = ignoreInvitesPolicies.sources; + + // Validate `sources`. If it is invalid, trash out the current `sources` + // and create a new list of sources from `target`. + let hasChanges = false; + if (!Array.isArray(sources)) { + // `sources` could not be an array. + hasChanges = true; + sources = []; + } + let sourceRooms = sources + // `sources` could contain non-string / invalid room ids + .filter(roomId => typeof roomId === "string").map(roomId => this.client.getRoom(roomId)).filter(room => !!room); + if (sourceRooms.length != sources.length) { + hasChanges = true; + } + if (sourceRooms.length == 0) { + // `sources` could be empty (possibly because we've removed + // invalid content) + const target = await this.getOrCreateTargetRoom(); + hasChanges = true; + sourceRooms = [target]; + } + if (hasChanges) { + // Reload `policies`/`ignoreInvitesPolicies` in case it has been changed + // during or by our call to `this.getTargetRoom()`. + await this.withIgnoreInvitesPolicies(ignoreInvitesPolicies => { + ignoreInvitesPolicies.sources = sources; + }); + } + return sourceRooms; + } + + /** + * Fetch the `IGNORE_INVITES_POLICIES` object from account data. + * + * If both an unstable prefix version and a stable prefix version are available, + * it will return the stable prefix version preferentially. + * + * The result is *not* validated but is guaranteed to be a non-null object. + * + * @returns A non-null object. + */ + getIgnoreInvitesPolicies() { + return this.getPoliciesAndIgnoreInvitesPolicies().ignoreInvitesPolicies; + } + + /** + * Modify in place the `IGNORE_INVITES_POLICIES` object from account data. + */ + async withIgnoreInvitesPolicies(cb) { + const { + policies, + ignoreInvitesPolicies + } = this.getPoliciesAndIgnoreInvitesPolicies(); + cb(ignoreInvitesPolicies); + policies[IGNORE_INVITES_ACCOUNT_EVENT_KEY.name] = ignoreInvitesPolicies; + await this.client.setAccountData(POLICIES_ACCOUNT_EVENT_TYPE.name, policies); + } + + /** + * As `getIgnoreInvitesPolicies` but also return the `POLICIES_ACCOUNT_EVENT_TYPE` + * object. + */ + getPoliciesAndIgnoreInvitesPolicies() { + let policies = {}; + for (const key of [POLICIES_ACCOUNT_EVENT_TYPE.name, POLICIES_ACCOUNT_EVENT_TYPE.altName]) { + if (!key) { + continue; + } + const value = this.client.getAccountData(key)?.getContent(); + if (value) { + policies = value; + break; + } + } + let ignoreInvitesPolicies = {}; + let hasIgnoreInvitesPolicies = false; + for (const key of [IGNORE_INVITES_ACCOUNT_EVENT_KEY.name, IGNORE_INVITES_ACCOUNT_EVENT_KEY.altName]) { + if (!key) { + continue; + } + const value = policies[key]; + if (value && typeof value == "object") { + ignoreInvitesPolicies = value; + hasIgnoreInvitesPolicies = true; + break; + } + } + if (!hasIgnoreInvitesPolicies) { + policies[IGNORE_INVITES_ACCOUNT_EVENT_KEY.name] = ignoreInvitesPolicies; + } + return { + policies, + ignoreInvitesPolicies + }; + } +} +exports.IgnoredInvites = IgnoredInvites;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/poll.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/poll.js new file mode 100644 index 0000000000..25818398f5 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/poll.js @@ -0,0 +1,237 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.isPollEvent = exports.PollEvent = exports.Poll = void 0; +var _matrixEventsSdk = require("matrix-events-sdk"); +var _polls = require("../@types/polls"); +var _relations = require("./relations"); +var _typedEventEmitter = require("./typed-event-emitter"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +let PollEvent = /*#__PURE__*/function (PollEvent) { + PollEvent["New"] = "Poll.new"; + PollEvent["End"] = "Poll.end"; + PollEvent["Update"] = "Poll.update"; + PollEvent["Responses"] = "Poll.Responses"; + PollEvent["Destroy"] = "Poll.Destroy"; + PollEvent["UndecryptableRelations"] = "Poll.UndecryptableRelations"; + return PollEvent; +}({}); +exports.PollEvent = PollEvent; +const filterResponseRelations = (relationEvents, pollEndTimestamp) => { + const responseEvents = relationEvents.filter(event => { + if (event.isDecryptionFailure()) { + return; + } + return _polls.M_POLL_RESPONSE.matches(event.getType()) && + // From MSC3381: + // "Votes sent on or before the end event's timestamp are valid votes" + event.getTs() <= pollEndTimestamp; + }); + return { + responseEvents + }; +}; +class Poll extends _typedEventEmitter.TypedEventEmitter { + constructor(rootEvent, matrixClient, room) { + super(); + this.rootEvent = rootEvent; + this.matrixClient = matrixClient; + this.room = room; + _defineProperty(this, "roomId", void 0); + _defineProperty(this, "pollEvent", void 0); + _defineProperty(this, "_isFetchingResponses", false); + _defineProperty(this, "relationsNextBatch", void 0); + _defineProperty(this, "responses", null); + _defineProperty(this, "endEvent", void 0); + /** + * Keep track of undecryptable relations + * As incomplete result sets affect poll results + */ + _defineProperty(this, "undecryptableRelationEventIds", new Set()); + _defineProperty(this, "countUndecryptableEvents", events => { + const undecryptableEventIds = events.filter(event => event.isDecryptionFailure()).map(event => event.getId()); + const previousCount = this.undecryptableRelationsCount; + this.undecryptableRelationEventIds = new Set([...this.undecryptableRelationEventIds, ...undecryptableEventIds]); + if (this.undecryptableRelationsCount !== previousCount) { + this.emit(PollEvent.UndecryptableRelations, this.undecryptableRelationsCount); + } + }); + if (!this.rootEvent.getRoomId() || !this.rootEvent.getId()) { + throw new Error("Invalid poll start event."); + } + this.roomId = this.rootEvent.getRoomId(); + this.pollEvent = this.rootEvent.unstableExtensibleEvent; + } + get pollId() { + return this.rootEvent.getId(); + } + get endEventId() { + return this.endEvent?.getId(); + } + get isEnded() { + return !!this.endEvent; + } + get isFetchingResponses() { + return this._isFetchingResponses; + } + get undecryptableRelationsCount() { + return this.undecryptableRelationEventIds.size; + } + async getResponses() { + // if we have already fetched some responses + // just return them + if (this.responses) { + return this.responses; + } + + // if there is no fetching in progress + // start fetching + if (!this.isFetchingResponses) { + await this.fetchResponses(); + } + // return whatever responses we got from the first page + return this.responses; + } + + /** + * + * @param event - event with a relation to the rootEvent + * @returns void + */ + onNewRelation(event) { + if (_polls.M_POLL_END.matches(event.getType()) && this.validateEndEvent(event)) { + this.endEvent = event; + this.refilterResponsesOnEnd(); + this.emit(PollEvent.End); + } + + // wait for poll responses to be initialised + if (!this.responses) { + return; + } + const pollEndTimestamp = this.endEvent?.getTs() || Number.MAX_SAFE_INTEGER; + const { + responseEvents + } = filterResponseRelations([event], pollEndTimestamp); + this.countUndecryptableEvents([event]); + if (responseEvents.length) { + responseEvents.forEach(event => { + this.responses.addEvent(event); + }); + this.emit(PollEvent.Responses, this.responses); + } + } + async fetchResponses() { + this._isFetchingResponses = true; + + // we want: + // - stable and unstable M_POLL_RESPONSE + // - stable and unstable M_POLL_END + // so make one api call and filter by event type client side + const allRelations = await this.matrixClient.relations(this.roomId, this.rootEvent.getId(), "m.reference", undefined, { + from: this.relationsNextBatch || undefined + }); + await Promise.all(allRelations.events.map(event => this.matrixClient.decryptEventIfNeeded(event))); + const responses = this.responses || new _relations.Relations("m.reference", _polls.M_POLL_RESPONSE.name, this.matrixClient, [_polls.M_POLL_RESPONSE.altName]); + const pollEndEvent = allRelations.events.find(event => _polls.M_POLL_END.matches(event.getType())); + if (this.validateEndEvent(pollEndEvent)) { + this.endEvent = pollEndEvent; + this.refilterResponsesOnEnd(); + this.emit(PollEvent.End); + } + const pollCloseTimestamp = this.endEvent?.getTs() || Number.MAX_SAFE_INTEGER; + const { + responseEvents + } = filterResponseRelations(allRelations.events, pollCloseTimestamp); + responseEvents.forEach(event => { + responses.addEvent(event); + }); + this.relationsNextBatch = allRelations.nextBatch ?? undefined; + this.responses = responses; + this.countUndecryptableEvents(allRelations.events); + + // while there are more pages of relations + // fetch them + if (this.relationsNextBatch) { + // don't await + // we want to return the first page as soon as possible + this.fetchResponses(); + } else { + // no more pages + this._isFetchingResponses = false; + } + + // emit after updating _isFetchingResponses state + this.emit(PollEvent.Responses, this.responses); + } + + /** + * Only responses made before the poll ended are valid + * Refilter after an end event is recieved + * To ensure responses are valid + */ + refilterResponsesOnEnd() { + if (!this.responses) { + return; + } + const pollEndTimestamp = this.endEvent?.getTs() || Number.MAX_SAFE_INTEGER; + this.responses.getRelations().forEach(event => { + if (event.getTs() > pollEndTimestamp) { + this.responses?.removeEvent(event); + } + }); + this.emit(PollEvent.Responses, this.responses); + } + validateEndEvent(endEvent) { + if (!endEvent) { + return false; + } + /** + * Repeated end events are ignored - + * only the first (valid) closure event by origin_server_ts is counted. + */ + if (this.endEvent && this.endEvent.getTs() < endEvent.getTs()) { + return false; + } + + /** + * MSC3381 + * If a m.poll.end event is received from someone other than the poll creator or user with permission to redact + * others' messages in the room, the event must be ignored by clients due to being invalid. + */ + const roomCurrentState = this.room.currentState; + const endEventSender = endEvent.getSender(); + return !!endEventSender && (endEventSender === this.rootEvent.getSender() || roomCurrentState.maySendRedactionForEvent(this.rootEvent, endEventSender)); + } +} + +/** + * Tests whether the event is a start, response or end poll event. + * + * @param event - Event to test + * @returns true if the event is a poll event, else false + */ +exports.Poll = Poll; +const isPollEvent = event => { + const eventType = event.getType(); + return _matrixEventsSdk.M_POLL_START.matches(eventType) || _polls.M_POLL_RESPONSE.matches(eventType) || _polls.M_POLL_END.matches(eventType); +}; +exports.isPollEvent = isPollEvent;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/read-receipt.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/read-receipt.js new file mode 100644 index 0000000000..8bef646ab4 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/read-receipt.js @@ -0,0 +1,260 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.ReadReceipt = void 0; +exports.synthesizeReceipt = synthesizeReceipt; +var _read_receipts = require("../@types/read_receipts"); +var _typedEventEmitter = require("./typed-event-emitter"); +var _utils = require("../utils"); +var _event = require("./event"); +var _event2 = require("../@types/event"); +var _room = require("./room"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2022 The Matrix.org Foundation C.I.C. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +function synthesizeReceipt(userId, event, receiptType) { + return new _event.MatrixEvent({ + content: { + [event.getId()]: { + [receiptType]: { + [userId]: { + ts: event.getTs(), + thread_id: event.threadRootId ?? _read_receipts.MAIN_ROOM_TIMELINE + } + } + } + }, + type: _event2.EventType.Receipt, + room_id: event.getRoomId() + }); +} +const ReceiptPairRealIndex = 0; +const ReceiptPairSyntheticIndex = 1; +class ReadReceipt extends _typedEventEmitter.TypedEventEmitter { + constructor(...args) { + super(...args); + // receipts should clobber based on receipt_type and user_id pairs hence + // the form of this structure. This is sub-optimal for the exposed APIs + // which pass in an event ID and get back some receipts, so we also store + // a pre-cached list for this purpose. + // Map: receipt type → user Id → receipt + _defineProperty(this, "receipts", new _utils.MapWithDefault(() => new Map())); + _defineProperty(this, "receiptCacheByEventId", new Map()); + _defineProperty(this, "timeline", void 0); + } + /** + * Gets the latest receipt for a given user in the room + * @param userId - The id of the user for which we want the receipt + * @param ignoreSynthesized - Whether to ignore synthesized receipts or not + * @param receiptType - Optional. The type of the receipt we want to get + * @returns the latest receipts of the chosen type for the chosen user + */ + getReadReceiptForUserId(userId, ignoreSynthesized = false, receiptType = _read_receipts.ReceiptType.Read) { + const [realReceipt, syntheticReceipt] = this.receipts.get(receiptType)?.get(userId) ?? [null, null]; + if (ignoreSynthesized) { + return realReceipt; + } + return syntheticReceipt ?? realReceipt; + } + + /** + * Get the ID of the event that a given user has read up to, or null if we + * have received no read receipts from them. + * @param userId - The user ID to get read receipt event ID for + * @param ignoreSynthesized - If true, return only receipts that have been + * sent by the server, not implicit ones generated + * by the JS SDK. + * @returns ID of the latest event that the given user has read, or null. + */ + getEventReadUpTo(userId, ignoreSynthesized = false) { + // XXX: This is very very ugly and I hope I won't have to ever add a new + // receipt type here again. IMHO this should be done by the server in + // some more intelligent manner or the client should just use timestamps + + const timelineSet = this.getUnfilteredTimelineSet(); + const publicReadReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized, _read_receipts.ReceiptType.Read); + const privateReadReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized, _read_receipts.ReceiptType.ReadPrivate); + + // If we have both, compare them + let comparison; + if (publicReadReceipt?.eventId && privateReadReceipt?.eventId) { + comparison = timelineSet.compareEventOrdering(publicReadReceipt?.eventId, privateReadReceipt?.eventId); + } + + // If we didn't get a comparison try to compare the ts of the receipts + if (!comparison && publicReadReceipt?.data?.ts && privateReadReceipt?.data?.ts) { + comparison = publicReadReceipt?.data?.ts - privateReadReceipt?.data?.ts; + } + + // The public receipt is more likely to drift out of date so the private + // one has precedence + if (!comparison) return privateReadReceipt?.eventId ?? publicReadReceipt?.eventId ?? null; + + // If public read receipt is older, return the private one + return (comparison < 0 ? privateReadReceipt?.eventId : publicReadReceipt?.eventId) ?? null; + } + addReceiptToStructure(eventId, receiptType, userId, receipt, synthetic) { + const receiptTypesMap = this.receipts.getOrCreate(receiptType); + let pair = receiptTypesMap.get(userId); + if (!pair) { + pair = [null, null]; + receiptTypesMap.set(userId, pair); + } + let existingReceipt = pair[ReceiptPairRealIndex]; + if (synthetic) { + existingReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex]; + } + if (existingReceipt) { + // we only want to add this receipt if we think it is later than the one we already have. + // This is managed server-side, but because we synthesize RRs locally we have to do it here too. + const ordering = this.getUnfilteredTimelineSet().compareEventOrdering(existingReceipt.eventId, eventId); + if (ordering !== null && ordering >= 0) { + return; + } + } + const wrappedReceipt = { + eventId, + data: receipt + }; + const realReceipt = synthetic ? pair[ReceiptPairRealIndex] : wrappedReceipt; + const syntheticReceipt = synthetic ? wrappedReceipt : pair[ReceiptPairSyntheticIndex]; + let ordering = null; + if (realReceipt && syntheticReceipt) { + ordering = this.getUnfilteredTimelineSet().compareEventOrdering(realReceipt.eventId, syntheticReceipt.eventId); + } + const preferSynthetic = ordering === null || ordering < 0; + + // we don't bother caching just real receipts by event ID as there's nothing that would read it. + // Take the current cached receipt before we overwrite the pair elements. + const cachedReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex]; + if (synthetic && preferSynthetic) { + pair[ReceiptPairSyntheticIndex] = wrappedReceipt; + } else if (!synthetic) { + pair[ReceiptPairRealIndex] = wrappedReceipt; + if (!preferSynthetic) { + pair[ReceiptPairSyntheticIndex] = null; + } + } + const newCachedReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex]; + if (cachedReceipt === newCachedReceipt) return; + + // clean up any previous cache entry + if (cachedReceipt && this.receiptCacheByEventId.get(cachedReceipt.eventId)) { + const previousEventId = cachedReceipt.eventId; + // Remove the receipt we're about to clobber out of existence from the cache + this.receiptCacheByEventId.set(previousEventId, this.receiptCacheByEventId.get(previousEventId).filter(r => { + return r.type !== receiptType || r.userId !== userId; + })); + if (this.receiptCacheByEventId.get(previousEventId).length < 1) { + this.receiptCacheByEventId.delete(previousEventId); // clean up the cache keys + } + } + + // cache the new one + if (!this.receiptCacheByEventId.get(eventId)) { + this.receiptCacheByEventId.set(eventId, []); + } + this.receiptCacheByEventId.get(eventId).push({ + userId: userId, + type: receiptType, + data: receipt + }); + } + + /** + * Get a list of receipts for the given event. + * @param event - the event to get receipts for + * @returns A list of receipts with a userId, type and data keys or + * an empty list. + */ + getReceiptsForEvent(event) { + return this.receiptCacheByEventId.get(event.getId()) || []; + } + /** + * This issue should also be addressed on synapse's side and is tracked as part + * of https://github.com/matrix-org/synapse/issues/14837 + * + * Retrieves the read receipt for the logged in user and checks if it matches + * the last event in the room and whether that event originated from the logged + * in user. + * Under those conditions we can consider the context as read. This is useful + * because we never send read receipts against our own events + * @param userId - the logged in user + */ + fixupNotifications(userId) { + const receipt = this.getReadReceiptForUserId(userId, false); + const lastEvent = this.timeline[this.timeline.length - 1]; + if (lastEvent && receipt?.eventId === lastEvent.getId() && userId === lastEvent.getSender()) { + this.setUnread(_room.NotificationCountType.Total, 0); + this.setUnread(_room.NotificationCountType.Highlight, 0); + } + } + + /** + * Add a temporary local-echo receipt to the room to reflect in the + * client the fact that we've sent one. + * @param userId - The user ID if the receipt sender + * @param e - The event that is to be acknowledged + * @param receiptType - The type of receipt + */ + addLocalEchoReceipt(userId, e, receiptType) { + this.addReceipt(synthesizeReceipt(userId, e, receiptType), true); + } + + /** + * Get a list of user IDs who have <b>read up to</b> the given event. + * @param event - the event to get read receipts for. + * @returns A list of user IDs. + */ + getUsersReadUpTo(event) { + return this.getReceiptsForEvent(event).filter(function (receipt) { + return (0, _utils.isSupportedReceiptType)(receipt.type); + }).map(function (receipt) { + return receipt.userId; + }); + } + + /** + * Determines if the given user has read a particular event ID with the known + * history of the room. This is not a definitive check as it relies only on + * what is available to the room at the time of execution. + * @param userId - The user ID to check the read state of. + * @param eventId - The event ID to check if the user read. + * @returns True if the user has read the event, false otherwise. + */ + hasUserReadEvent(userId, eventId) { + const readUpToId = this.getEventReadUpTo(userId, false); + if (readUpToId === eventId) return true; + if (this.timeline?.length && this.timeline[this.timeline.length - 1].getSender() && this.timeline[this.timeline.length - 1].getSender() === userId) { + // It doesn't matter where the event is in the timeline, the user has read + // it because they've sent the latest event. + return true; + } + for (let i = this.timeline?.length - 1; i >= 0; --i) { + const ev = this.timeline[i]; + + // If we encounter the target event first, the user hasn't read it + // however if we encounter the readUpToId first then the user has read + // it. These rules apply because we're iterating bottom-up. + if (ev.getId() === eventId) return false; + if (ev.getId() === readUpToId) return true; + } + + // We don't know if the user has read it, so assume not. + return false; + } +} +exports.ReadReceipt = ReadReceipt;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/related-relations.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/related-relations.js new file mode 100644 index 0000000000..9db67bf544 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/related-relations.js @@ -0,0 +1,41 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.RelatedRelations = void 0; +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +class RelatedRelations { + constructor(relations) { + _defineProperty(this, "relations", void 0); + this.relations = relations.filter(r => !!r); + } + getRelations() { + return this.relations.reduce((c, p) => [...c, ...p.getRelations()], []); + } + on(ev, fn) { + this.relations.forEach(r => r.on(ev, fn)); + } + off(ev, fn) { + this.relations.forEach(r => r.off(ev, fn)); + } +} +exports.RelatedRelations = RelatedRelations;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/relations-container.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/relations-container.js new file mode 100644 index 0000000000..489ab267eb --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/relations-container.js @@ -0,0 +1,135 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.RelationsContainer = void 0; +var _relations = require("./relations"); +var _event = require("./event"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2022 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +class RelationsContainer { + constructor(client, room) { + this.client = client; + this.room = room; + // A tree of objects to access a set of related children for an event, as in: + // this.relations.get(parentEventId).get(relationType).get(relationEventType) + _defineProperty(this, "relations", new Map()); + } + + /** + * Get a collection of child events to a given event in this timeline set. + * + * @param eventId - The ID of the event that you'd like to access child events for. + * For example, with annotations, this would be the ID of the event being annotated. + * @param relationType - The type of relationship involved, such as "m.annotation", "m.reference", "m.replace", etc. + * @param eventType - The relation event's type, such as "m.reaction", etc. + * @throws If `eventId</code>, <code>relationType</code> or <code>eventType` + * are not valid. + * + * @returns + * A container for relation events or undefined if there are no relation events for + * the relationType. + */ + getChildEventsForEvent(eventId, relationType, eventType) { + return this.relations.get(eventId)?.get(relationType)?.get(eventType); + } + getAllChildEventsForEvent(parentEventId) { + const relationsForEvent = this.relations.get(parentEventId) ?? new Map(); + const events = []; + for (const relationsRecord of relationsForEvent.values()) { + for (const relations of relationsRecord.values()) { + events.push(...relations.getRelations()); + } + } + return events; + } + + /** + * Set an event as the target event if any Relations exist for it already. + * Child events can point to other child events as their parent, so this method may be + * called for events which are also logically child events. + * + * @param event - The event to check as relation target. + */ + aggregateParentEvent(event) { + const relationsForEvent = this.relations.get(event.getId()); + if (!relationsForEvent) return; + for (const relationsWithRelType of relationsForEvent.values()) { + for (const relationsWithEventType of relationsWithRelType.values()) { + relationsWithEventType.setTargetEvent(event); + } + } + } + + /** + * Add relation events to the relevant relation collection. + * + * @param event - The new child event to be aggregated. + * @param timelineSet - The event timeline set within which to search for the related event if any. + */ + aggregateChildEvent(event, timelineSet) { + if (event.isRedacted() || event.status === _event.EventStatus.CANCELLED) { + return; + } + const relation = event.getRelation(); + if (!relation) return; + const onEventDecrypted = () => { + if (event.isDecryptionFailure()) { + // This could for example happen if the encryption keys are not yet available. + // The event may still be decrypted later. Register the listener again. + event.once(_event.MatrixEventEvent.Decrypted, onEventDecrypted); + return; + } + this.aggregateChildEvent(event, timelineSet); + }; + + // If the event is currently encrypted, wait until it has been decrypted. + if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) { + event.once(_event.MatrixEventEvent.Decrypted, onEventDecrypted); + return; + } + const { + event_id: relatesToEventId, + rel_type: relationType + } = relation; + const eventType = event.getType(); + let relationsForEvent = this.relations.get(relatesToEventId); + if (!relationsForEvent) { + relationsForEvent = new Map(); + this.relations.set(relatesToEventId, relationsForEvent); + } + let relationsWithRelType = relationsForEvent.get(relationType); + if (!relationsWithRelType) { + relationsWithRelType = new Map(); + relationsForEvent.set(relationType, relationsWithRelType); + } + let relationsWithEventType = relationsWithRelType.get(eventType); + if (!relationsWithEventType) { + relationsWithEventType = new _relations.Relations(relationType, eventType, this.client); + relationsWithRelType.set(eventType, relationsWithEventType); + const room = this.room ?? timelineSet?.room; + const relatesToEvent = timelineSet?.findEventById(relatesToEventId) ?? room?.findEventById(relatesToEventId) ?? room?.getPendingEvent(relatesToEventId); + if (relatesToEvent) { + relationsWithEventType.setTargetEvent(relatesToEvent); + } + } + relationsWithEventType.addEvent(event); + } +} +exports.RelationsContainer = RelationsContainer;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/relations.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/relations.js new file mode 100644 index 0000000000..1b83615e8b --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/relations.js @@ -0,0 +1,336 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.RelationsEvent = exports.Relations = void 0; +var _event = require("./event"); +var _logger = require("../logger"); +var _event2 = require("../@types/event"); +var _typedEventEmitter = require("./typed-event-emitter"); +var _room = require("./room"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2019, 2021, 2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +let RelationsEvent = /*#__PURE__*/function (RelationsEvent) { + RelationsEvent["Add"] = "Relations.add"; + RelationsEvent["Remove"] = "Relations.remove"; + RelationsEvent["Redaction"] = "Relations.redaction"; + return RelationsEvent; +}({}); +exports.RelationsEvent = RelationsEvent; +const matchesEventType = (eventType, targetEventType, altTargetEventTypes = []) => [targetEventType, ...altTargetEventTypes].includes(eventType); + +/** + * A container for relation events that supports easy access to common ways of + * aggregating such events. Each instance holds events that of a single relation + * type and event type. All of the events also relate to the same original event. + * + * The typical way to get one of these containers is via + * EventTimelineSet#getRelationsForEvent. + */ +class Relations extends _typedEventEmitter.TypedEventEmitter { + /** + * @param relationType - The type of relation involved, such as "m.annotation", "m.reference", "m.replace", etc. + * @param eventType - The relation event's type, such as "m.reaction", etc. + * @param client - The client which created this instance. For backwards compatibility also accepts a Room. + * @param altEventTypes - alt event types for relation events, for example to support unstable prefixed event types + */ + constructor(relationType, eventType, client, altEventTypes) { + super(); + this.relationType = relationType; + this.eventType = eventType; + this.altEventTypes = altEventTypes; + _defineProperty(this, "relationEventIds", new Set()); + _defineProperty(this, "relations", new Set()); + _defineProperty(this, "annotationsByKey", {}); + _defineProperty(this, "annotationsBySender", {}); + _defineProperty(this, "sortedAnnotationsByKey", []); + _defineProperty(this, "targetEvent", null); + _defineProperty(this, "creationEmitted", false); + _defineProperty(this, "client", void 0); + /** + * Listens for event status changes to remove cancelled events. + * + * @param event - The event whose status has changed + * @param status - The new status + */ + _defineProperty(this, "onEventStatus", (event, status) => { + if (!event.isSending()) { + // Sending is done, so we don't need to listen anymore + event.removeListener(_event.MatrixEventEvent.Status, this.onEventStatus); + return; + } + if (status !== _event.EventStatus.CANCELLED) { + return; + } + // Event was cancelled, remove from the collection + event.removeListener(_event.MatrixEventEvent.Status, this.onEventStatus); + this.removeEvent(event); + }); + /** + * For relations that have been redacted, we want to remove them from + * aggregation data sets and emit an update event. + * + * To do so, we listen for `Event.beforeRedaction`, which happens: + * - after the server accepted the redaction and remote echoed back to us + * - before the original event has been marked redacted in the client + * + * @param redactedEvent - The original relation event that is about to be redacted. + */ + _defineProperty(this, "onBeforeRedaction", async redactedEvent => { + if (!this.relations.has(redactedEvent)) { + return; + } + this.relations.delete(redactedEvent); + if (this.relationType === _event2.RelationType.Annotation) { + // Remove the redacted annotation from aggregation by key + this.removeAnnotationFromAggregation(redactedEvent); + } else if (this.relationType === _event2.RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) { + const lastReplacement = await this.getLastReplacement(); + this.targetEvent.makeReplaced(lastReplacement); + } + redactedEvent.removeListener(_event.MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); + this.emit(RelationsEvent.Redaction, redactedEvent); + }); + this.client = client instanceof _room.Room ? client.client : client; + } + + /** + * Add relation events to this collection. + * + * @param event - The new relation event to be added. + */ + async addEvent(event) { + if (this.relationEventIds.has(event.getId())) { + return; + } + const relation = event.getRelation(); + if (!relation) { + _logger.logger.error("Event must have relation info"); + return; + } + const relationType = relation.rel_type; + const eventType = event.getType(); + if (this.relationType !== relationType || !matchesEventType(eventType, this.eventType, this.altEventTypes)) { + _logger.logger.error("Event relation info doesn't match this container"); + return; + } + + // If the event is in the process of being sent, listen for cancellation + // so we can remove the event from the collection. + if (event.isSending()) { + event.on(_event.MatrixEventEvent.Status, this.onEventStatus); + } + this.relations.add(event); + this.relationEventIds.add(event.getId()); + if (this.relationType === _event2.RelationType.Annotation) { + this.addAnnotationToAggregation(event); + } else if (this.relationType === _event2.RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) { + const lastReplacement = await this.getLastReplacement(); + this.targetEvent.makeReplaced(lastReplacement); + } + event.on(_event.MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); + this.emit(RelationsEvent.Add, event); + this.maybeEmitCreated(); + } + + /** + * Remove relation event from this collection. + * + * @param event - The relation event to remove. + */ + async removeEvent(event) { + if (!this.relations.has(event)) { + return; + } + this.relations.delete(event); + if (this.relationType === _event2.RelationType.Annotation) { + this.removeAnnotationFromAggregation(event); + } else if (this.relationType === _event2.RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) { + const lastReplacement = await this.getLastReplacement(); + this.targetEvent.makeReplaced(lastReplacement); + } + this.emit(RelationsEvent.Remove, event); + } + /** + * Get all relation events in this collection. + * + * These are currently in the order of insertion to this collection, which + * won't match timeline order in the case of scrollback. + * TODO: Tweak `addEvent` to insert correctly for scrollback. + * + * Relation events in insertion order. + */ + getRelations() { + return [...this.relations]; + } + addAnnotationToAggregation(event) { + const { + key + } = event.getRelation() ?? {}; + if (!key) return; + let eventsForKey = this.annotationsByKey[key]; + if (!eventsForKey) { + eventsForKey = this.annotationsByKey[key] = new Set(); + this.sortedAnnotationsByKey.push([key, eventsForKey]); + } + // Add the new event to the set for this key + eventsForKey.add(event); + // Re-sort the [key, events] pairs in descending order of event count + this.sortedAnnotationsByKey.sort((a, b) => { + const aEvents = a[1]; + const bEvents = b[1]; + return bEvents.size - aEvents.size; + }); + const sender = event.getSender(); + let eventsFromSender = this.annotationsBySender[sender]; + if (!eventsFromSender) { + eventsFromSender = this.annotationsBySender[sender] = new Set(); + } + // Add the new event to the set for this sender + eventsFromSender.add(event); + } + removeAnnotationFromAggregation(event) { + const { + key + } = event.getRelation() ?? {}; + if (!key) return; + const eventsForKey = this.annotationsByKey[key]; + if (eventsForKey) { + eventsForKey.delete(event); + + // Re-sort the [key, events] pairs in descending order of event count + this.sortedAnnotationsByKey.sort((a, b) => { + const aEvents = a[1]; + const bEvents = b[1]; + return bEvents.size - aEvents.size; + }); + } + const sender = event.getSender(); + const eventsFromSender = this.annotationsBySender[sender]; + if (eventsFromSender) { + eventsFromSender.delete(event); + } + } + /** + * Get all events in this collection grouped by key and sorted by descending + * event count in each group. + * + * This is currently only supported for the annotation relation type. + * + * An array of [key, events] pairs sorted by descending event count. + * The events are stored in a Set (which preserves insertion order). + */ + getSortedAnnotationsByKey() { + if (this.relationType !== _event2.RelationType.Annotation) { + // Other relation types are not grouped currently. + return null; + } + return this.sortedAnnotationsByKey; + } + + /** + * Get all events in this collection grouped by sender. + * + * This is currently only supported for the annotation relation type. + * + * An object with each relation sender as a key and the matching Set of + * events for that sender as a value. + */ + getAnnotationsBySender() { + if (this.relationType !== _event2.RelationType.Annotation) { + // Other relation types are not grouped currently. + return null; + } + return this.annotationsBySender; + } + + /** + * Returns the most recent (and allowed) m.replace relation, if any. + * + * This is currently only supported for the m.replace relation type, + * once the target event is known, see `addEvent`. + */ + async getLastReplacement() { + if (this.relationType !== _event2.RelationType.Replace) { + // Aggregating on last only makes sense for this relation type + return null; + } + if (!this.targetEvent) { + // Don't know which replacements to accept yet. + // This method shouldn't be called before the original + // event is known anyway. + return null; + } + + // the all-knowning server tells us that the event at some point had + // this timestamp for its replacement, so any following replacement should definitely not be less + const replaceRelation = this.targetEvent.getServerAggregatedRelation(_event2.RelationType.Replace); + const minTs = replaceRelation?.origin_server_ts; + const lastReplacement = this.getRelations().reduce((last, event) => { + if (event.getSender() !== this.targetEvent.getSender()) { + return last; + } + if (minTs && minTs > event.getTs()) { + return last; + } + if (last && last.getTs() > event.getTs()) { + return last; + } + return event; + }, null); + if (lastReplacement?.shouldAttemptDecryption() && this.client.isCryptoEnabled()) { + await lastReplacement.attemptDecryption(this.client.crypto); + } else if (lastReplacement?.isBeingDecrypted()) { + await lastReplacement.getDecryptionPromise(); + } + return lastReplacement; + } + + /* + * @param targetEvent - the event the relations are related to. + */ + async setTargetEvent(event) { + if (this.targetEvent) { + return; + } + this.targetEvent = event; + if (this.relationType === _event2.RelationType.Replace && !this.targetEvent.isState()) { + const replacement = await this.getLastReplacement(); + // this is the initial update, so only call it if we already have something + // to not emit Event.replaced needlessly + if (replacement) { + this.targetEvent.makeReplaced(replacement); + } + } + this.maybeEmitCreated(); + } + maybeEmitCreated() { + if (this.creationEmitted) { + return; + } + // Only emit we're "created" once we have a target event instance _and_ + // at least one related event. + if (!this.targetEvent || !this.relations.size) { + return; + } + this.creationEmitted = true; + this.targetEvent.emit(_event.MatrixEventEvent.RelationsCreated, this.relationType, this.eventType); + } +} +exports.Relations = Relations;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/room-member.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/room-member.js new file mode 100644 index 0000000000..53c01065de --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/room-member.js @@ -0,0 +1,363 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.RoomMemberEvent = exports.RoomMember = void 0; +var _contentRepo = require("../content-repo"); +var _utils = require("../utils"); +var _logger = require("../logger"); +var _typedEventEmitter = require("./typed-event-emitter"); +var _event = require("../@types/event"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2015 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +let RoomMemberEvent = /*#__PURE__*/function (RoomMemberEvent) { + RoomMemberEvent["Membership"] = "RoomMember.membership"; + RoomMemberEvent["Name"] = "RoomMember.name"; + RoomMemberEvent["PowerLevel"] = "RoomMember.powerLevel"; + RoomMemberEvent["Typing"] = "RoomMember.typing"; + return RoomMemberEvent; +}({}); +exports.RoomMemberEvent = RoomMemberEvent; +class RoomMember extends _typedEventEmitter.TypedEventEmitter { + /** + * Construct a new room member. + * + * @param roomId - The room ID of the member. + * @param userId - The user ID of the member. + */ + constructor(roomId, userId) { + super(); + this.roomId = roomId; + this.userId = userId; + _defineProperty(this, "_isOutOfBand", false); + _defineProperty(this, "modified", -1); + _defineProperty(this, "requestedProfileInfo", false); + // used by sync.ts + // XXX these should be read-only + /** + * True if the room member is currently typing. + */ + _defineProperty(this, "typing", false); + /** + * The human-readable name for this room member. This will be + * disambiguated with a suffix of " (\@user_id:matrix.org)" if another member shares the + * same displayname. + */ + _defineProperty(this, "name", void 0); + /** + * The ambiguous displayname of this room member. + */ + _defineProperty(this, "rawDisplayName", void 0); + /** + * The power level for this room member. + */ + _defineProperty(this, "powerLevel", 0); + /** + * The normalised power level (0-100) for this room member. + */ + _defineProperty(this, "powerLevelNorm", 0); + /** + * The User object for this room member, if one exists. + */ + _defineProperty(this, "user", void 0); + /** + * The membership state for this room member e.g. 'join'. + */ + _defineProperty(this, "membership", void 0); + /** + * True if the member's name is disambiguated. + */ + _defineProperty(this, "disambiguate", false); + /** + * The events describing this RoomMember. + */ + _defineProperty(this, "events", {}); + this.name = userId; + this.rawDisplayName = userId; + this.updateModifiedTime(); + } + + /** + * Mark the member as coming from a channel that is not sync + */ + markOutOfBand() { + this._isOutOfBand = true; + } + + /** + * @returns 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. + */ + isOutOfBand() { + return this._isOutOfBand; + } + + /** + * Update this room member's membership event. May fire "RoomMember.name" if + * this event updates this member's name. + * @param event - The `m.room.member` event + * @param roomState - Optional. The room state to take into account + * when calculating (e.g. for disambiguating users with the same name). + * + * @remarks + * Fires {@link RoomMemberEvent.Name} + * Fires {@link RoomMemberEvent.Membership} + */ + setMembershipEvent(event, roomState) { + const displayName = event.getDirectionalContent().displayname ?? ""; + if (event.getType() !== _event.EventType.RoomMember) { + return; + } + this._isOutOfBand = false; + this.events.member = event; + const oldMembership = this.membership; + this.membership = event.getDirectionalContent().membership; + if (this.membership === undefined) { + // logging to diagnose https://github.com/vector-im/element-web/issues/20962 + // (logs event content, although only of membership events) + _logger.logger.trace(`membership event with membership undefined (forwardLooking: ${event.forwardLooking})!`, event.getContent(), `prevcontent is `, event.getPrevContent()); + } + this.disambiguate = shouldDisambiguate(this.userId, displayName, roomState); + const oldName = this.name; + this.name = calculateDisplayName(this.userId, displayName, this.disambiguate); + + // not quite raw: we strip direction override chars so it can safely be inserted into + // blocks of text without breaking the text direction + this.rawDisplayName = (0, _utils.removeDirectionOverrideChars)(event.getDirectionalContent().displayname ?? ""); + if (!this.rawDisplayName || !(0, _utils.removeHiddenChars)(this.rawDisplayName)) { + this.rawDisplayName = this.userId; + } + if (oldMembership !== this.membership) { + this.updateModifiedTime(); + this.emit(RoomMemberEvent.Membership, event, this, oldMembership); + } + if (oldName !== this.name) { + this.updateModifiedTime(); + this.emit(RoomMemberEvent.Name, event, this, oldName); + } + } + + /** + * Update this room member's power level event. May fire + * "RoomMember.powerLevel" if this event updates this member's power levels. + * @param powerLevelEvent - The `m.room.power_levels` event + * + * @remarks + * Fires {@link RoomMemberEvent.PowerLevel} + */ + setPowerLevelEvent(powerLevelEvent) { + if (powerLevelEvent.getType() !== _event.EventType.RoomPowerLevels || powerLevelEvent.getStateKey() !== "") { + return; + } + const evContent = powerLevelEvent.getDirectionalContent(); + let maxLevel = evContent.users_default || 0; + const users = evContent.users || {}; + Object.values(users).forEach(lvl => { + maxLevel = Math.max(maxLevel, lvl); + }); + const oldPowerLevel = this.powerLevel; + const oldPowerLevelNorm = this.powerLevelNorm; + if (users[this.userId] !== undefined && Number.isInteger(users[this.userId])) { + this.powerLevel = users[this.userId]; + } else if (evContent.users_default !== undefined) { + this.powerLevel = evContent.users_default; + } else { + this.powerLevel = 0; + } + this.powerLevelNorm = 0; + if (maxLevel > 0) { + this.powerLevelNorm = this.powerLevel * 100 / maxLevel; + } + + // emit for changes in powerLevelNorm as well (since the app will need to + // redraw everyone's level if the max has changed) + if (oldPowerLevel !== this.powerLevel || oldPowerLevelNorm !== this.powerLevelNorm) { + this.updateModifiedTime(); + this.emit(RoomMemberEvent.PowerLevel, powerLevelEvent, this); + } + } + + /** + * Update this room member's typing event. May fire "RoomMember.typing" if + * this event changes this member's typing state. + * @param event - The typing event + * + * @remarks + * Fires {@link RoomMemberEvent.Typing} + */ + setTypingEvent(event) { + if (event.getType() !== "m.typing") { + return; + } + const oldTyping = this.typing; + this.typing = false; + const typingList = event.getContent().user_ids; + if (!Array.isArray(typingList)) { + // malformed event :/ bail early. TODO: whine? + return; + } + if (typingList.indexOf(this.userId) !== -1) { + this.typing = true; + } + if (oldTyping !== this.typing) { + this.updateModifiedTime(); + this.emit(RoomMemberEvent.Typing, event, this); + } + } + + /** + * Update the last modified time to the current time. + */ + updateModifiedTime() { + this.modified = Date.now(); + } + + /** + * Get the timestamp when this RoomMember was last updated. This timestamp is + * updated when properties on this RoomMember are updated. + * It is updated <i>before</i> firing events. + * @returns The timestamp + */ + getLastModifiedTime() { + return this.modified; + } + isKicked() { + return this.membership === "leave" && this.events.member !== undefined && 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 + * @returns user id of the inviter + */ + getDMInviter() { + // 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 baseUrl - The base homeserver URL See + * {@link MatrixClient#getHomeserverUrl}. + * @param width - The desired width of the thumbnail. + * @param height - The desired height of the thumbnail. + * @param resizeMethod - The thumbnail resize method to use, either + * "crop" or "scale". + * @param allowDefault - (optional) Passing false causes this method to + * return null if the user has no avatar image. Otherwise, a default image URL + * will be returned. Default: true. (Deprecated) + * @param allowDirectLinks - (optional) If true, the avatar URL will be + * returned even if it is a direct hyperlink rather than a matrix content URL. + * If false, any non-matrix content URLs will be ignored. Setting this option to + * true will expose URLs that, if fetched, will leak information about the user + * to anyone who they share a room with. + * @returns the avatar URL or null. + */ + getAvatarUrl(baseUrl, width, height, resizeMethod, allowDefault = true, allowDirectLinks) { + const rawUrl = this.getMxcAvatarUrl(); + if (!rawUrl && !allowDefault) { + return null; + } + const httpUrl = (0, _contentRepo.getHttpUriForMxc)(baseUrl, rawUrl, width, height, resizeMethod, allowDirectLinks); + if (httpUrl) { + return httpUrl; + } + return null; + } + + /** + * get the mxc avatar url, either from a state event, or from a lazily loaded member + * @returns the mxc avatar url + */ + getMxcAvatarUrl() { + if (this.events.member) { + return this.events.member.getDirectionalContent().avatar_url; + } else if (this.user) { + return this.user.avatarUrl; + } + } +} +exports.RoomMember = RoomMember; +const MXID_PATTERN = /@.+:.+/; +const LTR_RTL_PATTERN = /[\u200E\u200F\u202A-\u202F]/; +function shouldDisambiguate(selfUserId, displayName, roomState) { + if (!displayName || displayName === selfUserId) return false; + + // First check if the displayname is something we consider truthy + // after stripping it of zero width characters and padding spaces + if (!(0, _utils.removeHiddenChars)(displayName)) return false; + if (!roomState) return false; + + // Next check if the name contains something that look like a mxid + // If it does, it may be someone trying to impersonate someone else + // Show full mxid in this case + if (MXID_PATTERN.test(displayName)) return true; + + // Also show mxid if the display name contains any LTR/RTL characters as these + // make it very difficult for us to find similar *looking* display names + // E.g "Mark" could be cloned by writing "kraM" but in RTL. + if (LTR_RTL_PATTERN.test(displayName)) return true; + + // Also show mxid if there are other people with the same or similar + // displayname, after hidden character removal. + const userIds = roomState.getUserIdsWithDisplayName(displayName); + if (userIds.some(u => u !== selfUserId)) return true; + return false; +} +function calculateDisplayName(selfUserId, displayName, disambiguate) { + if (!displayName || displayName === selfUserId) return selfUserId; + if (disambiguate) return (0, _utils.removeDirectionOverrideChars)(displayName) + " (" + selfUserId + ")"; + + // First check if the displayname is something we consider truthy + // after stripping it of zero width characters and padding spaces + if (!(0, _utils.removeHiddenChars)(displayName)) return selfUserId; + + // We always strip the direction override characters (LRO and RLO). + // These override the text direction for all subsequent characters + // in the paragraph so if display names contained these, they'd + // need to be wrapped in something to prevent this from leaking out + // (which we can do in HTML but not text) or we'd need to add + // control characters to the string to reset any overrides (eg. + // adding PDF characters at the end). As far as we can see, + // there should be no reason these would be necessary - rtl display + // names should flip into the correct direction automatically based on + // the characters, and you can still embed rtl in ltr or vice versa + // with the embed chars or marker chars. + return (0, _utils.removeDirectionOverrideChars)(displayName); +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/room-state.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/room-state.js new file mode 100644 index 0000000000..69d37911cf --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/room-state.js @@ -0,0 +1,931 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.RoomStateEvent = exports.RoomState = void 0; +var _roomMember = require("./room-member"); +var _logger = require("../logger"); +var _utils = require("../utils"); +var _event = require("../@types/event"); +var _event2 = require("./event"); +var _partials = require("../@types/partials"); +var _typedEventEmitter = require("./typed-event-emitter"); +var _beacon = require("./beacon"); +var _ReEmitter = require("../ReEmitter"); +var _beacon2 = require("../@types/beacon"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2015 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +// possible statuses for out-of-band member loading +var OobStatus = /*#__PURE__*/function (OobStatus) { + OobStatus[OobStatus["NotStarted"] = 0] = "NotStarted"; + OobStatus[OobStatus["InProgress"] = 1] = "InProgress"; + OobStatus[OobStatus["Finished"] = 2] = "Finished"; + return OobStatus; +}(OobStatus || {}); +let RoomStateEvent = /*#__PURE__*/function (RoomStateEvent) { + RoomStateEvent["Events"] = "RoomState.events"; + RoomStateEvent["Members"] = "RoomState.members"; + RoomStateEvent["NewMember"] = "RoomState.newMember"; + RoomStateEvent["Update"] = "RoomState.update"; + RoomStateEvent["BeaconLiveness"] = "RoomState.BeaconLiveness"; + RoomStateEvent["Marker"] = "RoomState.Marker"; + return RoomStateEvent; +}({}); +exports.RoomStateEvent = RoomStateEvent; +class RoomState extends _typedEventEmitter.TypedEventEmitter { + /** + * Construct room state. + * + * Room State represents the state of the room at a given point. + * It can be mutated by adding state events to it. + * There are two types of room member associated with a state event: + * normal member objects (accessed via getMember/getMembers) which mutate + * with the state to represent the current state of that room/user, e.g. + * the object returned by `getMember('@bob:example.com')` will mutate to + * get a different display name if Bob later changes his display name + * in the room. + * There are also 'sentinel' members (accessed via getSentinelMember). + * These also represent the state of room members at the point in time + * represented by the RoomState object, but unlike objects from getMember, + * sentinel objects will always represent the room state as at the time + * getSentinelMember was called, so if Bob subsequently changes his display + * name, a room member object previously acquired with getSentinelMember + * will still have his old display name. Calling getSentinelMember again + * after the display name change will return a new RoomMember object + * with Bob's new display name. + * + * @param roomId - Optional. The ID of the room which has this state. + * If none is specified it just tracks paginationTokens, useful for notifTimelineSet + * @param 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. + */ + constructor(roomId, oobMemberFlags = { + status: OobStatus.NotStarted + }) { + super(); + this.roomId = roomId; + this.oobMemberFlags = oobMemberFlags; + _defineProperty(this, "reEmitter", new _ReEmitter.TypedReEmitter(this)); + _defineProperty(this, "sentinels", {}); + // userId: RoomMember + // stores fuzzy matches to a list of userIDs (applies utils.removeHiddenChars to keys) + _defineProperty(this, "displayNameToUserIds", new Map()); + _defineProperty(this, "userIdsToDisplayNames", {}); + _defineProperty(this, "tokenToInvite", {}); + // 3pid invite state_key to m.room.member invite + _defineProperty(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 + _defineProperty(this, "summaryJoinedMemberCount", null); + // same for invited member count + _defineProperty(this, "invitedMemberCount", null); + _defineProperty(this, "summaryInvitedMemberCount", null); + _defineProperty(this, "modified", -1); + // XXX: Should be read-only + // The room member dictionary, keyed on the user's ID. + _defineProperty(this, "members", {}); + // userId: RoomMember + // The state events dictionary, keyed on the event type and then the state_key value. + _defineProperty(this, "events", new Map()); + // Map<eventType, Map<stateKey, MatrixEvent>> + // The pagination token for this state. + _defineProperty(this, "paginationToken", null); + _defineProperty(this, "beacons", new Map()); + _defineProperty(this, "_liveBeaconIds", []); + this.updateModifiedTime(); + } + + /** + * Returns the number of joined members in this room + * This method caches the result. + * @returns The number of members in this room whose membership is 'join' + */ + getJoinedMemberCount() { + if (this.summaryJoinedMemberCount !== null) { + return this.summaryJoinedMemberCount; + } + if (this.joinedMemberCount === null) { + this.joinedMemberCount = this.getMembers().reduce((count, m) => { + return m.membership === "join" ? count + 1 : count; + }, 0); + } + return this.joinedMemberCount; + } + + /** + * Set the joined member count explicitly (like from summary part of the sync response) + * @param count - the amount of joined members + */ + setJoinedMemberCount(count) { + this.summaryJoinedMemberCount = count; + } + + /** + * Returns the number of invited members in this room + * @returns The number of members in this room whose membership is 'invite' + */ + getInvitedMemberCount() { + 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 count - the amount of invited members + */ + setInvitedMemberCount(count) { + this.summaryInvitedMemberCount = count; + } + + /** + * Get all RoomMembers in this room. + * @returns A list of RoomMembers. + */ + getMembers() { + return Object.values(this.members); + } + + /** + * Get all RoomMembers in this room, excluding the user IDs provided. + * @param excludedIds - The user IDs to exclude. + * @returns A list of RoomMembers. + */ + getMembersExcept(excludedIds) { + return this.getMembers().filter(m => !excludedIds.includes(m.userId)); + } + + /** + * Get a room member by their user ID. + * @param userId - The room member's user ID. + * @returns The member or null if they do not exist. + */ + getMember(userId) { + return this.members[userId] || null; + } + + /** + * Get a room member whose properties will not change with this room state. You + * typically want this if you want to attach a RoomMember to a MatrixEvent which + * may no longer be represented correctly by Room.currentState or Room.oldState. + * The term 'sentinel' refers to the fact that this RoomMember is an unchanging + * guardian for state at this particular point in time. + * @param userId - The room member's user ID. + * @returns The member or null if they do not exist. + */ + getSentinelMember(userId) { + if (!userId) return null; + let sentinel = this.sentinels[userId]; + if (sentinel === undefined) { + sentinel = new _roomMember.RoomMember(this.roomId, userId); + const member = this.members[userId]; + if (member?.events.member) { + sentinel.setMembershipEvent(member.events.member, this); + } + this.sentinels[userId] = sentinel; + } + return sentinel; + } + + /** + * Get state events from the state of the room. + * @param eventType - The event type of the state event. + * @param stateKey - Optional. The state_key of the state event. If + * this is `undefined` then all matching state events will be + * returned. + * @returns A list of events if state_key was + * `undefined`, else a single event (or null if no match found). + */ + + getStateEvents(eventType, stateKey) { + if (!this.events.has(eventType)) { + // no match + return stateKey === undefined ? [] : null; + } + if (stateKey === undefined) { + // return all values + return Array.from(this.events.get(eventType).values()); + } + const event = this.events.get(eventType).get(stateKey); + return event ? event : null; + } + get hasLiveBeacons() { + return !!this.liveBeaconIds?.length; + } + get liveBeaconIds() { + return this._liveBeaconIds; + } + + /** + * Creates a copy of this room state so that mutations to either won't affect the other. + * @returns the copy of the room state + */ + clone() { + const copy = new RoomState(this.roomId, this.oobMemberFlags); + + // Ugly hack: because setStateEvents will mark + // members as susperseding future out of bound members + // if loading is in progress (through oobMemberFlags) + // since these are not new members, we're merely copying them + // set the status to not started + // after copying, we set back the status + const status = this.oobMemberFlags.status; + this.oobMemberFlags.status = OobStatus.NotStarted; + Array.from(this.events.values()).forEach(eventsByStateKey => { + copy.setStateEvents(Array.from(eventsByStateKey.values())); + }); + + // Ugly hack: see above + this.oobMemberFlags.status = status; + if (this.summaryInvitedMemberCount !== null) { + copy.setInvitedMemberCount(this.getInvitedMemberCount()); + } + if (this.summaryJoinedMemberCount !== null) { + copy.setJoinedMemberCount(this.getJoinedMemberCount()); + } + + // copy out of band flags if needed + if (this.oobMemberFlags.status == OobStatus.Finished) { + // copy markOutOfBand flags + this.getMembers().forEach(member => { + if (member.isOutOfBand()) { + copy.getMember(member.userId)?.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 events - state events to prepend + */ + setUnknownStateEvents(events) { + const unknownStateEvents = events.filter(event => { + return !this.events.has(event.getType()) || !this.events.get(event.getType()).has(event.getStateKey()); + }); + this.setStateEvents(unknownStateEvents); + } + + /** + * Add an array of one or more state MatrixEvents, overwriting any existing + * state with the same `{type, stateKey}` tuple. Will fire "RoomState.events" + * for every event added. May fire "RoomState.members" if there are + * `m.room.member` events. May fire "RoomStateEvent.Marker" if there are + * `UNSTABLE_MSC2716_MARKER` events. + * @param stateEvents - a list of state events for this room. + * + * @remarks + * Fires {@link RoomStateEvent.Members} + * Fires {@link RoomStateEvent.NewMember} + * Fires {@link RoomStateEvent.Events} + * Fires {@link RoomStateEvent.Marker} + */ + setStateEvents(stateEvents, markerFoundOptions) { + this.updateModifiedTime(); + + // update the core event dict + stateEvents.forEach(event => { + if (event.getRoomId() !== this.roomId || !event.isState()) return; + if (_beacon2.M_BEACON_INFO.matches(event.getType())) { + this.setBeacon(event); + } + const lastStateEvent = this.getStateEventMatching(event); + this.setStateEvent(event); + if (event.getType() === _event.EventType.RoomMember) { + this.updateDisplayNameCache(event.getStateKey(), event.getContent().displayname ?? ""); + this.updateThirdPartyTokenCache(event); + } + this.emit(RoomStateEvent.Events, event, this, lastStateEvent); + }); + this.onBeaconLivenessChange(); + // update higher level data structures. This needs to be done AFTER the + // core event dict as these structures may depend on other state events in + // the given array (e.g. disambiguating display names in one go to do both + // clashing names rather than progressively which only catches 1 of them). + stateEvents.forEach(event => { + if (event.getRoomId() !== this.roomId || !event.isState()) return; + if (event.getType() === _event.EventType.RoomMember) { + const userId = event.getStateKey(); + + // leave events apparently elide the displayname or avatar_url, + // so let's fake one up so that we don't leak user ids + // into the timeline + if (event.getContent().membership === "leave" || event.getContent().membership === "ban") { + event.getContent().avatar_url = event.getContent().avatar_url || event.getPrevContent().avatar_url; + event.getContent().displayname = event.getContent().displayname || event.getPrevContent().displayname; + } + const member = this.getOrCreateMember(userId, event); + member.setMembershipEvent(event, this); + this.updateMember(member); + this.emit(RoomStateEvent.Members, event, this, member); + } else if (event.getType() === _event.EventType.RoomPowerLevels) { + // events with unknown state keys should be ignored + // and should not aggregate onto members power levels + if (event.getStateKey() !== "") { + return; + } + const members = Object.values(this.members); + members.forEach(member => { + // We only propagate `RoomState.members` event if the + // power levels has been changed + // large room suffer from large re-rendering especially when not needed + const oldLastModified = member.getLastModifiedTime(); + member.setPowerLevelEvent(event); + if (oldLastModified !== member.getLastModifiedTime()) { + this.emit(RoomStateEvent.Members, event, this, member); + } + }); + + // assume all our sentinels are now out-of-date + this.sentinels = {}; + } else if (_event.UNSTABLE_MSC2716_MARKER.matches(event.getType())) { + this.emit(RoomStateEvent.Marker, event, markerFoundOptions); + } + }); + this.emit(RoomStateEvent.Update, this); + } + async processBeaconEvents(events, matrixClient) { + if (!events.length || + // discard locations if we have no beacons + !this.beacons.size) { + return; + } + const beaconByEventIdDict = [...this.beacons.values()].reduce((dict, beacon) => { + dict[beacon.beaconInfoId] = beacon; + return dict; + }, {}); + const processBeaconRelation = (beaconInfoEventId, event) => { + if (!_beacon2.M_BEACON.matches(event.getType())) { + return; + } + const beacon = beaconByEventIdDict[beaconInfoEventId]; + if (beacon) { + beacon.addLocations([event]); + } + }; + for (const event of events) { + const relatedToEventId = event.getRelation()?.event_id; + // not related to a beacon we know about; discard + if (!relatedToEventId || !beaconByEventIdDict[relatedToEventId]) return; + if (!_beacon2.M_BEACON.matches(event.getType()) && !event.isEncrypted()) return; + try { + await matrixClient.decryptEventIfNeeded(event); + processBeaconRelation(relatedToEventId, event); + } catch { + if (event.isDecryptionFailure()) { + // add an event listener for once the event is decrypted. + event.once(_event2.MatrixEventEvent.Decrypted, async () => { + processBeaconRelation(relatedToEventId, event); + }); + } + } + } + } + + /** + * 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 userId - the id of the user to look up + * @param event - the membership event for the (new) member. Used to emit. + * @returns the member, existing or newly created. + * + * @remarks + * Fires {@link RoomStateEvent.NewMember} + */ + getOrCreateMember(userId, event) { + let member = this.members[userId]; + if (!member) { + member = new _roomMember.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(RoomStateEvent.NewMember, event, this, member); + } + return member; + } + setStateEvent(event) { + if (!this.events.has(event.getType())) { + this.events.set(event.getType(), new Map()); + } + this.events.get(event.getType()).set(event.getStateKey(), event); + } + + /** + * @experimental + */ + setBeacon(event) { + const beaconIdentifier = (0, _beacon.getBeaconInfoIdentifier)(event); + if (this.beacons.has(beaconIdentifier)) { + const beacon = this.beacons.get(beaconIdentifier); + if (event.isRedacted()) { + if (beacon.beaconInfoId === event.getRedactionEvent()?.redacts) { + beacon.destroy(); + this.beacons.delete(beaconIdentifier); + } + return; + } + return beacon.update(event); + } + if (event.isRedacted()) { + return; + } + const beacon = new _beacon.Beacon(event); + this.reEmitter.reEmit(beacon, [_beacon.BeaconEvent.New, _beacon.BeaconEvent.Update, _beacon.BeaconEvent.Destroy, _beacon.BeaconEvent.LivenessChange]); + this.emit(_beacon.BeaconEvent.New, event, beacon); + beacon.on(_beacon.BeaconEvent.LivenessChange, this.onBeaconLivenessChange.bind(this)); + beacon.on(_beacon.BeaconEvent.Destroy, this.onBeaconLivenessChange.bind(this)); + this.beacons.set(beacon.identifier, beacon); + } + + /** + * @experimental + * Check liveness of room beacons + * emit RoomStateEvent.BeaconLiveness event + */ + onBeaconLivenessChange() { + this._liveBeaconIds = Array.from(this.beacons.values()).filter(beacon => beacon.isLive).map(beacon => beacon.identifier); + this.emit(RoomStateEvent.BeaconLiveness, this, this.hasLiveBeacons); + } + getStateEventMatching(event) { + return this.events.get(event.getType())?.get(event.getStateKey()) ?? null; + } + updateMember(member) { + // this member may have a power level already, so set it. + const pwrLvlEvent = this.getStateEvents(_event.EventType.RoomPowerLevels, ""); + if (pwrLvlEvent) { + member.setPowerLevelEvent(pwrLvlEvent); + } + + // blow away the sentinel which is now outdated + delete this.sentinels[member.userId]; + this.members[member.userId] = member; + this.joinedMemberCount = null; + this.invitedMemberCount = null; + } + + /** + * Get the out-of-band members loading state, whether loading is needed or not. + * Note that loading might be in progress and hence isn't needed. + * @returns whether or not the members of this room need to be loaded + */ + needsOutOfBandMembers() { + return this.oobMemberFlags.status === OobStatus.NotStarted; + } + + /** + * Check if loading of out-of-band-members has completed + * + * @returns true if the full membership list of this room has been loaded. False if it is not started or is in + * progress. + */ + outOfBandMembersReady() { + return this.oobMemberFlags.status === OobStatus.Finished; + } + + /** + * Mark this room state as waiting for out-of-band members, + * ensuring it doesn't ask for them to be requested again + * through needsOutOfBandMembers + */ + markOutOfBandMembersStarted() { + if (this.oobMemberFlags.status !== OobStatus.NotStarted) { + return; + } + this.oobMemberFlags.status = OobStatus.InProgress; + } + + /** + * Mark this room state as having failed to fetch out-of-band members + */ + markOutOfBandMembersFailed() { + if (this.oobMemberFlags.status !== OobStatus.InProgress) { + return; + } + this.oobMemberFlags.status = OobStatus.NotStarted; + } + + /** + * Clears the loaded out-of-band members + */ + clearOutOfBandMembers() { + let count = 0; + Object.keys(this.members).forEach(userId => { + const member = this.members[userId]; + if (member.isOutOfBand()) { + ++count; + delete this.members[userId]; + } + }); + _logger.logger.log(`LL: RoomState removed ${count} members...`); + this.oobMemberFlags.status = OobStatus.NotStarted; + } + + /** + * Sets the loaded out-of-band members. + * @param stateEvents - array of membership state events + */ + setOutOfBandMembers(stateEvents) { + _logger.logger.log(`LL: RoomState about to set ${stateEvents.length} OOB members ...`); + if (this.oobMemberFlags.status !== OobStatus.InProgress) { + return; + } + _logger.logger.log(`LL: RoomState put in finished state ...`); + this.oobMemberFlags.status = OobStatus.Finished; + stateEvents.forEach(e => this.setOutOfBandMember(e)); + this.emit(RoomStateEvent.Update, this); + } + + /** + * Sets a single out of band member, used by both setOutOfBandMembers and clone + * @param stateEvent - membership state event + */ + setOutOfBandMember(stateEvent) { + if (stateEvent.getType() !== _event.EventType.RoomMember) { + return; + } + const userId = stateEvent.getStateKey(); + const existingMember = this.getMember(userId); + // never replace members received as part of the sync + if (existingMember && !existingMember.isOutOfBand()) { + return; + } + const member = this.getOrCreateMember(userId, stateEvent); + member.setMembershipEvent(stateEvent, this); + // needed to know which members need to be stored seperately + // as they are not part of the sync accumulator + // this is cleared by setMembershipEvent so when it's updated through /sync + member.markOutOfBand(); + this.updateDisplayNameCache(member.userId, member.name); + this.setStateEvent(stateEvent); + this.updateMember(member); + this.emit(RoomStateEvent.Members, stateEvent, this, member); + } + + /** + * Set the current typing event for this room. + * @param event - The typing event + */ + setTypingEvent(event) { + Object.values(this.members).forEach(function (member) { + member.setTypingEvent(event); + }); + } + + /** + * Get the m.room.member event which has the given third party invite token. + * + * @param token - The token + * @returns The m.room.member event or null + */ + getInviteForThreePidToken(token) { + return this.tokenToInvite[token] || null; + } + + /** + * Update the last modified time to the current time. + */ + updateModifiedTime() { + this.modified = Date.now(); + } + + /** + * Get the timestamp when this room state was last updated. This timestamp is + * updated when this object has received new state events. + * @returns The timestamp + */ + getLastModifiedTime() { + return this.modified; + } + + /** + * Get user IDs with the specified or similar display names. + * @param displayName - The display name to get user IDs from. + * @returns An array of user IDs or an empty array. + */ + getUserIdsWithDisplayName(displayName) { + return this.displayNameToUserIds.get((0, _utils.removeHiddenChars)(displayName)) ?? []; + } + + /** + * Returns true if userId is in room, event is not redacted and either sender of + * mxEvent or has power level sufficient to redact events other than their own. + * @param mxEvent - The event to test permission for + * @param userId - The user ID of the user to test permission for + * @returns true if the given used ID can redact given event + */ + maySendRedactionForEvent(mxEvent, userId) { + const member = this.getMember(userId); + if (!member || member.membership === "leave") return false; + if (mxEvent.status || mxEvent.isRedacted()) return false; + + // The user may have been the sender, but they can't redact their own message + // if redactions are blocked. + const canRedact = this.maySendEvent(_event.EventType.RoomRedaction, userId); + if (mxEvent.getSender() === userId) return canRedact; + return this.hasSufficientPowerLevelFor("redact", member.powerLevel); + } + + /** + * Returns true if the given power level is sufficient for action + * @param action - The type of power level to check + * @param powerLevel - The power level of the member + * @returns true if the given power level is sufficient + */ + hasSufficientPowerLevelFor(action, powerLevel) { + const powerLevelsEvent = this.getStateEvents(_event.EventType.RoomPowerLevels, ""); + let powerLevels = {}; + if (powerLevelsEvent) { + powerLevels = powerLevelsEvent.getContent(); + } + let requiredLevel = 50; + if ((0, _utils.isNumber)(powerLevels[action])) { + requiredLevel = powerLevels[action]; + } + return powerLevel >= requiredLevel; + } + + /** + * Short-form for maySendEvent('m.room.message', userId) + * @param userId - The user ID of the user to test permission for + * @returns true if the given user ID should be permitted to send + * message events into the given room. + */ + maySendMessage(userId) { + return this.maySendEventOfType(_event.EventType.RoomMessage, userId, false); + } + + /** + * Returns true if the given user ID has permission to send a normal + * event of type `eventType` into this room. + * @param eventType - The type of event to test + * @param userId - The user ID of the user to test permission for + * @returns true if the given user ID should be permitted to send + * the given type of event into this room, + * according to the room's state. + */ + maySendEvent(eventType, userId) { + return this.maySendEventOfType(eventType, userId, false); + } + + /** + * Returns true if the given MatrixClient has permission to send a state + * event of type `stateEventType` into this room. + * @param stateEventType - The type of state events to test + * @param cli - The client to test permission for + * @returns true if the given client should be permitted to send + * the given type of state event into this room, + * according to the room's state. + */ + mayClientSendStateEvent(stateEventType, cli) { + if (cli.isGuest() || !cli.credentials.userId) { + return false; + } + return this.maySendStateEvent(stateEventType, cli.credentials.userId); + } + + /** + * Returns true if the given user ID has permission to send a state + * event of type `stateEventType` into this room. + * @param stateEventType - The type of state events to test + * @param userId - The user ID of the user to test permission for + * @returns true if the given user ID should be permitted to send + * the given type of state event into this room, + * according to the room's state. + */ + maySendStateEvent(stateEventType, userId) { + return this.maySendEventOfType(stateEventType, userId, true); + } + + /** + * Returns true if the given user ID has permission to send a normal or state + * event of type `eventType` into this room. + * @param eventType - The type of event to test + * @param userId - The user ID of the user to test permission for + * @param state - If true, tests if the user may send a state + event of this type. Otherwise tests whether + they may send a regular event. + * @returns true if the given user ID should be permitted to send + * the given type of event into this room, + * according to the room's state. + */ + maySendEventOfType(eventType, userId, state) { + const powerLevelsEvent = this.getStateEvents(_event.EventType.RoomPowerLevels, ""); + let powerLevels; + let eventsLevels = {}; + let stateDefault = 0; + let eventsDefault = 0; + let powerLevel = 0; + if (powerLevelsEvent) { + powerLevels = powerLevelsEvent.getContent(); + eventsLevels = powerLevels.events || {}; + if (Number.isSafeInteger(powerLevels.state_default)) { + stateDefault = powerLevels.state_default; + } else { + stateDefault = 50; + } + const userPowerLevel = powerLevels.users && powerLevels.users[userId]; + if (Number.isSafeInteger(userPowerLevel)) { + powerLevel = userPowerLevel; + } else if (Number.isSafeInteger(powerLevels.users_default)) { + powerLevel = powerLevels.users_default; + } + if (Number.isSafeInteger(powerLevels.events_default)) { + eventsDefault = powerLevels.events_default; + } + } + let requiredLevel = state ? stateDefault : eventsDefault; + if (Number.isSafeInteger(eventsLevels[eventType])) { + requiredLevel = eventsLevels[eventType]; + } + return powerLevel >= requiredLevel; + } + + /** + * Returns true if the given user ID has permission to trigger notification + * of type `notifLevelKey` + * @param notifLevelKey - The level of notification to test (eg. 'room') + * @param userId - The user ID of the user to test permission for + * @returns true if the given user ID has permission to trigger a + * notification of this type. + */ + mayTriggerNotifOfType(notifLevelKey, userId) { + const member = this.getMember(userId); + if (!member) { + return false; + } + const powerLevelsEvent = this.getStateEvents(_event.EventType.RoomPowerLevels, ""); + let notifLevel = 50; + if (powerLevelsEvent && powerLevelsEvent.getContent() && powerLevelsEvent.getContent().notifications && (0, _utils.isNumber)(powerLevelsEvent.getContent().notifications[notifLevelKey])) { + notifLevel = powerLevelsEvent.getContent().notifications[notifLevelKey]; + } + return member.powerLevel >= notifLevel; + } + + /** + * Returns the join rule based on the m.room.join_rule state event, defaulting to `invite`. + * @returns the join_rule applied to this room + */ + getJoinRule() { + const joinRuleEvent = this.getStateEvents(_event.EventType.RoomJoinRules, ""); + const joinRuleContent = joinRuleEvent?.getContent() ?? {}; + return joinRuleContent["join_rule"] || _partials.JoinRule.Invite; + } + + /** + * Returns the history visibility based on the m.room.history_visibility state event, defaulting to `shared`. + * @returns the history_visibility applied to this room + */ + getHistoryVisibility() { + const historyVisibilityEvent = this.getStateEvents(_event.EventType.RoomHistoryVisibility, ""); + const historyVisibilityContent = historyVisibilityEvent?.getContent() ?? {}; + return historyVisibilityContent["history_visibility"] || _partials.HistoryVisibility.Shared; + } + + /** + * Returns the guest access based on the m.room.guest_access state event, defaulting to `shared`. + * @returns the guest_access applied to this room + */ + getGuestAccess() { + const guestAccessEvent = this.getStateEvents(_event.EventType.RoomGuestAccess, ""); + const guestAccessContent = guestAccessEvent?.getContent() ?? {}; + return guestAccessContent["guest_access"] || _partials.GuestAccess.Forbidden; + } + + /** + * Find the predecessor room based on this room state. + * + * @param msc3946ProcessDynamicPredecessor - if true, look for an + * m.room.predecessor state event and use it if found (MSC3946). + * @returns null if this room has no predecessor. Otherwise, returns + * the roomId, last eventId and viaServers of the predecessor room. + * + * If msc3946ProcessDynamicPredecessor is true, use m.predecessor events + * as well as m.room.create events to find predecessors. + * + * Note: if an m.predecessor event is used, eventId may be undefined + * since last_known_event_id is optional. + * + * Note: viaServers may be undefined, and will definitely be undefined if + * this predecessor comes from a RoomCreate event (rather than a + * RoomPredecessor, which has the optional via_servers property). + */ + findPredecessor(msc3946ProcessDynamicPredecessor = false) { + // Note: the tests for this function are against Room.findPredecessor, + // which just calls through to here. + + if (msc3946ProcessDynamicPredecessor) { + const predecessorEvent = this.getStateEvents(_event.EventType.RoomPredecessor, ""); + if (predecessorEvent) { + const content = predecessorEvent.getContent(); + const roomId = content.predecessor_room_id; + let eventId = content.last_known_event_id; + if (typeof eventId !== "string") { + eventId = undefined; + } + let viaServers = content.via_servers; + if (!Array.isArray(viaServers)) { + viaServers = undefined; + } + if (typeof roomId === "string") { + return { + roomId, + eventId, + viaServers + }; + } + } + } + const createEvent = this.getStateEvents(_event.EventType.RoomCreate, ""); + if (createEvent) { + const predecessor = createEvent.getContent()["predecessor"]; + if (predecessor) { + const roomId = predecessor["room_id"]; + if (typeof roomId === "string") { + let eventId = predecessor["event_id"]; + if (typeof eventId !== "string" || eventId === "") { + eventId = undefined; + } + return { + roomId, + eventId + }; + } + } + } + return null; + } + updateThirdPartyTokenCache(memberEvent) { + if (!memberEvent.getContent().third_party_invite) { + return; + } + const token = (memberEvent.getContent().third_party_invite.signed || {}).token; + if (!token) { + return; + } + const threePidInvite = this.getStateEvents(_event.EventType.RoomThirdPartyInvite, token); + if (!threePidInvite) { + return; + } + this.tokenToInvite[token] = memberEvent; + } + updateDisplayNameCache(userId, displayName) { + const oldName = this.userIdsToDisplayNames[userId]; + delete this.userIdsToDisplayNames[userId]; + if (oldName) { + // Remove the old name from the cache. + // We clobber the user_id > name lookup but the name -> [user_id] lookup + // means we need to remove that user ID from that array rather than nuking + // the lot. + const strippedOldName = (0, _utils.removeHiddenChars)(oldName); + const existingUserIds = this.displayNameToUserIds.get(strippedOldName); + if (existingUserIds) { + // remove this user ID from this array + const filteredUserIDs = existingUserIds.filter(id => id !== userId); + this.displayNameToUserIds.set(strippedOldName, filteredUserIDs); + } + } + this.userIdsToDisplayNames[userId] = displayName; + const strippedDisplayname = displayName && (0, _utils.removeHiddenChars)(displayName); + // an empty stripped displayname (undefined/'') will be set to MXID in room-member.js + if (strippedDisplayname) { + const arr = this.displayNameToUserIds.get(strippedDisplayname) ?? []; + arr.push(userId); + this.displayNameToUserIds.set(strippedDisplayname, arr); + } + } +} +exports.RoomState = RoomState;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/room-summary.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/room-summary.js new file mode 100644 index 0000000000..44296632b4 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/room-summary.js @@ -0,0 +1,34 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.RoomSummary = void 0; +/* +Copyright 2015 - 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Construct a new Room Summary. A summary can be used for display on a recent + * list, without having to load the entire room list into memory. + * @param roomId - Required. The ID of this room. + * @param info - Optional. The summary info. Additional keys are supported. + */ +class RoomSummary { + constructor(roomId, info) { + this.roomId = roomId; + } +} +exports.RoomSummary = RoomSummary;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/room.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/room.js new file mode 100644 index 0000000000..d1bc837971 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/room.js @@ -0,0 +1,3079 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.RoomNameType = exports.RoomEvent = exports.Room = exports.NotificationCountType = exports.KNOWN_SAFE_ROOM_VERSION = void 0; +var _matrixEventsSdk = require("matrix-events-sdk"); +var _eventTimelineSet = require("./event-timeline-set"); +var _eventTimeline = require("./event-timeline"); +var _contentRepo = require("../content-repo"); +var _utils = require("../utils"); +var _event = require("./event"); +var _eventStatus = require("./event-status"); +var _roomMember = require("./room-member"); +var _roomSummary = require("./room-summary"); +var _logger = require("../logger"); +var _ReEmitter = require("../ReEmitter"); +var _event2 = require("../@types/event"); +var _client = require("../client"); +var _filter = require("../filter"); +var _roomState = require("./room-state"); +var _beacon = require("./beacon"); +var _thread = require("./thread"); +var _read_receipts = require("../@types/read_receipts"); +var _relationsContainer = require("./relations-container"); +var _readReceipt = require("./read-receipt"); +var _poll = require("./poll"); +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2015 - 2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +// These constants are used as sane defaults when the homeserver doesn't support +// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be +// the same as the common default room version whereas SAFE_ROOM_VERSIONS are the +// room versions which are considered okay for people to run without being asked +// to upgrade (ie: "stable"). Eventually, we should remove these when all homeservers +// return an m.room_versions capability. +const KNOWN_SAFE_ROOM_VERSION = "10"; +exports.KNOWN_SAFE_ROOM_VERSION = KNOWN_SAFE_ROOM_VERSION; +const SAFE_ROOM_VERSIONS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]; +// When inserting a visibility event affecting event `eventId`, we +// need to scan through existing visibility events for `eventId`. +// In theory, this could take an unlimited amount of time if: +// +// - the visibility event was sent by a moderator; and +// - `eventId` already has many visibility changes (usually, it should +// be 2 or less); and +// - for some reason, the visibility changes are received out of order +// (usually, this shouldn't happen at all). +// +// For this reason, we limit the number of events to scan through, +// expecting that a broken visibility change for a single event in +// an extremely uncommon case (possibly a DoS) is a small +// price to pay to keep matrix-js-sdk responsive. +const MAX_NUMBER_OF_VISIBILITY_EVENTS_TO_SCAN_THROUGH = 30; +let NotificationCountType = /*#__PURE__*/function (NotificationCountType) { + NotificationCountType["Highlight"] = "highlight"; + NotificationCountType["Total"] = "total"; + return NotificationCountType; +}({}); +exports.NotificationCountType = NotificationCountType; +let RoomEvent = /*#__PURE__*/function (RoomEvent) { + RoomEvent["MyMembership"] = "Room.myMembership"; + RoomEvent["Tags"] = "Room.tags"; + RoomEvent["AccountData"] = "Room.accountData"; + RoomEvent["Receipt"] = "Room.receipt"; + RoomEvent["Name"] = "Room.name"; + RoomEvent["Redaction"] = "Room.redaction"; + RoomEvent["RedactionCancelled"] = "Room.redactionCancelled"; + RoomEvent["LocalEchoUpdated"] = "Room.localEchoUpdated"; + RoomEvent["Timeline"] = "Room.timeline"; + RoomEvent["TimelineReset"] = "Room.timelineReset"; + RoomEvent["TimelineRefresh"] = "Room.TimelineRefresh"; + RoomEvent["OldStateUpdated"] = "Room.OldStateUpdated"; + RoomEvent["CurrentStateUpdated"] = "Room.CurrentStateUpdated"; + RoomEvent["HistoryImportedWithinTimeline"] = "Room.historyImportedWithinTimeline"; + RoomEvent["UnreadNotifications"] = "Room.UnreadNotifications"; + return RoomEvent; +}({}); +exports.RoomEvent = RoomEvent; +class Room extends _readReceipt.ReadReceipt { + /** + * Construct a new Room. + * + * <p>For a room, we store an ordered sequence of timelines, which may or may not + * be continuous. Each timeline lists a series of events, as well as tracking + * the room state at the start and the end of the timeline. It also tracks + * forward and backward pagination tokens, as well as containing links to the + * next timeline in the sequence. + * + * <p>There is one special timeline - the 'live' timeline, which represents the + * timeline to which events are being added in real-time as they are received + * from the /sync API. Note that you should not retain references to this + * timeline - even if it is the current timeline right now, it may not remain + * so if the server gives us a timeline gap in /sync. + * + * <p>In order that we can find events from their ids later, we also maintain a + * map from event_id to timeline and index. + * + * @param roomId - Required. The ID of this room. + * @param client - Required. The client, used to lazy load members. + * @param myUserId - Required. The ID of the syncing user. + * @param opts - Configuration options + */ + constructor(roomId, client, myUserId, opts = {}) { + super(); + // In some cases, we add listeners for every displayed Matrix event, so it's + // common to have quite a few more than the default limit. + this.roomId = roomId; + this.client = client; + this.myUserId = myUserId; + this.opts = opts; + _defineProperty(this, "reEmitter", void 0); + _defineProperty(this, "txnToEvent", new Map()); + // Pending in-flight requests { string: MatrixEvent } + _defineProperty(this, "notificationCounts", {}); + _defineProperty(this, "threadNotifications", new Map()); + _defineProperty(this, "cachedThreadReadReceipts", new Map()); + // Useful to know at what point the current user has started using threads in this room + _defineProperty(this, "oldestThreadedReceiptTs", Infinity); + /** + * A record of the latest unthread receipts per user + * This is useful in determining whether a user has read a thread or not + */ + _defineProperty(this, "unthreadedReceipts", new Map()); + _defineProperty(this, "timelineSets", void 0); + _defineProperty(this, "polls", new Map()); + /** + * Empty array if the timeline sets have not been initialised. After initialisation: + * 0: All threads + * 1: Threads the current user has participated in + */ + _defineProperty(this, "threadsTimelineSets", []); + // any filtered timeline sets we're maintaining for this room + _defineProperty(this, "filteredTimelineSets", {}); + // filter_id: timelineSet + _defineProperty(this, "timelineNeedsRefresh", false); + _defineProperty(this, "pendingEventList", void 0); + // read by megolm via getter; boolean value - null indicates "use global value" + _defineProperty(this, "blacklistUnverifiedDevices", void 0); + _defineProperty(this, "selfMembership", void 0); + _defineProperty(this, "summaryHeroes", null); + // flags to stop logspam about missing m.room.create events + _defineProperty(this, "getTypeWarning", false); + _defineProperty(this, "getVersionWarning", false); + _defineProperty(this, "membersPromise", void 0); + // XXX: These should be read-only + /** + * The human-readable display name for this room. + */ + _defineProperty(this, "name", void 0); + /** + * The un-homoglyphed name for this room. + */ + _defineProperty(this, "normalizedName", void 0); + /** + * Dict of room tags; the keys are the tag name and the values + * are any metadata associated with the tag - e.g. `{ "fav" : { order: 1 } }` + */ + _defineProperty(this, "tags", {}); + // $tagName: { $metadata: $value } + /** + * accountData Dict of per-room account_data events; the keys are the + * event type and the values are the events. + */ + _defineProperty(this, "accountData", new Map()); + // $eventType: $event + /** + * The room summary. + */ + _defineProperty(this, "summary", null); + /** + * The live event timeline for this room, with the oldest event at index 0. + * + * @deprecated Present for backwards compatibility. + * Use getLiveTimeline().getEvents() instead + */ + _defineProperty(this, "timeline", void 0); + /** + * oldState The state of the room at the time of the oldest event in the live timeline. + * + * @deprecated Present for backwards compatibility. + * Use getLiveTimeline().getState(EventTimeline.BACKWARDS) instead + */ + _defineProperty(this, "oldState", void 0); + /** + * currentState The state of the room at the time of the newest event in the timeline. + * + * @deprecated Present for backwards compatibility. + * Use getLiveTimeline().getState(EventTimeline.FORWARDS) instead. + */ + _defineProperty(this, "currentState", void 0); + _defineProperty(this, "relations", new _relationsContainer.RelationsContainer(this.client, this)); + /** + * A collection of events known by the client + * This is not a comprehensive list of the threads that exist in this room + */ + _defineProperty(this, "threads", new Map()); + /** + * @deprecated This value is unreliable. It may not contain the last thread. + * Use {@link Room.getLastThread} instead. + */ + _defineProperty(this, "lastThread", void 0); + /** + * A mapping of eventId to all visibility changes to apply + * to the event, by chronological order, as per + * https://github.com/matrix-org/matrix-doc/pull/3531 + * + * # Invariants + * + * - within each list, all events are classed by + * chronological order; + * - all events are events such that + * `asVisibilityEvent()` returns a non-null `IVisibilityChange`; + * - within each list with key `eventId`, all events + * are in relation to `eventId`. + * + * @experimental + */ + _defineProperty(this, "visibilityEvents", new Map()); + _defineProperty(this, "threadTimelineSetsPromise", null); + _defineProperty(this, "threadsReady", false); + _defineProperty(this, "updateThreadRootEvents", (thread, toStartOfTimeline, recreateEvent) => { + if (thread.length) { + this.updateThreadRootEvent(this.threadsTimelineSets?.[0], thread, toStartOfTimeline, recreateEvent); + if (thread.hasCurrentUserParticipated) { + this.updateThreadRootEvent(this.threadsTimelineSets?.[1], thread, toStartOfTimeline, recreateEvent); + } + } + }); + _defineProperty(this, "updateThreadRootEvent", (timelineSet, thread, toStartOfTimeline, recreateEvent) => { + if (timelineSet && thread.rootEvent) { + if (recreateEvent) { + timelineSet.removeEvent(thread.id); + } + if (_thread.Thread.hasServerSideSupport) { + timelineSet.addLiveEvent(thread.rootEvent, { + duplicateStrategy: _eventTimelineSet.DuplicateStrategy.Replace, + fromCache: false, + roomState: this.currentState + }); + } else { + timelineSet.addEventToTimeline(thread.rootEvent, timelineSet.getLiveTimeline(), { + toStartOfTimeline + }); + } + } + }); + _defineProperty(this, "applyRedaction", event => { + if (event.isRedaction()) { + const redactId = event.event.redacts; + + // if we know about this event, redact its contents now. + const redactedEvent = redactId ? this.findEventById(redactId) : undefined; + if (redactedEvent) { + redactedEvent.makeRedacted(event); + + // If this is in the current state, replace it with the redacted version + if (redactedEvent.isState()) { + const currentStateEvent = this.currentState.getStateEvents(redactedEvent.getType(), redactedEvent.getStateKey()); + if (currentStateEvent?.getId() === redactedEvent.getId()) { + this.currentState.setStateEvents([redactedEvent]); + } + } + this.emit(RoomEvent.Redaction, event, this); + + // TODO: we stash user displaynames (among other things) in + // RoomMember objects which are then attached to other events + // (in the sender and target fields). We should get those + // RoomMember objects to update themselves when the events that + // they are based on are changed. + + // Remove any visibility change on this event. + this.visibilityEvents.delete(redactId); + + // If this event is a visibility change event, remove it from the + // list of visibility changes and update any event affected by it. + if (redactedEvent.isVisibilityEvent()) { + this.redactVisibilityChangeEvent(event); + } + } + + // FIXME: apply redactions to notification list + + // NB: We continue to add the redaction event to the timeline so + // clients can say "so and so redacted an event" if they wish to. Also + // this may be needed to trigger an update. + } + }); + this.setMaxListeners(100); + this.reEmitter = new _ReEmitter.TypedReEmitter(this); + opts.pendingEventOrdering = opts.pendingEventOrdering || _client.PendingEventOrdering.Chronological; + this.name = roomId; + this.normalizedName = roomId; + + // all our per-room timeline sets. the first one is the unfiltered ones; + // the subsequent ones are the filtered ones in no particular order. + this.timelineSets = [new _eventTimelineSet.EventTimelineSet(this, opts)]; + this.reEmitter.reEmit(this.getUnfilteredTimelineSet(), [RoomEvent.Timeline, RoomEvent.TimelineReset]); + this.fixUpLegacyTimelineFields(); + if (this.opts.pendingEventOrdering === _client.PendingEventOrdering.Detached) { + this.pendingEventList = []; + this.client.store.getPendingEvents(this.roomId).then(events => { + const mapper = this.client.getEventMapper({ + toDevice: false, + decrypt: false + }); + events.forEach(async serializedEvent => { + const event = mapper(serializedEvent); + await client.decryptEventIfNeeded(event); + event.setStatus(_eventStatus.EventStatus.NOT_SENT); + this.addPendingEvent(event, event.getTxnId()); + }); + }); + } + + // awaited by getEncryptionTargetMembers while room members are loading + if (!this.opts.lazyLoadMembers) { + this.membersPromise = Promise.resolve(false); + } else { + this.membersPromise = undefined; + } + } + async createThreadsTimelineSets() { + if (this.threadTimelineSetsPromise) { + return this.threadTimelineSetsPromise; + } + if (this.client?.supportsThreads()) { + try { + this.threadTimelineSetsPromise = Promise.all([this.createThreadTimelineSet(), this.createThreadTimelineSet(_thread.ThreadFilterType.My)]); + const timelineSets = await this.threadTimelineSetsPromise; + this.threadsTimelineSets[0] = timelineSets[0]; + this.threadsTimelineSets[1] = timelineSets[1]; + return timelineSets; + } catch (e) { + this.threadTimelineSetsPromise = null; + return null; + } + } + return null; + } + + /** + * Bulk decrypt critical events in a room + * + * Critical events represents the minimal set of events to decrypt + * for a typical UI to function properly + * + * - Last event of every room (to generate likely message preview) + * - All events up to the read receipt (to calculate an accurate notification count) + * + * @returns Signals when all events have been decrypted + */ + async decryptCriticalEvents() { + if (!this.client.isCryptoEnabled()) return; + const readReceiptEventId = this.getEventReadUpTo(this.client.getUserId(), true); + const events = this.getLiveTimeline().getEvents(); + const readReceiptTimelineIndex = events.findIndex(matrixEvent => { + return matrixEvent.event.event_id === readReceiptEventId; + }); + const decryptionPromises = events.slice(readReceiptTimelineIndex).reverse().map(event => this.client.decryptEventIfNeeded(event, { + isRetry: true + })); + await Promise.allSettled(decryptionPromises); + } + + /** + * Bulk decrypt events in a room + * + * @returns Signals when all events have been decrypted + */ + async decryptAllEvents() { + if (!this.client.isCryptoEnabled()) return; + const decryptionPromises = this.getUnfilteredTimelineSet().getLiveTimeline().getEvents().slice(0) // copy before reversing + .reverse().map(event => this.client.decryptEventIfNeeded(event, { + isRetry: true + })); + await Promise.allSettled(decryptionPromises); + } + + /** + * Gets the creator of the room + * @returns The creator of the room, or null if it could not be determined + */ + getCreator() { + const createEvent = this.currentState.getStateEvents(_event2.EventType.RoomCreate, ""); + return createEvent?.getContent()["creator"] ?? null; + } + + /** + * Gets the version of the room + * @returns The version of the room, or null if it could not be determined + */ + getVersion() { + const createEvent = this.currentState.getStateEvents(_event2.EventType.RoomCreate, ""); + if (!createEvent) { + if (!this.getVersionWarning) { + _logger.logger.warn("[getVersion] Room " + this.roomId + " does not have an m.room.create event"); + this.getVersionWarning = true; + } + return "1"; + } + return createEvent.getContent()["room_version"] ?? "1"; + } + + /** + * Determines whether this room needs to be upgraded to a new version + * @returns What version the room should be upgraded to, or null if + * the room does not require upgrading at this time. + * @deprecated Use #getRecommendedVersion() instead + */ + shouldUpgradeToVersion() { + // TODO: Remove this function. + // This makes assumptions about which versions are safe, and can easily + // be wrong. Instead, people are encouraged to use getRecommendedVersion + // which determines a safer value. This function doesn't use that function + // because this is not async-capable, and to avoid breaking the contract + // we're deprecating this. + + if (!SAFE_ROOM_VERSIONS.includes(this.getVersion())) { + return KNOWN_SAFE_ROOM_VERSION; + } + return null; + } + + /** + * Determines the recommended room version for the room. This returns an + * object with 3 properties: `version` as the new version the + * room should be upgraded to (may be the same as the current version); + * `needsUpgrade` to indicate if the room actually can be + * upgraded (ie: does the current version not match?); and `urgent` + * to indicate if the new version patches a vulnerability in a previous + * version. + * @returns + * Resolves to the version the room should be upgraded to. + */ + async getRecommendedVersion() { + const capabilities = await this.client.getCapabilities(); + let versionCap = capabilities["m.room_versions"]; + if (!versionCap) { + versionCap = { + default: KNOWN_SAFE_ROOM_VERSION, + available: {} + }; + for (const safeVer of SAFE_ROOM_VERSIONS) { + versionCap.available[safeVer] = _client.RoomVersionStability.Stable; + } + } + let result = this.checkVersionAgainstCapability(versionCap); + if (result.urgent && result.needsUpgrade) { + // Something doesn't feel right: we shouldn't need to update + // because the version we're on should be in the protocol's + // namespace. This usually means that the server was updated + // before the client was, making us think the newest possible + // room version is not stable. As a solution, we'll refresh + // the capability we're using to determine this. + _logger.logger.warn("Refreshing room version capability because the server looks " + "to be supporting a newer room version we don't know about."); + const caps = await this.client.getCapabilities(true); + versionCap = caps["m.room_versions"]; + if (!versionCap) { + _logger.logger.warn("No room version capability - assuming upgrade required."); + return result; + } else { + result = this.checkVersionAgainstCapability(versionCap); + } + } + return result; + } + checkVersionAgainstCapability(versionCap) { + const currentVersion = this.getVersion(); + _logger.logger.log(`[${this.roomId}] Current version: ${currentVersion}`); + _logger.logger.log(`[${this.roomId}] Version capability: `, versionCap); + const result = { + version: currentVersion, + needsUpgrade: false, + urgent: false + }; + + // If the room is on the default version then nothing needs to change + if (currentVersion === versionCap.default) return result; + const stableVersions = Object.keys(versionCap.available).filter(v => versionCap.available[v] === "stable"); + + // Check if the room is on an unstable version. We determine urgency based + // off the version being in the Matrix spec namespace or not (if the version + // is in the current namespace and unstable, the room is probably vulnerable). + if (!stableVersions.includes(currentVersion)) { + result.version = versionCap.default; + result.needsUpgrade = true; + result.urgent = !!this.getVersion().match(/^[0-9]+[0-9.]*$/g); + if (result.urgent) { + _logger.logger.warn(`URGENT upgrade required on ${this.roomId}`); + } else { + _logger.logger.warn(`Non-urgent upgrade required on ${this.roomId}`); + } + return result; + } + + // The room is on a stable, but non-default, version by this point. + // No upgrade needed. + return result; + } + + /** + * Determines whether the given user is permitted to perform a room upgrade + * @param userId - The ID of the user to test against + * @returns True if the given user is permitted to upgrade the room + */ + userMayUpgradeRoom(userId) { + return this.currentState.maySendStateEvent(_event2.EventType.RoomTombstone, userId); + } + + /** + * Get the list of pending sent events for this room + * + * @returns A list of the sent events + * waiting for remote echo. + * + * @throws If `opts.pendingEventOrdering` was not 'detached' + */ + getPendingEvents() { + if (!this.pendingEventList) { + throw new Error("Cannot call getPendingEvents with pendingEventOrdering == " + this.opts.pendingEventOrdering); + } + return this.pendingEventList; + } + + /** + * Removes a pending event for this room + * + * @returns True if an element was removed. + */ + removePendingEvent(eventId) { + if (!this.pendingEventList) { + throw new Error("Cannot call removePendingEvent with pendingEventOrdering == " + this.opts.pendingEventOrdering); + } + const removed = (0, _utils.removeElement)(this.pendingEventList, function (ev) { + return ev.getId() == eventId; + }, false); + this.savePendingEvents(); + return removed; + } + + /** + * Check whether the pending event list contains a given event by ID. + * If pending event ordering is not "detached" then this returns false. + * + * @param eventId - The event ID to check for. + */ + hasPendingEvent(eventId) { + return this.pendingEventList?.some(event => event.getId() === eventId) ?? false; + } + + /** + * Get a specific event from the pending event list, if configured, null otherwise. + * + * @param eventId - The event ID to check for. + */ + getPendingEvent(eventId) { + return this.pendingEventList?.find(event => event.getId() === eventId) ?? null; + } + + /** + * Get the live unfiltered timeline for this room. + * + * @returns live timeline + */ + getLiveTimeline() { + return this.getUnfilteredTimelineSet().getLiveTimeline(); + } + + /** + * Get the timestamp of the last message in the room + * + * @returns the timestamp of the last message in the room + */ + getLastActiveTimestamp() { + const timeline = this.getLiveTimeline(); + const events = timeline.getEvents(); + if (events.length) { + const lastEvent = events[events.length - 1]; + return lastEvent.getTs(); + } else { + return Number.MIN_SAFE_INTEGER; + } + } + + /** + * Returns the last live event of this room. + * "last" means latest timestamp. + * Instead of using timestamps, it would be better to do the comparison based on the order of the homeserver DAG. + * Unfortunately, this information is currently not available in the client. + * See {@link https://github.com/matrix-org/matrix-js-sdk/issues/3325}. + * "live of this room" means from all live timelines: the room and the threads. + * + * @returns MatrixEvent if there is a last event; else undefined. + */ + getLastLiveEvent() { + const roomEvents = this.getLiveTimeline().getEvents(); + const lastRoomEvent = roomEvents[roomEvents.length - 1]; + const lastThread = this.getLastThread(); + if (!lastThread) return lastRoomEvent; + const lastThreadEvent = lastThread.events[lastThread.events.length - 1]; + return (lastRoomEvent?.getTs() ?? 0) > (lastThreadEvent?.getTs() ?? 0) ? lastRoomEvent : lastThreadEvent; + } + + /** + * Returns the last thread of this room. + * "last" means latest timestamp of the last thread event. + * Instead of using timestamps, it would be better to do the comparison based on the order of the homeserver DAG. + * Unfortunately, this information is currently not available in the client. + * See {@link https://github.com/matrix-org/matrix-js-sdk/issues/3325}. + * + * @returns the thread with the most recent event in its live time line. undefined if there is no thread. + */ + getLastThread() { + return this.getThreads().reduce((lastThread, thread) => { + if (!lastThread) return thread; + const threadEvent = thread.events[thread.events.length - 1]; + const lastThreadEvent = lastThread.events[lastThread.events.length - 1]; + if ((threadEvent?.getTs() ?? 0) >= (lastThreadEvent?.getTs() ?? 0)) { + // Last message of current thread is newer → new last thread. + // Equal also means newer, because it was added to the thread map later. + return thread; + } + return lastThread; + }, undefined); + } + + /** + * @returns the membership type (join | leave | invite) for the logged in user + */ + getMyMembership() { + return this.selfMembership ?? "leave"; + } + + /** + * If this room is a DM we're invited to, + * try to find out who invited us + * @returns user id of the inviter + */ + getDMInviter() { + 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) { + return this.summaryHeroes?.[0]; + } + } + } + + /** + * Assuming this room is a DM room, tries to guess with which user. + * @returns user id of the other member (could be syncing user) + */ + guessDMUserId() { + 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 + if (Array.isArray(this.summaryHeroes) && this.summaryHeroes.length) { + 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; + } + getAvatarFallbackMember() { + 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.RoomMember(this.roomId, availableUser.userId); + member.user = availableUser; + return member; + } + } + } + + /** + * Sets the membership this room was received as during sync + * @param membership - join | leave | invite + */ + updateMyMembership(membership) { + const prevMembership = this.selfMembership; + this.selfMembership = membership; + if (prevMembership !== membership) { + if (membership === "leave") { + this.cleanupAfterLeaving(); + } + this.emit(RoomEvent.MyMembership, this, membership, prevMembership); + } + } + async loadMembersFromServer() { + const lastSyncToken = this.client.store.getSyncToken(); + const response = await this.client.members(this.roomId, undefined, "leave", lastSyncToken ?? undefined); + return response.chunk; + } + async loadMembers() { + // were the members loaded from the server? + let fromServer = false; + let rawMembersEvents = await this.client.store.getOutOfBandMembers(this.roomId); + // If the room is encrypted, we always fetch members from the server at + // least once, in case the latest state wasn't persisted properly. Note + // that this function is only called once (unless loading the members + // fails), since loadMembersIfNeeded always returns this.membersPromise + // if set, which will be the result of the first (successful) call. + if (rawMembersEvents === null || this.client.isCryptoEnabled() && this.client.isRoomEncrypted(this.roomId)) { + fromServer = true; + rawMembersEvents = await this.loadMembersFromServer(); + _logger.logger.log(`LL: got ${rawMembersEvents.length} ` + `members from server for room ${this.roomId}`); + } + const memberEvents = rawMembersEvents.filter(_utils.noUnsafeEventProps).map(this.client.getEventMapper()); + return { + memberEvents, + fromServer + }; + } + + /** + * Check if loading of out-of-band-members has completed + * + * @returns true if the full membership list of this room has been loaded (including if lazy-loading is disabled). + * False if the load is not started or is in progress. + */ + membersLoaded() { + if (!this.opts.lazyLoadMembers) { + return true; + } + return this.currentState.outOfBandMembersReady(); + } + + /** + * Preloads the member list in case lazy loading + * of memberships is in use. Can be called multiple times, + * it will only preload once. + * @returns when preloading is done and + * accessing the members on the room will take + * all members in the room into account + */ + loadMembersIfNeeded() { + 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); + return result.fromServer; + }).catch(err => { + // allow retries on fail + this.membersPromise = undefined; + this.currentState.markOutOfBandMembersFailed(); + throw err; + }); + // update members in storage, but don't wait for it + inMemoryUpdate.then(fromServer => { + if (fromServer) { + const oobMembers = this.currentState.getMembers().filter(m => m.isOutOfBand()).map(m => m.events.member?.event); + _logger.logger.log(`LL: telling store to write ${oobMembers.length}` + ` members for room ${this.roomId}`); + const store = this.client.store; + return store.setOutOfBandMembers(this.roomId, oobMembers) + // swallow any IDB error as we don't want to fail + // because of this + .catch(err => { + _logger.logger.log("LL: storing OOB room members failed, oh well", err); + }); + } + }).catch(err => { + // as this is not awaited anywhere, + // at least show the error in the console + _logger.logger.error(err); + }); + this.membersPromise = inMemoryUpdate; + return this.membersPromise; + } + + /** + * Removes the lazily loaded members from storage if needed + */ + async clearLoadedMembersIfNeeded() { + if (this.opts.lazyLoadMembers && this.membersPromise) { + await this.loadMembersIfNeeded(); + await this.client.store.clearOutOfBandMembers(this.roomId); + this.currentState.clearOutOfBandMembers(); + this.membersPromise = undefined; + } + } + + /** + * called when sync receives this room in the leave section + * to do cleanup after leaving a room. Possibly called multiple times. + */ + cleanupAfterLeaving() { + this.clearLoadedMembersIfNeeded().catch(err => { + _logger.logger.error(`error after clearing loaded members from ` + `room ${this.roomId} after leaving`); + _logger.logger.log(err); + }); + } + + /** + * Empty out the current live timeline and re-request it. This is used when + * historical messages are imported into the room via MSC2716 `/batch_send` + * because the client may already have that section of the timeline loaded. + * We need to force the client to throw away their current timeline so that + * when they back paginate over the area again with the historical messages + * in between, it grabs the newly imported messages. We can listen for + * `UNSTABLE_MSC2716_MARKER`, in order to tell when historical messages are ready + * to be discovered in the room and the timeline needs a refresh. The SDK + * emits a `RoomEvent.HistoryImportedWithinTimeline` event when we detect a + * valid marker and can check the needs refresh status via + * `room.getTimelineNeedsRefresh()`. + */ + async refreshLiveTimeline() { + const liveTimelineBefore = this.getLiveTimeline(); + const forwardPaginationToken = liveTimelineBefore.getPaginationToken(_eventTimeline.EventTimeline.FORWARDS); + const backwardPaginationToken = liveTimelineBefore.getPaginationToken(_eventTimeline.EventTimeline.BACKWARDS); + const eventsBefore = liveTimelineBefore.getEvents(); + const mostRecentEventInTimeline = eventsBefore[eventsBefore.length - 1]; + _logger.logger.log(`[refreshLiveTimeline for ${this.roomId}] at ` + `mostRecentEventInTimeline=${mostRecentEventInTimeline && mostRecentEventInTimeline.getId()} ` + `liveTimelineBefore=${liveTimelineBefore.toString()} ` + `forwardPaginationToken=${forwardPaginationToken} ` + `backwardPaginationToken=${backwardPaginationToken}`); + + // Get the main TimelineSet + const timelineSet = this.getUnfilteredTimelineSet(); + let newTimeline; + // If there isn't any event in the timeline, let's go fetch the latest + // event and construct a timeline from it. + // + // This should only really happen if the user ran into an error + // with refreshing the timeline before which left them in a blank + // timeline from `resetLiveTimeline`. + if (!mostRecentEventInTimeline) { + newTimeline = await this.client.getLatestTimeline(timelineSet); + } else { + // Empty out all of `this.timelineSets`. But we also need to keep the + // same `timelineSet` references around so the React code updates + // properly and doesn't ignore the room events we emit because it checks + // that the `timelineSet` references are the same. We need the + // `timelineSet` empty so that the `client.getEventTimeline(...)` call + // later, will call `/context` and create a new timeline instead of + // returning the same one. + this.resetLiveTimeline(null, null); + + // Make the UI timeline show the new blank live timeline we just + // reset so that if the network fails below it's showing the + // accurate state of what we're working with instead of the + // disconnected one in the TimelineWindow which is just hanging + // around by reference. + this.emit(RoomEvent.TimelineRefresh, this, timelineSet); + + // Use `client.getEventTimeline(...)` to construct a new timeline from a + // `/context` response state and events for the most recent event before + // we reset everything. The `timelineSet` we pass in needs to be empty + // in order for this function to call `/context` and generate a new + // timeline. + newTimeline = await this.client.getEventTimeline(timelineSet, mostRecentEventInTimeline.getId()); + } + + // If a racing `/sync` beat us to creating a new timeline, use that + // instead because it's the latest in the room and any new messages in + // the scrollback will include the history. + const liveTimeline = timelineSet.getLiveTimeline(); + if (!liveTimeline || liveTimeline.getPaginationToken(_eventTimeline.Direction.Forward) === null && liveTimeline.getPaginationToken(_eventTimeline.Direction.Backward) === null && liveTimeline.getEvents().length === 0) { + _logger.logger.log(`[refreshLiveTimeline for ${this.roomId}] using our new live timeline`); + // Set the pagination token back to the live sync token (`null`) instead + // of using the `/context` historical token (ex. `t12-13_0_0_0_0_0_0_0_0`) + // so that it matches the next response from `/sync` and we can properly + // continue the timeline. + newTimeline.setPaginationToken(forwardPaginationToken, _eventTimeline.EventTimeline.FORWARDS); + + // Set our new fresh timeline as the live timeline to continue syncing + // forwards and back paginating from. + timelineSet.setLiveTimeline(newTimeline); + // Fixup `this.oldstate` so that `scrollback` has the pagination tokens + // available + this.fixUpLegacyTimelineFields(); + } else { + _logger.logger.log(`[refreshLiveTimeline for ${this.roomId}] \`/sync\` or some other request beat us to creating a new ` + `live timeline after we reset it. We'll use that instead since any events in the scrollback from ` + `this timeline will include the history.`); + } + + // The timeline has now been refreshed ✅ + this.setTimelineNeedsRefresh(false); + + // Emit an event which clients can react to and re-load the timeline + // from the SDK + this.emit(RoomEvent.TimelineRefresh, this, timelineSet); + } + + /** + * Reset the live timeline of all timelineSets, and start new ones. + * + * <p>This is used when /sync returns a 'limited' timeline. + * + * @param backPaginationToken - token for back-paginating the new timeline + * @param forwardPaginationToken - token for forward-paginating the old live timeline, + * if absent or null, all timelines are reset, removing old ones (including the previous live + * timeline which would otherwise be unable to paginate forwards without this token). + * Removing just the old live timeline whilst preserving previous ones is not supported. + */ + resetLiveTimeline(backPaginationToken, forwardPaginationToken) { + for (const timelineSet of this.timelineSets) { + timelineSet.resetLiveTimeline(backPaginationToken ?? undefined, forwardPaginationToken ?? undefined); + } + for (const thread of this.threads.values()) { + thread.resetLiveTimeline(backPaginationToken, forwardPaginationToken); + } + this.fixUpLegacyTimelineFields(); + } + + /** + * Fix up this.timeline, this.oldState and this.currentState + * + * @internal + */ + fixUpLegacyTimelineFields() { + const previousOldState = this.oldState; + const previousCurrentState = this.currentState; + + // maintain this.timeline as a reference to the live timeline, + // and this.oldState and this.currentState as references to the + // state at the start and end of that timeline. These are more + // for backwards-compatibility than anything else. + this.timeline = this.getLiveTimeline().getEvents(); + this.oldState = this.getLiveTimeline().getState(_eventTimeline.EventTimeline.BACKWARDS); + this.currentState = this.getLiveTimeline().getState(_eventTimeline.EventTimeline.FORWARDS); + + // Let people know to register new listeners for the new state + // references. The reference won't necessarily change every time so only + // emit when we see a change. + if (previousOldState !== this.oldState) { + this.emit(RoomEvent.OldStateUpdated, this, previousOldState, this.oldState); + } + if (previousCurrentState !== this.currentState) { + this.emit(RoomEvent.CurrentStateUpdated, this, previousCurrentState, this.currentState); + + // Re-emit various events on the current room state + // TODO: If currentState really only exists for backwards + // compatibility, shouldn't we be doing this some other way? + this.reEmitter.stopReEmitting(previousCurrentState, [_roomState.RoomStateEvent.Events, _roomState.RoomStateEvent.Members, _roomState.RoomStateEvent.NewMember, _roomState.RoomStateEvent.Update, _roomState.RoomStateEvent.Marker, _beacon.BeaconEvent.New, _beacon.BeaconEvent.Update, _beacon.BeaconEvent.Destroy, _beacon.BeaconEvent.LivenessChange]); + this.reEmitter.reEmit(this.currentState, [_roomState.RoomStateEvent.Events, _roomState.RoomStateEvent.Members, _roomState.RoomStateEvent.NewMember, _roomState.RoomStateEvent.Update, _roomState.RoomStateEvent.Marker, _beacon.BeaconEvent.New, _beacon.BeaconEvent.Update, _beacon.BeaconEvent.Destroy, _beacon.BeaconEvent.LivenessChange]); + } + } + + /** + * Returns whether there are any devices in the room that are unverified + * + * Note: Callers should first check if crypto is enabled on this device. If it is + * disabled, then we aren't tracking room devices at all, so we can't answer this, and an + * error will be thrown. + * + * @returns the result + */ + async hasUnverifiedDevices() { + if (!this.client.isRoomEncrypted(this.roomId)) { + return false; + } + const e2eMembers = await this.getEncryptionTargetMembers(); + for (const member of e2eMembers) { + const devices = this.client.getStoredDevicesForUser(member.userId); + if (devices.some(device => device.isUnverified())) { + return true; + } + } + return false; + } + + /** + * Return the timeline sets for this room. + * @returns array of timeline sets for this room + */ + getTimelineSets() { + return this.timelineSets; + } + + /** + * Helper to return the main unfiltered timeline set for this room + * @returns room's unfiltered timeline set + */ + getUnfilteredTimelineSet() { + return this.timelineSets[0]; + } + + /** + * Get the timeline which contains the given event from the unfiltered set, if any + * + * @param eventId - event ID to look for + * @returns timeline containing + * the given event, or null if unknown + */ + getTimelineForEvent(eventId) { + const event = this.findEventById(eventId); + const thread = this.findThreadForEvent(event); + if (thread) { + return thread.timelineSet.getTimelineForEvent(eventId); + } else { + return this.getUnfilteredTimelineSet().getTimelineForEvent(eventId); + } + } + + /** + * Add a new timeline to this room's unfiltered timeline set + * + * @returns newly-created timeline + */ + addTimeline() { + return this.getUnfilteredTimelineSet().addTimeline(); + } + + /** + * Whether the timeline needs to be refreshed in order to pull in new + * historical messages that were imported. + * @param value - The value to set + */ + setTimelineNeedsRefresh(value) { + this.timelineNeedsRefresh = value; + } + + /** + * Whether the timeline needs to be refreshed in order to pull in new + * historical messages that were imported. + * @returns . + */ + getTimelineNeedsRefresh() { + return this.timelineNeedsRefresh; + } + + /** + * Get an event which is stored in our unfiltered timeline set, or in a thread + * + * @param eventId - event ID to look for + * @returns the given event, or undefined if unknown + */ + findEventById(eventId) { + let event = this.getUnfilteredTimelineSet().findEventById(eventId); + if (!event) { + const threads = this.getThreads(); + for (let i = 0; i < threads.length; i++) { + const thread = threads[i]; + event = thread.findEventById(eventId); + if (event) { + return event; + } + } + } + return event; + } + + /** + * Get one of the notification counts for this room + * @param type - The type of notification count to get. default: 'total' + * @returns The notification count, or undefined if there is no count + * for this type. + */ + getUnreadNotificationCount(type = NotificationCountType.Total) { + let count = this.getRoomUnreadNotificationCount(type); + for (const threadNotification of this.threadNotifications.values()) { + count += threadNotification[type] ?? 0; + } + return count; + } + + /** + * Get the notification for the event context (room or thread timeline) + */ + getUnreadCountForEventContext(type = NotificationCountType.Total, event) { + const isThreadEvent = !!event.threadRootId && !event.isThreadRoot; + return (isThreadEvent ? this.getThreadUnreadNotificationCount(event.threadRootId, type) : this.getRoomUnreadNotificationCount(type)) ?? 0; + } + + /** + * Get one of the notification counts for this room + * @param type - The type of notification count to get. default: 'total' + * @returns The notification count, or undefined if there is no count + * for this type. + */ + getRoomUnreadNotificationCount(type = NotificationCountType.Total) { + return this.notificationCounts[type] ?? 0; + } + + /** + * Get one of the notification counts for a thread + * @param threadId - the root event ID + * @param type - The type of notification count to get. default: 'total' + * @returns The notification count, or undefined if there is no count + * for this type. + */ + getThreadUnreadNotificationCount(threadId, type = NotificationCountType.Total) { + return this.threadNotifications.get(threadId)?.[type] ?? 0; + } + + /** + * Checks if the current room has unread thread notifications + * @returns + */ + hasThreadUnreadNotification() { + for (const notification of this.threadNotifications.values()) { + if ((notification.highlight ?? 0) > 0 || (notification.total ?? 0) > 0) { + return true; + } + } + return false; + } + + /** + * Swet one of the notification count for a thread + * @param threadId - the root event ID + * @param type - The type of notification count to get. default: 'total' + * @returns + */ + setThreadUnreadNotificationCount(threadId, type, count) { + const notification = _objectSpread({ + highlight: this.threadNotifications.get(threadId)?.highlight, + total: this.threadNotifications.get(threadId)?.total + }, { + [type]: count + }); + this.threadNotifications.set(threadId, notification); + this.emit(RoomEvent.UnreadNotifications, notification, threadId); + } + + /** + * @returns the notification count type for all the threads in the room + */ + get threadsAggregateNotificationType() { + let type = null; + for (const threadNotification of this.threadNotifications.values()) { + if ((threadNotification.highlight ?? 0) > 0) { + return NotificationCountType.Highlight; + } else if ((threadNotification.total ?? 0) > 0 && !type) { + type = NotificationCountType.Total; + } + } + return type; + } + + /** + * Resets the thread notifications for this room + */ + resetThreadUnreadNotificationCount(notificationsToKeep) { + if (notificationsToKeep) { + for (const [threadId] of this.threadNotifications) { + if (!notificationsToKeep.includes(threadId)) { + this.threadNotifications.delete(threadId); + } + } + } else { + this.threadNotifications.clear(); + } + this.emit(RoomEvent.UnreadNotifications); + } + + /** + * Set one of the notification counts for this room + * @param type - The type of notification count to set. + * @param count - The new count + */ + setUnreadNotificationCount(type, count) { + this.notificationCounts[type] = count; + this.emit(RoomEvent.UnreadNotifications, this.notificationCounts); + } + setUnread(type, count) { + return this.setUnreadNotificationCount(type, count); + } + setSummary(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 value - true to blacklist unverified devices, null + * to use the global value for this room. + */ + setBlacklistUnverifiedDevices(value) { + this.blacklistUnverifiedDevices = value; + } + + /** + * Whether to send encrypted messages to devices within this room. + * @returns true if blacklisting unverified devices, null + * if the global value should be used for this room. + */ + getBlacklistUnverifiedDevices() { + if (this.blacklistUnverifiedDevices === undefined) return null; + return this.blacklistUnverifiedDevices; + } + + /** + * Get the avatar URL for a room if one was set. + * @param baseUrl - The homeserver base URL. See + * {@link MatrixClient#getHomeserverUrl}. + * @param width - The desired width of the thumbnail. + * @param height - The desired height of the thumbnail. + * @param resizeMethod - The thumbnail resize method to use, either + * "crop" or "scale". + * @param allowDefault - True to allow an identicon for this room if an + * avatar URL wasn't explicitly set. Default: true. (Deprecated) + * @returns the avatar URL or null. + */ + getAvatarUrl(baseUrl, width, height, resizeMethod, allowDefault = true) { + const roomAvatarEvent = this.currentState.getStateEvents(_event2.EventType.RoomAvatar, ""); + if (!roomAvatarEvent && !allowDefault) { + return null; + } + const mainUrl = roomAvatarEvent ? roomAvatarEvent.getContent().url : null; + if (mainUrl) { + return (0, _contentRepo.getHttpUriForMxc)(baseUrl, mainUrl, width, height, resizeMethod); + } + return null; + } + + /** + * Get the mxc avatar url for the room, if one was set. + * @returns the mxc avatar url or falsy + */ + getMxcAvatarUrl() { + return this.currentState.getStateEvents(_event2.EventType.RoomAvatar, "")?.getContent()?.url || null; + } + + /** + * Get this room's canonical alias + * The alias returned by this function may not necessarily + * still point to this room. + * @returns The room's canonical alias, or null if there is none + */ + getCanonicalAlias() { + const canonicalAlias = this.currentState.getStateEvents(_event2.EventType.RoomCanonicalAlias, ""); + if (canonicalAlias) { + return canonicalAlias.getContent().alias || null; + } + return null; + } + + /** + * Get this room's alternative aliases + * @returns The room's alternative aliases, or an empty array + */ + getAltAliases() { + const canonicalAlias = this.currentState.getStateEvents(_event2.EventType.RoomCanonicalAlias, ""); + if (canonicalAlias) { + return canonicalAlias.getContent().alt_aliases || []; + } + return []; + } + + /** + * Add events to a timeline + * + * <p>Will fire "Room.timeline" for each event added. + * + * @param events - A list of events to add. + * + * @param toStartOfTimeline - True to add these events to the start + * (oldest) instead of the end (newest) of the timeline. If true, the oldest + * event will be the <b>last</b> element of 'events'. + * + * @param timeline - timeline to + * add events to. + * + * @param paginationToken - token for the next batch of events + * + * @remarks + * Fires {@link RoomEvent.Timeline} + */ + addEventsToTimeline(events, toStartOfTimeline, timeline, paginationToken) { + timeline.getTimelineSet().addEventsToTimeline(events, toStartOfTimeline, timeline, paginationToken); + } + + /** + * Get the instance of the thread associated with the current event + * @param eventId - the ID of the current event + * @returns a thread instance if known + */ + getThread(eventId) { + return this.threads.get(eventId) ?? null; + } + + /** + * Get all the known threads in the room + */ + getThreads() { + return Array.from(this.threads.values()); + } + + /** + * Get a member from the current room state. + * @param userId - The user ID of the member. + * @returns The member or `null`. + */ + getMember(userId) { + return this.currentState.getMember(userId); + } + + /** + * Get all currently loaded members from the current + * room state. + * @returns Room members + */ + getMembers() { + return this.currentState.getMembers(); + } + + /** + * Get a list of members whose membership state is "join". + * @returns A list of currently joined members. + */ + getJoinedMembers() { + 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. + * @returns The number of members in this room whose membership is 'join' + */ + getJoinedMemberCount() { + return this.currentState.getJoinedMemberCount(); + } + + /** + * Returns the number of invited members in this room + * @returns The number of members in this room whose membership is 'invite' + */ + getInvitedMemberCount() { + return this.currentState.getInvitedMemberCount(); + } + + /** + * Returns the number of invited + joined members in this room + * @returns The number of members in this room whose membership is 'invite' or 'join' + */ + getInvitedAndJoinedMemberCount() { + return this.getInvitedMemberCount() + this.getJoinedMemberCount(); + } + + /** + * Get a list of members with given membership state. + * @param membership - The membership state. + * @returns A list of members with the given membership state. + */ + getMembersWithMembership(membership) { + return this.currentState.getMembers().filter(function (m) { + return m.membership === membership; + }); + } + + /** + * Get a list of members we should be encrypting for in this room + * @returns A list of members who + * we should encrypt messages for in this room. + */ + async getEncryptionTargetMembers() { + await this.loadMembersIfNeeded(); + let members = this.getMembersWithMembership("join"); + if (this.shouldEncryptForInvitedMembers()) { + members = members.concat(this.getMembersWithMembership("invite")); + } + return members; + } + + /** + * Determine whether we should encrypt messages for invited users in this room + * @returns if we should encrypt messages for invited users + */ + shouldEncryptForInvitedMembers() { + const ev = this.currentState.getStateEvents(_event2.EventType.RoomHistoryVisibility, ""); + return ev?.getContent()?.history_visibility !== "joined"; + } + + /** + * Get the default room name (i.e. what a given user would see if the + * room had no m.room.name) + * @param userId - The userId from whose perspective we want + * to calculate the default name + * @returns The default room name + */ + getDefaultRoomName(userId) { + return this.calculateRoomName(userId, true); + } + + /** + * Check if the given user_id has the given membership state. + * @param userId - The user ID to check. + * @param membership - The membership e.g. `'join'` + * @returns True if this user_id has the given membership state. + */ + hasMembershipState(userId, membership) { + const member = this.getMember(userId); + if (!member) { + return false; + } + return member.membership === membership; + } + + /** + * Add a timelineSet for this room with the given filter + * @param filter - The filter to be applied to this timelineSet + * @param opts - Configuration options + * @returns The timelineSet + */ + getOrCreateFilteredTimelineSet(filter, { + prepopulateTimeline = true, + useSyncEvents = true, + pendingEvents = true + } = {}) { + if (this.filteredTimelineSets[filter.filterId]) { + return this.filteredTimelineSets[filter.filterId]; + } + const opts = Object.assign({ + filter, + pendingEvents + }, this.opts); + const timelineSet = new _eventTimelineSet.EventTimelineSet(this, opts); + this.reEmitter.reEmit(timelineSet, [RoomEvent.Timeline, RoomEvent.TimelineReset]); + if (useSyncEvents) { + this.filteredTimelineSets[filter.filterId] = timelineSet; + this.timelineSets.push(timelineSet); + } + const unfilteredLiveTimeline = this.getLiveTimeline(); + // Not all filter are possible to replicate client-side only + // When that's the case we do not want to prepopulate from the live timeline + // as we would get incorrect results compared to what the server would send back + if (prepopulateTimeline) { + // populate up the new timelineSet with filtered events from our live + // unfiltered timeline. + // + // XXX: This is risky as our timeline + // may have grown huge and so take a long time to filter. + // see https://github.com/vector-im/vector-web/issues/2109 + + unfilteredLiveTimeline.getEvents().forEach(function (event) { + timelineSet.addLiveEvent(event); + }); + + // find the earliest unfiltered timeline + let timeline = unfilteredLiveTimeline; + while (timeline.getNeighbouringTimeline(_eventTimeline.EventTimeline.BACKWARDS)) { + timeline = timeline.getNeighbouringTimeline(_eventTimeline.EventTimeline.BACKWARDS); + } + timelineSet.getLiveTimeline().setPaginationToken(timeline.getPaginationToken(_eventTimeline.EventTimeline.BACKWARDS), _eventTimeline.EventTimeline.BACKWARDS); + } else if (useSyncEvents) { + const livePaginationToken = unfilteredLiveTimeline.getPaginationToken(_eventTimeline.Direction.Forward); + timelineSet.getLiveTimeline().setPaginationToken(livePaginationToken, _eventTimeline.Direction.Backward); + } + + // alternatively, we could try to do something like this to try and re-paginate + // in the filtered events from nothing, but Mark says it's an abuse of the API + // to do so: + // + // timelineSet.resetLiveTimeline( + // unfilteredLiveTimeline.getPaginationToken(EventTimeline.FORWARDS) + // ); + + return timelineSet; + } + async getThreadListFilter(filterType = _thread.ThreadFilterType.All) { + const myUserId = this.client.getUserId(); + const filter = new _filter.Filter(myUserId); + const definition = { + room: { + timeline: { + [_thread.FILTER_RELATED_BY_REL_TYPES.name]: [_thread.THREAD_RELATION_TYPE.name] + } + } + }; + if (filterType === _thread.ThreadFilterType.My) { + definition.room.timeline[_thread.FILTER_RELATED_BY_SENDERS.name] = [myUserId]; + } + filter.setDefinition(definition); + const filterId = await this.client.getOrCreateFilter(`THREAD_PANEL_${this.roomId}_${filterType}`, filter); + filter.filterId = filterId; + return filter; + } + async createThreadTimelineSet(filterType) { + let timelineSet; + if (_thread.Thread.hasServerSideListSupport) { + timelineSet = new _eventTimelineSet.EventTimelineSet(this, _objectSpread(_objectSpread({}, this.opts), {}, { + pendingEvents: false + }), undefined, undefined, filterType ?? _thread.ThreadFilterType.All); + this.reEmitter.reEmit(timelineSet, [RoomEvent.Timeline, RoomEvent.TimelineReset]); + } else if (_thread.Thread.hasServerSideSupport) { + const filter = await this.getThreadListFilter(filterType); + timelineSet = this.getOrCreateFilteredTimelineSet(filter, { + prepopulateTimeline: false, + useSyncEvents: false, + pendingEvents: false + }); + } else { + timelineSet = new _eventTimelineSet.EventTimelineSet(this, { + pendingEvents: false + }); + Array.from(this.threads).forEach(([, thread]) => { + if (thread.length === 0) return; + const currentUserParticipated = thread.timeline.some(event => { + return event.getSender() === this.client.getUserId(); + }); + if (filterType !== _thread.ThreadFilterType.My || currentUserParticipated) { + timelineSet.getLiveTimeline().addEvent(thread.rootEvent, { + toStartOfTimeline: false + }); + } + }); + } + return timelineSet; + } + /** + * Takes the given thread root events and creates threads for them. + */ + processThreadRoots(events, toStartOfTimeline) { + for (const rootEvent of events) { + _eventTimeline.EventTimeline.setEventMetadata(rootEvent, this.currentState, toStartOfTimeline); + if (!this.getThread(rootEvent.getId())) { + this.createThread(rootEvent.getId(), rootEvent, [], toStartOfTimeline); + } + } + } + + /** + * Fetch the bare minimum of room threads required for the thread list to work reliably. + * With server support that means fetching one page. + * Without server support that means fetching as much at once as the server allows us to. + */ + async fetchRoomThreads() { + if (this.threadsReady || !this.client.supportsThreads()) { + return; + } + if (_thread.Thread.hasServerSideListSupport) { + await Promise.all([this.fetchRoomThreadList(_thread.ThreadFilterType.All), this.fetchRoomThreadList(_thread.ThreadFilterType.My)]); + } else { + const allThreadsFilter = await this.getThreadListFilter(); + const { + chunk: events + } = await this.client.createMessagesRequest(this.roomId, "", Number.MAX_SAFE_INTEGER, _eventTimeline.Direction.Backward, allThreadsFilter); + if (!events.length) return; + + // Sorted by last_reply origin_server_ts + const threadRoots = events.map(this.client.getEventMapper()).sort((eventA, eventB) => { + /** + * `origin_server_ts` in a decentralised world is far from ideal + * but for lack of any better, we will have to use this + * Long term the sorting should be handled by homeservers and this + * is only meant as a short term patch + */ + const threadAMetadata = eventA.getServerAggregatedRelation(_thread.THREAD_RELATION_TYPE.name); + const threadBMetadata = eventB.getServerAggregatedRelation(_thread.THREAD_RELATION_TYPE.name); + return threadAMetadata.latest_event.origin_server_ts - threadBMetadata.latest_event.origin_server_ts; + }); + let latestMyThreadsRootEvent; + const roomState = this.getLiveTimeline().getState(_eventTimeline.EventTimeline.FORWARDS); + for (const rootEvent of threadRoots) { + const opts = { + duplicateStrategy: _eventTimelineSet.DuplicateStrategy.Ignore, + fromCache: false, + roomState + }; + this.threadsTimelineSets[0]?.addLiveEvent(rootEvent, opts); + const threadRelationship = rootEvent.getServerAggregatedRelation(_thread.THREAD_RELATION_TYPE.name); + if (threadRelationship?.current_user_participated) { + this.threadsTimelineSets[1]?.addLiveEvent(rootEvent, opts); + latestMyThreadsRootEvent = rootEvent; + } + } + this.processThreadRoots(threadRoots, true); + this.client.decryptEventIfNeeded(threadRoots[threadRoots.length - 1]); + if (latestMyThreadsRootEvent) { + this.client.decryptEventIfNeeded(latestMyThreadsRootEvent); + } + } + this.on(_thread.ThreadEvent.NewReply, this.onThreadNewReply); + this.on(_thread.ThreadEvent.Delete, this.onThreadDelete); + this.threadsReady = true; + } + + /** + * Calls {@link processPollEvent} for a list of events. + * + * @param events - List of events + */ + async processPollEvents(events) { + for (const event of events) { + try { + // Continue if the event is a clear text, non-poll event. + if (!event.isEncrypted() && !(0, _poll.isPollEvent)(event)) continue; + + /** + * Try to decrypt the event. Promise resolution does not guarantee a successful decryption. + * Retry is handled in {@link processPollEvent}. + */ + await this.client.decryptEventIfNeeded(event); + this.processPollEvent(event); + } catch (err) { + _logger.logger.warn("Error processing poll event", event.getId(), err); + } + } + } + + /** + * Processes poll events: + * If the event has a decryption failure, it will listen for decryption and tries again. + * If it is a poll start event (`m.poll.start`), + * it creates and stores a Poll model and emits a PollEvent.New event. + * If the event is related to a poll, it will add it to the poll. + * Noop for other cases. + * + * @param event - Event that could be a poll event + */ + async processPollEvent(event) { + if (event.isDecryptionFailure()) { + event.once(_event.MatrixEventEvent.Decrypted, maybeDecryptedEvent => { + this.processPollEvent(maybeDecryptedEvent); + }); + return; + } + if (_matrixEventsSdk.M_POLL_START.matches(event.getType())) { + try { + const poll = new _poll.Poll(event, this.client, this); + this.polls.set(event.getId(), poll); + this.emit(_poll.PollEvent.New, poll); + } catch {} + // poll creation can fail for malformed poll start events + return; + } + const relationEventId = event.relationEventId; + if (relationEventId && this.polls.has(relationEventId)) { + const poll = this.polls.get(relationEventId); + poll?.onNewRelation(event); + } + } + + /** + * Fetch a single page of threadlist messages for the specific thread filter + * @internal + */ + async fetchRoomThreadList(filter) { + if (this.threadsTimelineSets.length === 0) return; + const timelineSet = filter === _thread.ThreadFilterType.My ? this.threadsTimelineSets[1] : this.threadsTimelineSets[0]; + const { + chunk: events, + end + } = await this.client.createThreadListMessagesRequest(this.roomId, null, undefined, _eventTimeline.Direction.Backward, timelineSet.threadListType, timelineSet.getFilter()); + timelineSet.getLiveTimeline().setPaginationToken(end ?? null, _eventTimeline.Direction.Backward); + if (!events.length) return; + const matrixEvents = events.map(this.client.getEventMapper()); + this.processThreadRoots(matrixEvents, true); + const roomState = this.getLiveTimeline().getState(_eventTimeline.EventTimeline.FORWARDS); + for (const rootEvent of matrixEvents) { + timelineSet.addLiveEvent(rootEvent, { + duplicateStrategy: _eventTimelineSet.DuplicateStrategy.Replace, + fromCache: false, + roomState + }); + } + } + onThreadNewReply(thread) { + this.updateThreadRootEvents(thread, false, true); + } + onThreadDelete(thread) { + this.threads.delete(thread.id); + const timeline = this.getTimelineForEvent(thread.id); + const roomEvent = timeline?.getEvents()?.find(it => it.getId() === thread.id); + if (roomEvent) { + thread.clearEventMetadata(roomEvent); + } else { + _logger.logger.debug("onThreadDelete: Could not find root event in room timeline"); + } + for (const timelineSet of this.threadsTimelineSets) { + timelineSet.removeEvent(thread.id); + } + } + + /** + * Forget the timelineSet for this room with the given filter + * + * @param filter - the filter whose timelineSet is to be forgotten + */ + removeFilteredTimelineSet(filter) { + const timelineSet = this.filteredTimelineSets[filter.filterId]; + delete this.filteredTimelineSets[filter.filterId]; + const i = this.timelineSets.indexOf(timelineSet); + if (i > -1) { + this.timelineSets.splice(i, 1); + } + } + eventShouldLiveIn(event, events, roots) { + if (!this.client?.supportsThreads()) { + return { + shouldLiveInRoom: true, + shouldLiveInThread: false + }; + } + + // A thread root is always shown in both timelines + if (event.isThreadRoot || roots?.has(event.getId())) { + return { + shouldLiveInRoom: true, + shouldLiveInThread: true, + threadId: event.getId() + }; + } + + // A thread relation is always only shown in a thread + if (event.isRelation(_thread.THREAD_RELATION_TYPE.name)) { + return { + shouldLiveInRoom: false, + shouldLiveInThread: true, + threadId: event.threadRootId + }; + } + const parentEventId = event.getAssociatedId(); + let parentEvent; + if (parentEventId) { + parentEvent = this.findEventById(parentEventId) ?? events?.find(e => e.getId() === parentEventId); + } + + // Treat relations and redactions as extensions of their parents so evaluate parentEvent instead + if (parentEvent && (event.isRelation() || event.isRedaction())) { + return this.eventShouldLiveIn(parentEvent, events, roots); + } + if (!event.isRelation()) { + return { + shouldLiveInRoom: true, + shouldLiveInThread: false + }; + } + + // Edge case where we know the event is a relation but don't have the parentEvent + if (roots?.has(event.relationEventId)) { + return { + shouldLiveInRoom: true, + shouldLiveInThread: true, + threadId: event.relationEventId + }; + } + const unsigned = event.getUnsigned(); + if (typeof unsigned[_event2.UNSIGNED_THREAD_ID_FIELD.name] === "string") { + return { + shouldLiveInRoom: false, + shouldLiveInThread: true, + threadId: unsigned[_event2.UNSIGNED_THREAD_ID_FIELD.name] + }; + } + + // We've exhausted all scenarios, + // we cannot assume that it lives in the main timeline as this may be a relation for an unknown thread + // adding the event in the wrong timeline causes stuck notifications and can break ability to send read receipts + return { + shouldLiveInRoom: false, + shouldLiveInThread: false + }; + } + findThreadForEvent(event) { + if (!event) return null; + const { + threadId + } = this.eventShouldLiveIn(event); + return threadId ? this.getThread(threadId) : null; + } + addThreadedEvents(threadId, events, toStartOfTimeline = false) { + const thread = this.getThread(threadId); + if (thread) { + thread.addEvents(events, toStartOfTimeline); + } else { + const rootEvent = this.findEventById(threadId) ?? events.find(e => e.getId() === threadId); + this.createThread(threadId, rootEvent, events, toStartOfTimeline); + } + } + + /** + * Adds events to a thread's timeline. Will fire "Thread.update" + */ + processThreadedEvents(events, toStartOfTimeline) { + events.forEach(this.applyRedaction); + const eventsByThread = {}; + for (const event of events) { + const { + threadId, + shouldLiveInThread + } = this.eventShouldLiveIn(event); + if (shouldLiveInThread && !eventsByThread[threadId]) { + eventsByThread[threadId] = []; + } + eventsByThread[threadId]?.push(event); + } + Object.entries(eventsByThread).map(([threadId, threadEvents]) => this.addThreadedEvents(threadId, threadEvents, toStartOfTimeline)); + } + createThread(threadId, rootEvent, events = [], toStartOfTimeline) { + if (this.threads.has(threadId)) { + return this.threads.get(threadId); + } + if (rootEvent) { + const relatedEvents = this.relations.getAllChildEventsForEvent(rootEvent.getId()); + if (relatedEvents?.length) { + // Include all relations of the root event, given it'll be visible in both timelines, + // except `m.replace` as that will already be applied atop the event using `MatrixEvent::makeReplaced` + events = events.concat(relatedEvents.filter(e => !e.isRelation(_event2.RelationType.Replace))); + } + } + const thread = new _thread.Thread(threadId, rootEvent, { + room: this, + client: this.client, + pendingEventOrdering: this.opts.pendingEventOrdering, + receipts: this.cachedThreadReadReceipts.get(threadId) ?? [] + }); + + // All read receipts should now come down from sync, we do not need to keep + // a reference to the cached receipts anymore. + this.cachedThreadReadReceipts.delete(threadId); + + // If we managed to create a thread and figure out its `id` then we can use it + // This has to happen before thread.addEvents, because that adds events to the eventtimeline, and the + // eventtimeline sometimes looks up thread information via the room. + this.threads.set(thread.id, thread); + + // This is necessary to be able to jump to events in threads: + // If we jump to an event in a thread where neither the event, nor the root, + // nor any thread event are loaded yet, we'll load the event as well as the thread root, create the thread, + // and pass the event through this. + thread.addEvents(events, false); + this.reEmitter.reEmit(thread, [_thread.ThreadEvent.Delete, _thread.ThreadEvent.Update, _thread.ThreadEvent.NewReply, RoomEvent.Timeline, RoomEvent.TimelineReset]); + const isNewer = this.lastThread?.rootEvent && rootEvent?.localTimestamp && this.lastThread.rootEvent?.localTimestamp < rootEvent?.localTimestamp; + if (!this.lastThread || isNewer) { + this.lastThread = thread; + } + if (this.threadsReady) { + this.updateThreadRootEvents(thread, toStartOfTimeline, false); + } + this.emit(_thread.ThreadEvent.New, thread, toStartOfTimeline); + return thread; + } + processLiveEvent(event) { + this.applyRedaction(event); + + // Implement MSC3531: hiding messages. + if (event.isVisibilityEvent()) { + // This event changes the visibility of another event, record + // the visibility change, inform clients if necessary. + this.applyNewVisibilityEvent(event); + } + // If any pending visibility change is waiting for this (older) event, + this.applyPendingVisibilityEvents(event); + + // Sliding Sync modifications: + // The proxy cannot guarantee every sent event will have a transaction_id field, so we need + // to check the event ID against the list of pending events if there is no transaction ID + // field. Only do this for events sent by us though as it's potentially expensive to loop + // the pending events map. + const txnId = event.getUnsigned().transaction_id; + if (!txnId && event.getSender() === this.myUserId) { + // check the txn map for a matching event ID + for (const [tid, localEvent] of this.txnToEvent) { + if (localEvent.getId() === event.getId()) { + _logger.logger.debug("processLiveEvent: found sent event without txn ID: ", tid, event.getId()); + // update the unsigned field so we can re-use the same codepaths + const unsigned = event.getUnsigned(); + unsigned.transaction_id = tid; + event.setUnsigned(unsigned); + break; + } + } + } + } + + /** + * Add an event to the end of this room's live timelines. Will fire + * "Room.timeline". + * + * @param event - Event to be added + * @param addLiveEventOptions - addLiveEvent options + * @internal + * + * @remarks + * Fires {@link RoomEvent.Timeline} + */ + addLiveEvent(event, addLiveEventOptions) { + const { + duplicateStrategy, + timelineWasEmpty, + fromCache + } = addLiveEventOptions; + + // add to our timeline sets + for (const timelineSet of this.timelineSets) { + timelineSet.addLiveEvent(event, { + duplicateStrategy, + fromCache, + timelineWasEmpty + }); + } + + // synthesize and inject implicit read receipts + // Done after adding the event because otherwise the app would get a read receipt + // pointing to an event that wasn't yet in the timeline + // Don't synthesize RR for m.room.redaction as this causes the RR to go missing. + if (event.sender && event.getType() !== _event2.EventType.RoomRedaction) { + this.addReceipt((0, _readReceipt.synthesizeReceipt)(event.sender.userId, event, _read_receipts.ReceiptType.Read), true); + + // Any live events from a user could be taken as implicit + // presence information: evidence that they are currently active. + // ...except in a world where we use 'user.currentlyActive' to reduce + // presence spam, this isn't very useful - we'll get a transition when + // they are no longer currently active anyway. So don't bother to + // reset the lastActiveAgo and lastPresenceTs from the RoomState's user. + } + } + + /** + * Add a pending outgoing event to this room. + * + * <p>The event is added to either the pendingEventList, or the live timeline, + * depending on the setting of opts.pendingEventOrdering. + * + * <p>This is an internal method, intended for use by MatrixClient. + * + * @param event - The event to add. + * + * @param txnId - Transaction id for this outgoing event + * + * @throws if the event doesn't have status SENDING, or we aren't given a + * unique transaction id. + * + * @remarks + * Fires {@link RoomEvent.LocalEchoUpdated} + */ + addPendingEvent(event, txnId) { + if (event.status !== _eventStatus.EventStatus.SENDING && event.status !== _eventStatus.EventStatus.NOT_SENT) { + throw new Error("addPendingEvent called on an event with status " + event.status); + } + if (this.txnToEvent.get(txnId)) { + throw new Error("addPendingEvent called on an event with known txnId " + txnId); + } + + // call setEventMetadata to set up event.sender etc + // as event is shared over all timelineSets, we set up its metadata based + // on the unfiltered timelineSet. + _eventTimeline.EventTimeline.setEventMetadata(event, this.getLiveTimeline().getState(_eventTimeline.EventTimeline.FORWARDS), false); + this.txnToEvent.set(txnId, event); + if (this.pendingEventList) { + if (this.pendingEventList.some(e => e.status === _eventStatus.EventStatus.NOT_SENT)) { + _logger.logger.warn("Setting event as NOT_SENT due to messages in the same state"); + event.setStatus(_eventStatus.EventStatus.NOT_SENT); + } + this.pendingEventList.push(event); + this.savePendingEvents(); + if (event.isRelation()) { + // For pending events, add them to the relations collection immediately. + // (The alternate case below already covers this as part of adding to + // the timeline set.) + this.aggregateNonLiveRelation(event); + } + if (event.isRedaction()) { + const redactId = event.event.redacts; + let redactedEvent = this.pendingEventList.find(e => e.getId() === redactId); + if (!redactedEvent && redactId) { + redactedEvent = this.findEventById(redactId); + } + if (redactedEvent) { + redactedEvent.markLocallyRedacted(event); + this.emit(RoomEvent.Redaction, event, this); + } + } + } else { + for (const timelineSet of this.timelineSets) { + if (timelineSet.getFilter()) { + if (timelineSet.getFilter().filterRoomTimeline([event]).length) { + timelineSet.addEventToTimeline(event, timelineSet.getLiveTimeline(), { + toStartOfTimeline: false + }); + } + } else { + timelineSet.addEventToTimeline(event, timelineSet.getLiveTimeline(), { + toStartOfTimeline: false + }); + } + } + } + this.emit(RoomEvent.LocalEchoUpdated, event, this); + } + + /** + * Persists all pending events to local storage + * + * If the current room is encrypted only encrypted events will be persisted + * all messages that are not yet encrypted will be discarded + * + * This is because the flow of EVENT_STATUS transition is + * `queued => sending => encrypting => sending => sent` + * + * Steps 3 and 4 are skipped for unencrypted room. + * It is better to discard an unencrypted message rather than persisting + * it locally for everyone to read + */ + savePendingEvents() { + if (this.pendingEventList) { + const pendingEvents = this.pendingEventList.map(event => { + return _objectSpread(_objectSpread({}, event.event), {}, { + txn_id: event.getTxnId() + }); + }).filter(event => { + // Filter out the unencrypted messages if the room is encrypted + const isEventEncrypted = event.type === _event2.EventType.RoomMessageEncrypted; + const isRoomEncrypted = this.client.isRoomEncrypted(this.roomId); + return isEventEncrypted || !isRoomEncrypted; + }); + this.client.store.setPendingEvents(this.roomId, pendingEvents); + } + } + + /** + * Used to aggregate the local echo for a relation, and also + * for re-applying a relation after it's redaction has been cancelled, + * as the local echo for the redaction of the relation would have + * un-aggregated the relation. Note that this is different from regular messages, + * which are just kept detached for their local echo. + * + * Also note that live events are aggregated in the live EventTimelineSet. + * @param event - the relation event that needs to be aggregated. + */ + aggregateNonLiveRelation(event) { + this.relations.aggregateChildEvent(event); + } + getEventForTxnId(txnId) { + return this.txnToEvent.get(txnId); + } + + /** + * Deal with the echo of a message we sent. + * + * <p>We move the event to the live timeline if it isn't there already, and + * update it. + * + * @param remoteEvent - The event received from + * /sync + * @param localEvent - The local echo, which + * should be either in the pendingEventList or the timeline. + * + * @internal + * + * @remarks + * Fires {@link RoomEvent.LocalEchoUpdated} + */ + handleRemoteEcho(remoteEvent, localEvent) { + const oldEventId = localEvent.getId(); + const newEventId = remoteEvent.getId(); + const oldStatus = localEvent.status; + _logger.logger.debug(`Got remote echo for event ${oldEventId} -> ${newEventId} old status ${oldStatus}`); + + // no longer pending + this.txnToEvent.delete(remoteEvent.getUnsigned().transaction_id); + + // if it's in the pending list, remove it + if (this.pendingEventList) { + this.removePendingEvent(oldEventId); + } + + // replace the event source (this will preserve the plaintext payload if + // any, which is good, because we don't want to try decoding it again). + localEvent.handleRemoteEcho(remoteEvent.event); + const { + shouldLiveInRoom, + threadId + } = this.eventShouldLiveIn(remoteEvent); + const thread = threadId ? this.getThread(threadId) : null; + thread?.setEventMetadata(localEvent); + thread?.timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId); + if (shouldLiveInRoom) { + for (const timelineSet of this.timelineSets) { + // if it's already in the timeline, update the timeline map. If it's not, add it. + timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId); + } + } + this.emit(RoomEvent.LocalEchoUpdated, localEvent, this, oldEventId, oldStatus); + } + + /** + * Update the status / event id on a pending event, to reflect its transmission + * progress. + * + * <p>This is an internal method. + * + * @param event - local echo event + * @param newStatus - status to assign + * @param newEventId - new event id to assign. Ignored unless newStatus == EventStatus.SENT. + * + * @remarks + * Fires {@link RoomEvent.LocalEchoUpdated} + */ + updatePendingEvent(event, newStatus, newEventId) { + _logger.logger.log(`setting pendingEvent status to ${newStatus} in ${event.getRoomId()} ` + `event ID ${event.getId()} -> ${newEventId}`); + + // if the message was sent, we expect an event id + if (newStatus == _eventStatus.EventStatus.SENT && !newEventId) { + throw new Error("updatePendingEvent called with status=SENT, but no new event id"); + } + + // SENT races against /sync, so we have to special-case it. + if (newStatus == _eventStatus.EventStatus.SENT) { + const timeline = this.getTimelineForEvent(newEventId); + if (timeline) { + // we've already received the event via the event stream. + // nothing more to do here, assuming the transaction ID was correctly matched. + // Let's check that. + const remoteEvent = this.findEventById(newEventId); + const remoteTxnId = remoteEvent?.getUnsigned().transaction_id; + if (!remoteTxnId && remoteEvent) { + // This code path is mostly relevant for the Sliding Sync proxy. + // The remote event did not contain a transaction ID, so we did not handle + // the remote echo yet. Handle it now. + const unsigned = remoteEvent.getUnsigned(); + unsigned.transaction_id = event.getTxnId(); + remoteEvent.setUnsigned(unsigned); + // the remote event is _already_ in the timeline, so we need to remove it so + // we can convert the local event into the final event. + this.removeEvent(remoteEvent.getId()); + this.handleRemoteEcho(remoteEvent, event); + } + return; + } + } + const oldStatus = event.status; + const oldEventId = event.getId(); + if (!oldStatus) { + throw new Error("updatePendingEventStatus called on an event which is not a local echo."); + } + const allowed = ALLOWED_TRANSITIONS[oldStatus]; + if (!allowed?.includes(newStatus)) { + throw new Error(`Invalid EventStatus transition ${oldStatus}->${newStatus}`); + } + event.setStatus(newStatus); + if (newStatus == _eventStatus.EventStatus.SENT) { + // update the event id + event.replaceLocalEventId(newEventId); + const { + shouldLiveInRoom, + threadId + } = this.eventShouldLiveIn(event); + const thread = threadId ? this.getThread(threadId) : undefined; + thread?.setEventMetadata(event); + thread?.timelineSet.replaceEventId(oldEventId, newEventId); + if (shouldLiveInRoom) { + // if the event was already in the timeline (which will be the case if + // opts.pendingEventOrdering==chronological), we need to update the + // timeline map. + for (const timelineSet of this.timelineSets) { + timelineSet.replaceEventId(oldEventId, newEventId); + } + } + } else if (newStatus == _eventStatus.EventStatus.CANCELLED) { + // remove it from the pending event list, or the timeline. + if (this.pendingEventList) { + const removedEvent = this.getPendingEvent(oldEventId); + this.removePendingEvent(oldEventId); + if (removedEvent?.isRedaction()) { + this.revertRedactionLocalEcho(removedEvent); + } + } + this.removeEvent(oldEventId); + } + this.savePendingEvents(); + this.emit(RoomEvent.LocalEchoUpdated, event, this, oldEventId, oldStatus); + } + revertRedactionLocalEcho(redactionEvent) { + const redactId = redactionEvent.event.redacts; + if (!redactId) { + return; + } + const redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId); + if (redactedEvent) { + redactedEvent.unmarkLocallyRedacted(); + // re-render after undoing redaction + this.emit(RoomEvent.RedactionCancelled, redactionEvent, this); + // reapply relation now redaction failed + if (redactedEvent.isRelation()) { + this.aggregateNonLiveRelation(redactedEvent); + } + } + } + + /** + * Add some events to this room. This can include state events, message + * events and typing notifications. These events are treated as "live" so + * they will go to the end of the timeline. + * + * @param events - A list of events to add. + * @param addLiveEventOptions - addLiveEvent options + * @throws If `duplicateStrategy` is not falsey, 'replace' or 'ignore'. + */ + + /** + * @deprecated In favor of the overload with `IAddLiveEventOptions` + */ + + async addLiveEvents(events, duplicateStrategyOrOpts, fromCache = false) { + let duplicateStrategy = duplicateStrategyOrOpts; + let timelineWasEmpty = false; + if (typeof duplicateStrategyOrOpts === "object") { + ({ + duplicateStrategy, + fromCache = false, + /* roomState, (not used here) */ + timelineWasEmpty + } = duplicateStrategyOrOpts); + } else if (duplicateStrategyOrOpts !== undefined) { + // Deprecation warning + // FIXME: Remove after 2023-06-01 (technical debt) + _logger.logger.warn("Overload deprecated: " + "`Room.addLiveEvents(events, duplicateStrategy?, fromCache?)` " + "is deprecated in favor of the overload with `Room.addLiveEvents(events, IAddLiveEventOptions)`"); + } + if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) { + throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'"); + } + + // sanity check that the live timeline is still live + for (let i = 0; i < this.timelineSets.length; i++) { + const liveTimeline = this.timelineSets[i].getLiveTimeline(); + if (liveTimeline.getPaginationToken(_eventTimeline.EventTimeline.FORWARDS)) { + throw new Error("live timeline " + i + " is no longer live - it has a pagination token " + "(" + liveTimeline.getPaginationToken(_eventTimeline.EventTimeline.FORWARDS) + ")"); + } + if (liveTimeline.getNeighbouringTimeline(_eventTimeline.EventTimeline.FORWARDS)) { + throw new Error(`live timeline ${i} is no longer live - it has a neighbouring timeline`); + } + } + const threadRoots = this.findThreadRoots(events); + const eventsByThread = {}; + const options = { + duplicateStrategy, + fromCache, + timelineWasEmpty + }; + + // List of extra events to check for being parents of any relations encountered + const neighbouringEvents = [...events]; + for (const event of events) { + // TODO: We should have a filter to say "only add state event types X Y Z to the timeline". + this.processLiveEvent(event); + if (event.getUnsigned().transaction_id) { + const existingEvent = this.txnToEvent.get(event.getUnsigned().transaction_id); + if (existingEvent) { + // remote echo of an event we sent earlier + this.handleRemoteEcho(event, existingEvent); + continue; // we can skip adding the event to the timeline sets, it is already there + } + } + + let { + shouldLiveInRoom, + shouldLiveInThread, + threadId + } = this.eventShouldLiveIn(event, neighbouringEvents, threadRoots); + if (!shouldLiveInThread && !shouldLiveInRoom && event.isRelation()) { + try { + const parentEvent = new _event.MatrixEvent(await this.client.fetchRoomEvent(this.roomId, event.relationEventId)); + neighbouringEvents.push(parentEvent); + if (parentEvent.threadRootId) { + threadRoots.add(parentEvent.threadRootId); + const unsigned = event.getUnsigned(); + unsigned[_event2.UNSIGNED_THREAD_ID_FIELD.name] = parentEvent.threadRootId; + event.setUnsigned(unsigned); + } + ({ + shouldLiveInRoom, + shouldLiveInThread, + threadId + } = this.eventShouldLiveIn(event, neighbouringEvents, threadRoots)); + } catch (e) { + _logger.logger.error("Failed to load parent event of unhandled relation", e); + } + } + if (shouldLiveInThread && !eventsByThread[threadId ?? ""]) { + eventsByThread[threadId ?? ""] = []; + } + eventsByThread[threadId ?? ""]?.push(event); + if (shouldLiveInRoom) { + this.addLiveEvent(event, options); + } else if (!shouldLiveInThread && event.isRelation()) { + this.relations.aggregateChildEvent(event); + } + } + Object.entries(eventsByThread).forEach(([threadId, threadEvents]) => { + this.addThreadedEvents(threadId, threadEvents, false); + }); + } + partitionThreadedEvents(events) { + // Indices to the events array, for readability + const ROOM = 0; + const THREAD = 1; + const UNKNOWN_RELATION = 2; + if (this.client.supportsThreads()) { + const threadRoots = this.findThreadRoots(events); + return events.reduce((memo, event) => { + const { + shouldLiveInRoom, + shouldLiveInThread, + threadId + } = this.eventShouldLiveIn(event, events, threadRoots); + if (shouldLiveInRoom) { + memo[ROOM].push(event); + } + if (shouldLiveInThread) { + event.setThreadId(threadId ?? ""); + memo[THREAD].push(event); + } + if (!shouldLiveInThread && !shouldLiveInRoom) { + memo[UNKNOWN_RELATION].push(event); + } + return memo; + }, [[], [], []]); + } else { + // When `experimentalThreadSupport` is disabled treat all events as timelineEvents + return [events, [], []]; + } + } + + /** + * Given some events, find the IDs of all the thread roots that are referred to by them. + */ + findThreadRoots(events) { + const threadRoots = new Set(); + for (const event of events) { + if (event.isRelation(_thread.THREAD_RELATION_TYPE.name)) { + threadRoots.add(event.relationEventId ?? ""); + } + const unsigned = event.getUnsigned(); + if (typeof unsigned[_event2.UNSIGNED_THREAD_ID_FIELD.name] === "string") { + threadRoots.add(unsigned[_event2.UNSIGNED_THREAD_ID_FIELD.name]); + } + } + return threadRoots; + } + + /** + * Add a receipt event to the room. + * @param event - The m.receipt event. + * @param synthetic - True if this event is implicit. + */ + addReceipt(event, synthetic = false) { + const content = event.getContent(); + Object.keys(content).forEach(eventId => { + Object.keys(content[eventId]).forEach(receiptType => { + Object.keys(content[eventId][receiptType]).forEach(userId => { + const receipt = content[eventId][receiptType][userId]; + const receiptForMainTimeline = !receipt.thread_id || receipt.thread_id === _read_receipts.MAIN_ROOM_TIMELINE; + const receiptDestination = receiptForMainTimeline ? this : this.threads.get(receipt.thread_id ?? ""); + if (receiptDestination) { + receiptDestination.addReceiptToStructure(eventId, receiptType, userId, receipt, synthetic); + + // If the read receipt sent for the logged in user matches + // the last event of the live timeline, then we know for a fact + // that the user has read that message. + // We can mark the room as read and not wait for the local echo + // from synapse + // This needs to be done after the initial sync as we do not want this + // logic to run whilst the room is being initialised + if (this.client.isInitialSyncComplete() && userId === this.client.getUserId()) { + const lastEvent = receiptDestination.timeline[receiptDestination.timeline.length - 1]; + if (lastEvent && eventId === lastEvent.getId() && userId === lastEvent.getSender()) { + receiptDestination.setUnread(NotificationCountType.Total, 0); + receiptDestination.setUnread(NotificationCountType.Highlight, 0); + } + } + } else { + // The thread does not exist locally, keep the read receipt + // in a cache locally, and re-apply the `addReceipt` logic + // when the thread is created + this.cachedThreadReadReceipts.set(receipt.thread_id, [...(this.cachedThreadReadReceipts.get(receipt.thread_id) ?? []), { + eventId, + receiptType, + userId, + receipt, + synthetic + }]); + } + const me = this.client.getUserId(); + // Track the time of the current user's oldest threaded receipt in the room. + if (userId === me && !receiptForMainTimeline && receipt.ts < this.oldestThreadedReceiptTs) { + this.oldestThreadedReceiptTs = receipt.ts; + } + + // Track each user's unthreaded read receipt. + if (!receipt.thread_id && receipt.ts > (this.unthreadedReceipts.get(userId)?.ts ?? 0)) { + this.unthreadedReceipts.set(userId, receipt); + } + }); + }); + }); + + // send events after we've regenerated the structure & cache, otherwise things that + // listened for the event would read stale data. + this.emit(RoomEvent.Receipt, event, this); + } + + /** + * Adds/handles ephemeral events such as typing notifications and read receipts. + * @param events - A list of events to process + */ + addEphemeralEvents(events) { + for (const event of events) { + if (event.getType() === _event2.EventType.Typing) { + this.currentState.setTypingEvent(event); + } else if (event.getType() === _event2.EventType.Receipt) { + this.addReceipt(event); + } // else ignore - life is too short for us to care about these events + } + } + + /** + * Removes events from this room. + * @param eventIds - A list of eventIds to remove. + */ + removeEvents(eventIds) { + for (const eventId of eventIds) { + this.removeEvent(eventId); + } + } + + /** + * Removes a single event from this room. + * + * @param eventId - The id of the event to remove + * + * @returns true if the event was removed from any of the room's timeline sets + */ + removeEvent(eventId) { + let removedAny = false; + for (const timelineSet of this.timelineSets) { + const removed = timelineSet.removeEvent(eventId); + if (removed) { + if (removed.isRedaction()) { + this.revertRedactionLocalEcho(removed); + } + removedAny = true; + } + } + return removedAny; + } + + /** + * Recalculate various aspects of the room, including the room name and + * room summary. Call this any time the room's current state is modified. + * May fire "Room.name" if the room name is updated. + * + * @remarks + * Fires {@link RoomEvent.Name} + */ + recalculate() { + // set fake stripped state events if this is an invite room so logic remains + // consistent elsewhere. + const membershipEvent = this.currentState.getStateEvents(_event2.EventType.RoomMember, this.myUserId); + if (membershipEvent) { + const membership = membershipEvent.getContent().membership; + this.updateMyMembership(membership); + if (membership === "invite") { + const strippedStateEvents = membershipEvent.getUnsigned().invite_room_state || []; + strippedStateEvents.forEach(strippedEvent => { + const existingEvent = this.currentState.getStateEvents(strippedEvent.type, strippedEvent.state_key); + if (!existingEvent) { + // set the fake stripped event instead + this.currentState.setStateEvents([new _event.MatrixEvent({ + type: strippedEvent.type, + state_key: strippedEvent.state_key, + content: strippedEvent.content, + event_id: "$fake" + Date.now(), + room_id: this.roomId, + user_id: this.myUserId // technically a lie + })]); + } + }); + } + } + + const oldName = this.name; + this.name = this.calculateRoomName(this.myUserId); + this.normalizedName = (0, _utils.normalize)(this.name); + this.summary = new _roomSummary.RoomSummary(this.roomId, { + title: this.name + }); + if (oldName !== this.name) { + this.emit(RoomEvent.Name, this); + } + } + + /** + * Update the room-tag event for the room. The previous one is overwritten. + * @param event - the m.tag event + */ + addTags(event) { + // event content looks like: + // content: { + // tags: { + // $tagName: { $metadata: $value }, + // $tagName: { $metadata: $value }, + // } + // } + + // XXX: do we need to deep copy here? + this.tags = event.getContent().tags || {}; + + // XXX: we could do a deep-comparison to see if the tags have really + // changed - but do we want to bother? + this.emit(RoomEvent.Tags, event, this); + } + + /** + * Update the account_data events for this room, overwriting events of the same type. + * @param events - an array of account_data events to add + */ + addAccountData(events) { + for (const event of events) { + if (event.getType() === "m.tag") { + this.addTags(event); + } + const eventType = event.getType(); + const lastEvent = this.accountData.get(eventType); + this.accountData.set(eventType, event); + this.emit(RoomEvent.AccountData, event, this, lastEvent); + } + } + + /** + * Access account_data event of given event type for this room + * @param type - the type of account_data event to be accessed + * @returns the account_data event in question + */ + getAccountData(type) { + return this.accountData.get(type); + } + + /** + * Returns whether the syncing user has permission to send a message in the room + * @returns true if the user should be permitted to send + * message events into the room. + */ + maySendMessage() { + return this.getMyMembership() === "join" && (this.client.isRoomEncrypted(this.roomId) ? this.currentState.maySendEvent(_event2.EventType.RoomMessageEncrypted, this.myUserId) : this.currentState.maySendEvent(_event2.EventType.RoomMessage, this.myUserId)); + } + + /** + * Returns whether the given user has permissions to issue an invite for this room. + * @param userId - the ID of the Matrix user to check permissions for + * @returns true if the user should be permitted to issue invites for this room. + */ + canInvite(userId) { + let canInvite = this.getMyMembership() === "join"; + const powerLevelsEvent = this.currentState.getStateEvents(_event2.EventType.RoomPowerLevels, ""); + const powerLevels = powerLevelsEvent && powerLevelsEvent.getContent(); + const me = this.getMember(userId); + if (powerLevels && me && powerLevels.invite > me.powerLevel) { + canInvite = false; + } + return canInvite; + } + + /** + * Returns the join rule based on the m.room.join_rule state event, defaulting to `invite`. + * @returns the join_rule applied to this room + */ + getJoinRule() { + return this.currentState.getJoinRule(); + } + + /** + * Returns the history visibility based on the m.room.history_visibility state event, defaulting to `shared`. + * @returns the history_visibility applied to this room + */ + getHistoryVisibility() { + return this.currentState.getHistoryVisibility(); + } + + /** + * Returns the history visibility based on the m.room.history_visibility state event, defaulting to `shared`. + * @returns the history_visibility applied to this room + */ + getGuestAccess() { + return this.currentState.getGuestAccess(); + } + + /** + * Returns the type of the room from the `m.room.create` event content or undefined if none is set + * @returns the type of the room. + */ + getType() { + const createEvent = this.currentState.getStateEvents(_event2.EventType.RoomCreate, ""); + if (!createEvent) { + if (!this.getTypeWarning) { + _logger.logger.warn("[getType] Room " + this.roomId + " does not have an m.room.create event"); + this.getTypeWarning = true; + } + return undefined; + } + return createEvent.getContent()[_event2.RoomCreateTypeField]; + } + + /** + * Returns whether the room is a space-room as defined by MSC1772. + * @returns true if the room's type is RoomType.Space + */ + isSpaceRoom() { + return this.getType() === _event2.RoomType.Space; + } + + /** + * Returns whether the room is a call-room as defined by MSC3417. + * @returns true if the room's type is RoomType.UnstableCall + */ + isCallRoom() { + return this.getType() === _event2.RoomType.UnstableCall; + } + + /** + * Returns whether the room is a video room. + * @returns true if the room's type is RoomType.ElementVideo + */ + isElementVideoRoom() { + return this.getType() === _event2.RoomType.ElementVideo; + } + + /** + * Find the predecessor of this room. + * + * @param msc3946ProcessDynamicPredecessor - if true, look for an + * m.room.predecessor state event and use it if found (MSC3946). + * @returns null if this room has no predecessor. Otherwise, returns + * the roomId, last eventId and viaServers of the predecessor room. + * + * If msc3946ProcessDynamicPredecessor is true, use m.predecessor events + * as well as m.room.create events to find predecessors. + * + * Note: if an m.predecessor event is used, eventId may be undefined + * since last_known_event_id is optional. + * + * Note: viaServers may be undefined, and will definitely be undefined if + * this predecessor comes from a RoomCreate event (rather than a + * RoomPredecessor, which has the optional via_servers property). + */ + findPredecessor(msc3946ProcessDynamicPredecessor = false) { + const currentState = this.getLiveTimeline().getState(_eventTimeline.EventTimeline.FORWARDS); + if (!currentState) { + return null; + } + return currentState.findPredecessor(msc3946ProcessDynamicPredecessor); + } + roomNameGenerator(state) { + if (this.client.roomNameGenerator) { + const name = this.client.roomNameGenerator(this.roomId, state); + if (name !== null) { + return name; + } + } + switch (state.type) { + case RoomNameType.Actual: + return state.name; + case RoomNameType.Generated: + switch (state.subtype) { + case "Inviting": + return `Inviting ${memberNamesToRoomName(state.names, state.count)}`; + default: + return memberNamesToRoomName(state.names, state.count); + } + case RoomNameType.EmptyRoom: + if (state.oldName) { + return `Empty room (was ${state.oldName})`; + } else { + return "Empty room"; + } + } + } + + /** + * This is an internal method. Calculates the name of the room from the current + * room state. + * @param userId - The client's user ID. Used to filter room members + * correctly. + * @param ignoreRoomNameEvent - Return the implicit room name that we'd see if there + * was no m.room.name event. + * @returns The calculated room name. + */ + calculateRoomName(userId, ignoreRoomNameEvent = false) { + if (!ignoreRoomNameEvent) { + // check for an alias, if any. for now, assume first alias is the + // official one. + const mRoomName = this.currentState.getStateEvents(_event2.EventType.RoomName, ""); + if (mRoomName?.getContent().name) { + return this.roomNameGenerator({ + type: RoomNameType.Actual, + name: mRoomName.getContent().name + }); + } + } + const alias = this.getCanonicalAlias(); + if (alias) { + return this.roomNameGenerator({ + type: RoomNameType.Actual, + name: alias + }); + } + const joinedMemberCount = this.currentState.getJoinedMemberCount(); + const invitedMemberCount = this.currentState.getInvitedMemberCount(); + // -1 because these numbers include the syncing user + let inviteJoinCount = joinedMemberCount + invitedMemberCount - 1; + + // get service members (e.g. helper bots) for exclusion + let excludedUserIds = []; + const mFunctionalMembers = this.currentState.getStateEvents(_event2.UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, ""); + if (Array.isArray(mFunctionalMembers?.getContent().service_members)) { + excludedUserIds = mFunctionalMembers.getContent().service_members; + } + + // get members that are NOT ourselves and are actually in the room. + let otherNames = []; + if (this.summaryHeroes) { + // if we have a summary, the member state events should be in the room state + this.summaryHeroes.forEach(userId => { + // filter service members + if (excludedUserIds.includes(userId)) { + inviteJoinCount--; + return; + } + const member = this.getMember(userId); + otherNames.push(member ? member.name : userId); + }); + } else { + let otherMembers = this.currentState.getMembers().filter(m => { + return m.userId !== userId && (m.membership === "invite" || m.membership === "join"); + }); + otherMembers = otherMembers.filter(({ + userId + }) => { + // filter service members + if (excludedUserIds.includes(userId)) { + inviteJoinCount--; + return false; + } + return true; + }); + // make sure members have stable order + otherMembers.sort((a, b) => (0, _utils.compare)(a.userId, b.userId)); + // only 5 first members, immitate summaryHeroes + otherMembers = otherMembers.slice(0, 5); + otherNames = otherMembers.map(m => m.name); + } + if (inviteJoinCount) { + return this.roomNameGenerator({ + type: RoomNameType.Generated, + names: otherNames, + count: inviteJoinCount + }); + } + const myMembership = this.getMyMembership(); + // if I have created a room and invited people through + // 3rd party invites + if (myMembership == "join") { + const thirdPartyInvites = this.currentState.getStateEvents(_event2.EventType.RoomThirdPartyInvite); + if (thirdPartyInvites?.length) { + const thirdPartyNames = thirdPartyInvites.map(i => { + return i.getContent().display_name; + }); + return this.roomNameGenerator({ + type: RoomNameType.Generated, + subtype: "Inviting", + names: thirdPartyNames, + count: thirdPartyNames.length + 1 + }); + } + } + + // let's try to figure out who was here before + let leftNames = otherNames; + // if we didn't have heroes, try finding them in the room state + if (!leftNames.length) { + leftNames = this.currentState.getMembers().filter(m => { + return m.userId !== userId && m.membership !== "invite" && m.membership !== "join"; + }).map(m => m.name); + } + let oldName; + if (leftNames.length) { + oldName = this.roomNameGenerator({ + type: RoomNameType.Generated, + names: leftNames, + count: leftNames.length + 1 + }); + } + return this.roomNameGenerator({ + type: RoomNameType.EmptyRoom, + oldName + }); + } + + /** + * When we receive a new visibility change event: + * + * - store this visibility change alongside the timeline, in case we + * later need to apply it to an event that we haven't received yet; + * - if we have already received the event whose visibility has changed, + * patch it to reflect the visibility change and inform listeners. + */ + applyNewVisibilityEvent(event) { + const visibilityChange = event.asVisibilityChange(); + if (!visibilityChange) { + // The event is ill-formed. + return; + } + + // Ignore visibility change events that are not emitted by moderators. + const userId = event.getSender(); + if (!userId) { + return; + } + const isPowerSufficient = _event2.EVENT_VISIBILITY_CHANGE_TYPE.name && this.currentState.maySendStateEvent(_event2.EVENT_VISIBILITY_CHANGE_TYPE.name, userId) || _event2.EVENT_VISIBILITY_CHANGE_TYPE.altName && this.currentState.maySendStateEvent(_event2.EVENT_VISIBILITY_CHANGE_TYPE.altName, userId); + if (!isPowerSufficient) { + // Powerlevel is insufficient. + return; + } + + // Record this change in visibility. + // If the event is not in our timeline and we only receive it later, + // we may need to apply the visibility change at a later date. + + const visibilityEventsOnOriginalEvent = this.visibilityEvents.get(visibilityChange.eventId); + if (visibilityEventsOnOriginalEvent) { + // It would be tempting to simply erase the latest visibility change + // but we need to record all of the changes in case the latest change + // is ever redacted. + // + // In practice, linear scans through `visibilityEvents` should be fast. + // However, to protect against a potential DoS attack, we limit the + // number of iterations in this loop. + let index = visibilityEventsOnOriginalEvent.length - 1; + const min = Math.max(0, visibilityEventsOnOriginalEvent.length - MAX_NUMBER_OF_VISIBILITY_EVENTS_TO_SCAN_THROUGH); + for (; index >= min; --index) { + const target = visibilityEventsOnOriginalEvent[index]; + if (target.getTs() < event.getTs()) { + break; + } + } + if (index === -1) { + visibilityEventsOnOriginalEvent.unshift(event); + } else { + visibilityEventsOnOriginalEvent.splice(index + 1, 0, event); + } + } else { + this.visibilityEvents.set(visibilityChange.eventId, [event]); + } + + // Finally, let's check if the event is already in our timeline. + // If so, we need to patch it and inform listeners. + + const originalEvent = this.findEventById(visibilityChange.eventId); + if (!originalEvent) { + return; + } + originalEvent.applyVisibilityEvent(visibilityChange); + } + redactVisibilityChangeEvent(event) { + // Sanity checks. + if (!event.isVisibilityEvent) { + throw new Error("expected a visibility change event"); + } + const relation = event.getRelation(); + const originalEventId = relation?.event_id; + const visibilityEventsOnOriginalEvent = this.visibilityEvents.get(originalEventId); + if (!visibilityEventsOnOriginalEvent) { + // No visibility changes on the original event. + // In particular, this change event was not recorded, + // most likely because it was ill-formed. + return; + } + const index = visibilityEventsOnOriginalEvent.findIndex(change => change.getId() === event.getId()); + if (index === -1) { + // This change event was not recorded, most likely because + // it was ill-formed. + return; + } + // Remove visibility change. + visibilityEventsOnOriginalEvent.splice(index, 1); + + // If we removed the latest visibility change event, propagate changes. + if (index === visibilityEventsOnOriginalEvent.length) { + const originalEvent = this.findEventById(originalEventId); + if (!originalEvent) { + return; + } + if (index === 0) { + // We have just removed the only visibility change event. + this.visibilityEvents.delete(originalEventId); + originalEvent.applyVisibilityEvent(); + } else { + const newEvent = visibilityEventsOnOriginalEvent[visibilityEventsOnOriginalEvent.length - 1]; + const newVisibility = newEvent.asVisibilityChange(); + if (!newVisibility) { + // Event is ill-formed. + // This breaks our invariant. + throw new Error("at this stage, visibility changes should be well-formed"); + } + originalEvent.applyVisibilityEvent(newVisibility); + } + } + } + + /** + * When we receive an event whose visibility has been altered by + * a (more recent) visibility change event, patch the event in + * place so that clients now not to display it. + * + * @param event - Any matrix event. If this event has at least one a + * pending visibility change event, apply the latest visibility + * change event. + */ + applyPendingVisibilityEvents(event) { + const visibilityEvents = this.visibilityEvents.get(event.getId()); + if (!visibilityEvents || visibilityEvents.length == 0) { + // No pending visibility change in store. + return; + } + const visibilityEvent = visibilityEvents[visibilityEvents.length - 1]; + const visibilityChange = visibilityEvent.asVisibilityChange(); + if (!visibilityChange) { + return; + } + if (visibilityChange.visible) { + // Events are visible by default, no need to apply a visibility change. + // Note that we need to keep the visibility changes in `visibilityEvents`, + // in case we later fetch an older visibility change event that is superseded + // by `visibilityChange`. + } + if (visibilityEvent.getTs() < event.getTs()) { + // Something is wrong, the visibility change cannot happen before the + // event. Presumably an ill-formed event. + return; + } + event.applyVisibilityEvent(visibilityChange); + } + + /** + * Find when a client has gained thread capabilities by inspecting the oldest + * threaded receipt + * @returns the timestamp of the oldest threaded receipt + */ + getOldestThreadedReceiptTs() { + return this.oldestThreadedReceiptTs; + } + + /** + * Returns the most recent unthreaded receipt for a given user + * @param userId - the MxID of the User + * @returns an unthreaded Receipt. Can be undefined if receipts have been disabled + * or a user chooses to use private read receipts (or we have simply not received + * a receipt from this user yet). + */ + getLastUnthreadedReceiptFor(userId) { + return this.unthreadedReceipts.get(userId); + } + + /** + * This issue should also be addressed on synapse's side and is tracked as part + * of https://github.com/matrix-org/synapse/issues/14837 + * + * + * We consider a room fully read if the current user has sent + * the last event in the live timeline of that context and if the read receipt + * we have on record matches. + * This also detects all unread threads and applies the same logic to those + * contexts + */ + fixupNotifications(userId) { + super.fixupNotifications(userId); + const unreadThreads = this.getThreads().filter(thread => this.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) > 0); + for (const thread of unreadThreads) { + thread.fixupNotifications(userId); + } + } +} + +// a map from current event status to a list of allowed next statuses +exports.Room = Room; +const ALLOWED_TRANSITIONS = { + [_eventStatus.EventStatus.ENCRYPTING]: [_eventStatus.EventStatus.SENDING, _eventStatus.EventStatus.NOT_SENT, _eventStatus.EventStatus.CANCELLED], + [_eventStatus.EventStatus.SENDING]: [_eventStatus.EventStatus.ENCRYPTING, _eventStatus.EventStatus.QUEUED, _eventStatus.EventStatus.NOT_SENT, _eventStatus.EventStatus.SENT], + [_eventStatus.EventStatus.QUEUED]: [_eventStatus.EventStatus.SENDING, _eventStatus.EventStatus.NOT_SENT, _eventStatus.EventStatus.CANCELLED], + [_eventStatus.EventStatus.SENT]: [], + [_eventStatus.EventStatus.NOT_SENT]: [_eventStatus.EventStatus.SENDING, _eventStatus.EventStatus.QUEUED, _eventStatus.EventStatus.CANCELLED], + [_eventStatus.EventStatus.CANCELLED]: [] +}; +let RoomNameType = /*#__PURE__*/function (RoomNameType) { + RoomNameType[RoomNameType["EmptyRoom"] = 0] = "EmptyRoom"; + RoomNameType[RoomNameType["Generated"] = 1] = "Generated"; + RoomNameType[RoomNameType["Actual"] = 2] = "Actual"; + return RoomNameType; +}({}); +exports.RoomNameType = RoomNameType; +// Can be overriden by IMatrixClientCreateOpts::memberNamesToRoomNameFn +function memberNamesToRoomName(names, count) { + 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`; + } + } +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/search-result.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/search-result.js new file mode 100644 index 0000000000..e0b1137236 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/search-result.js @@ -0,0 +1,58 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.SearchResult = void 0; +var _eventContext = require("./event-context"); +/* +Copyright 2015 - 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +class SearchResult { + /** + * Create a SearchResponse from the response to /search + */ + + static fromJson(jsonObj, eventMapper) { + const jsonContext = jsonObj.context || {}; + let eventsBefore = (jsonContext.events_before || []).map(eventMapper); + let eventsAfter = (jsonContext.events_after || []).map(eventMapper); + const context = new _eventContext.EventContext(eventMapper(jsonObj.result)); + + // Filter out any contextual events which do not correspond to the same timeline (thread or room) + const threadRootId = context.ourEvent.threadRootId; + eventsBefore = eventsBefore.filter(e => e.threadRootId === threadRootId); + eventsAfter = eventsAfter.filter(e => e.threadRootId === threadRootId); + context.setPaginateToken(jsonContext.start, true); + context.addEvents(eventsBefore, true); + context.addEvents(eventsAfter, false); + context.setPaginateToken(jsonContext.end, false); + return new SearchResult(jsonObj.rank, context); + } + + /** + * Construct a new SearchResult + * + * @param rank - where this SearchResult ranks in the results + * @param context - the matching event and its + * context + */ + constructor(rank, context) { + this.rank = rank; + this.context = context; + } +} +exports.SearchResult = SearchResult;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/thread.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/thread.js new file mode 100644 index 0000000000..4471d512e2 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/thread.js @@ -0,0 +1,649 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.ThreadFilterType = exports.ThreadEvent = exports.Thread = exports.THREAD_RELATION_TYPE = exports.FeatureSupport = exports.FILTER_RELATED_BY_SENDERS = exports.FILTER_RELATED_BY_REL_TYPES = void 0; +exports.determineFeatureSupport = determineFeatureSupport; +exports.threadFilterTypeToFilter = threadFilterTypeToFilter; +var _client = require("../client"); +var _ReEmitter = require("../ReEmitter"); +var _event = require("../@types/event"); +var _event2 = require("./event"); +var _eventTimeline = require("./event-timeline"); +var _eventTimelineSet = require("./event-timeline-set"); +var _room = require("./room"); +var _NamespacedValue = require("../NamespacedValue"); +var _logger = require("../logger"); +var _readReceipt = require("./read-receipt"); +var _read_receipts = require("../@types/read_receipts"); +var _feature = require("../feature"); +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2021 - 2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +let ThreadEvent = /*#__PURE__*/function (ThreadEvent) { + ThreadEvent["New"] = "Thread.new"; + ThreadEvent["Update"] = "Thread.update"; + ThreadEvent["NewReply"] = "Thread.newReply"; + ThreadEvent["ViewThread"] = "Thread.viewThread"; + ThreadEvent["Delete"] = "Thread.delete"; + return ThreadEvent; +}({}); +exports.ThreadEvent = ThreadEvent; +let FeatureSupport = /*#__PURE__*/function (FeatureSupport) { + FeatureSupport[FeatureSupport["None"] = 0] = "None"; + FeatureSupport[FeatureSupport["Experimental"] = 1] = "Experimental"; + FeatureSupport[FeatureSupport["Stable"] = 2] = "Stable"; + return FeatureSupport; +}({}); +exports.FeatureSupport = FeatureSupport; +function determineFeatureSupport(stable, unstable) { + if (stable) { + return FeatureSupport.Stable; + } else if (unstable) { + return FeatureSupport.Experimental; + } else { + return FeatureSupport.None; + } +} +class Thread extends _readReceipt.ReadReceipt { + constructor(id, rootEvent, opts) { + super(); + this.id = id; + this.rootEvent = rootEvent; + /** + * A reference to all the events ID at the bottom of the threads + */ + _defineProperty(this, "timelineSet", void 0); + _defineProperty(this, "timeline", []); + _defineProperty(this, "_currentUserParticipated", false); + _defineProperty(this, "reEmitter", void 0); + _defineProperty(this, "lastEvent", void 0); + _defineProperty(this, "replyCount", 0); + _defineProperty(this, "lastPendingEvent", void 0); + _defineProperty(this, "pendingReplyCount", 0); + _defineProperty(this, "room", void 0); + _defineProperty(this, "client", void 0); + _defineProperty(this, "pendingEventOrdering", void 0); + _defineProperty(this, "initialEventsFetched", !Thread.hasServerSideSupport); + /** + * An array of events to add to the timeline once the thread has been initialised + * with server suppport. + */ + _defineProperty(this, "replayEvents", []); + _defineProperty(this, "onBeforeRedaction", (event, redaction) => { + if (event?.isRelation(THREAD_RELATION_TYPE.name) && this.room.eventShouldLiveIn(event).threadId === this.id && event.getId() !== this.id && + // the root event isn't counted in the length so ignore this redaction + !redaction.status // only respect it when it succeeds + ) { + this.replyCount--; + this.updatePendingReplyCount(); + this.emit(ThreadEvent.Update, this); + } + }); + _defineProperty(this, "onRedaction", async event => { + if (event.threadRootId !== this.id) return; // ignore redactions for other timelines + if (this.replyCount <= 0) { + for (const threadEvent of this.timeline) { + this.clearEventMetadata(threadEvent); + } + this.lastEvent = this.rootEvent; + this._currentUserParticipated = false; + this.emit(ThreadEvent.Delete, this); + } else { + await this.updateThreadMetadata(); + } + }); + _defineProperty(this, "onTimelineEvent", (event, room, toStartOfTimeline) => { + // Add a synthesized receipt when paginating forward in the timeline + if (!toStartOfTimeline) { + const sender = event.getSender(); + if (sender && room && this.shouldSendLocalEchoReceipt(sender, event)) { + room.addLocalEchoReceipt(sender, event, _read_receipts.ReceiptType.Read); + } + } + this.onEcho(event, toStartOfTimeline ?? false); + }); + _defineProperty(this, "onLocalEcho", event => { + this.onEcho(event, false); + }); + _defineProperty(this, "onEcho", async (event, toStartOfTimeline) => { + if (event.threadRootId !== this.id) return; // ignore echoes for other timelines + if (this.lastEvent === event) return; // ignore duplicate events + await this.updateThreadMetadata(); + if (!event.isRelation(THREAD_RELATION_TYPE.name)) return; // don't send a new reply event for reactions or edits + if (toStartOfTimeline) return; // ignore messages added to the start of the timeline + this.emit(ThreadEvent.NewReply, this, event); + }); + if (!opts?.room) { + // Logging/debugging for https://github.com/vector-im/element-web/issues/22141 + // Hope is that we end up with a more obvious stack trace. + throw new Error("element-web#22141: A thread requires a room in order to function"); + } + this.room = opts.room; + this.client = opts.client; + this.pendingEventOrdering = opts.pendingEventOrdering ?? _client.PendingEventOrdering.Chronological; + this.timelineSet = new _eventTimelineSet.EventTimelineSet(this.room, { + timelineSupport: true, + pendingEvents: true + }, this.client, this); + this.reEmitter = new _ReEmitter.TypedReEmitter(this); + this.reEmitter.reEmit(this.timelineSet, [_room.RoomEvent.Timeline, _room.RoomEvent.TimelineReset]); + this.room.on(_event2.MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); + this.room.on(_room.RoomEvent.Redaction, this.onRedaction); + this.room.on(_room.RoomEvent.LocalEchoUpdated, this.onLocalEcho); + this.timelineSet.on(_room.RoomEvent.Timeline, this.onTimelineEvent); + this.processReceipts(opts.receipts); + + // even if this thread is thought to be originating from this client, we initialise it as we may be in a + // gappy sync and a thread around this event may already exist. + this.updateThreadMetadata(); + this.setEventMetadata(this.rootEvent); + } + async fetchRootEvent() { + this.rootEvent = this.room.findEventById(this.id); + // If the rootEvent does not exist in the local stores, then fetch it from the server. + try { + const eventData = await this.client.fetchRoomEvent(this.roomId, this.id); + const mapper = this.client.getEventMapper(); + this.rootEvent = mapper(eventData); // will merge with existing event object if such is known + } catch (e) { + _logger.logger.error("Failed to fetch thread root to construct thread with", e); + } + await this.processEvent(this.rootEvent); + } + static setServerSideSupport(status) { + Thread.hasServerSideSupport = status; + if (status !== FeatureSupport.Stable) { + FILTER_RELATED_BY_SENDERS.setPreferUnstable(true); + FILTER_RELATED_BY_REL_TYPES.setPreferUnstable(true); + THREAD_RELATION_TYPE.setPreferUnstable(true); + } + } + static setServerSideListSupport(status) { + Thread.hasServerSideListSupport = status; + } + static setServerSideFwdPaginationSupport(status) { + Thread.hasServerSideFwdPaginationSupport = status; + } + shouldSendLocalEchoReceipt(sender, event) { + const recursionSupport = this.client.canSupport.get(_feature.Feature.RelationsRecursion) ?? _feature.ServerSupport.Unsupported; + if (recursionSupport === _feature.ServerSupport.Unsupported) { + // Normally we add a local receipt, but if we don't have + // recursion support, then events may arrive out of order, so we + // only create a receipt if it's after our existing receipt. + const oldReceiptEventId = this.getReadReceiptForUserId(sender)?.eventId; + if (oldReceiptEventId) { + const receiptEvent = this.findEventById(oldReceiptEventId); + if (receiptEvent && receiptEvent.getTs() > event.getTs()) { + return false; + } + } + } + return true; + } + get roomState() { + return this.room.getLiveTimeline().getState(_eventTimeline.EventTimeline.FORWARDS); + } + addEventToTimeline(event, toStartOfTimeline) { + if (!this.findEventById(event.getId())) { + this.timelineSet.addEventToTimeline(event, this.liveTimeline, { + toStartOfTimeline, + fromCache: false, + roomState: this.roomState + }); + this.timeline = this.events; + } + } + + /** + * TEMPORARY. Only call this when MSC3981 is not available, and we have some + * late-arriving events to insert, because we recursively found them as part + * of populating a thread. When we have MSC3981 we won't need it, because + * they will all be supplied by the homeserver in one request, and they will + * already be in the right order in that response. + * This is a copy of addEventToTimeline above, modified to call + * insertEventIntoTimeline so this event is inserted into our best guess of + * the right place based on timestamp. (We should be using Sync Order but we + * don't have it.) + * + * @internal + */ + insertEventIntoTimeline(event) { + const eventId = event.getId(); + if (!eventId) { + return; + } + // If the event is already in this thread, bail out + if (this.findEventById(eventId)) { + return; + } + this.timelineSet.insertEventIntoTimeline(event, this.liveTimeline, this.roomState); + + // As far as we know, timeline should always be the same as events + this.timeline = this.events; + } + addEvents(events, toStartOfTimeline) { + events.forEach(ev => this.addEvent(ev, toStartOfTimeline, false)); + this.updateThreadMetadata(); + } + + /** + * Add an event to the thread and updates + * the tail/root references if needed + * Will fire "Thread.update" + * @param event - The event to add + * @param toStartOfTimeline - whether the event is being added + * to the start (and not the end) of the timeline. + * @param emit - whether to emit the Update event if the thread was updated or not. + */ + async addEvent(event, toStartOfTimeline, emit = true) { + this.setEventMetadata(event); + const lastReply = this.lastReply(); + const isNewestReply = !lastReply || event.localTimestamp >= lastReply.localTimestamp; + + // Add all incoming events to the thread's timeline set when there's no server support + if (!Thread.hasServerSideSupport) { + // all the relevant membership info to hydrate events with a sender + // is held in the main room timeline + // We want to fetch the room state from there and pass it down to this thread + // timeline set to let it reconcile an event with its relevant RoomMember + this.addEventToTimeline(event, toStartOfTimeline); + this.client.decryptEventIfNeeded(event, {}); + } else if (!toStartOfTimeline && this.initialEventsFetched && isNewestReply) { + this.addEventToTimeline(event, false); + this.fetchEditsWhereNeeded(event); + } else if (event.isRelation(_event.RelationType.Annotation) || event.isRelation(_event.RelationType.Replace)) { + if (!this.initialEventsFetched) { + /** + * A thread can be fully discovered via a single sync response + * And when that's the case we still ask the server to do an initialisation + * as it's the safest to ensure we have everything. + * However when we are in that scenario we might loose annotation or edits + * + * This fix keeps a reference to those events and replay them once the thread + * has been initialised properly. + */ + this.replayEvents?.push(event); + } else { + const recursionSupport = this.client.canSupport.get(_feature.Feature.RelationsRecursion) ?? _feature.ServerSupport.Unsupported; + if (recursionSupport === _feature.ServerSupport.Unsupported) { + this.insertEventIntoTimeline(event); + } else { + this.addEventToTimeline(event, toStartOfTimeline); + } + } + // Apply annotations and replace relations to the relations of the timeline only + this.timelineSet.relations?.aggregateParentEvent(event); + this.timelineSet.relations?.aggregateChildEvent(event, this.timelineSet); + return; + } + + // If no thread support exists we want to count all thread relation + // added as a reply. We can't rely on the bundled relationships count + if ((!Thread.hasServerSideSupport || !this.rootEvent) && event.isRelation(THREAD_RELATION_TYPE.name)) { + this.replyCount++; + } + if (emit) { + this.emit(ThreadEvent.NewReply, this, event); + this.updateThreadMetadata(); + } + } + async processEvent(event) { + if (event) { + this.setEventMetadata(event); + await this.fetchEditsWhereNeeded(event); + } + this.timeline = this.events; + } + + /** + * Processes the receipts that were caught during initial sync + * When clients become aware of a thread, they try to retrieve those read receipts + * and apply them to the current thread + * @param receipts - A collection of the receipts cached from initial sync + */ + processReceipts(receipts = []) { + for (const { + eventId, + receiptType, + userId, + receipt, + synthetic + } of receipts) { + this.addReceiptToStructure(eventId, receiptType, userId, receipt, synthetic); + } + } + getRootEventBundledRelationship(rootEvent = this.rootEvent) { + return rootEvent?.getServerAggregatedRelation(THREAD_RELATION_TYPE.name); + } + async processRootEvent() { + const bundledRelationship = this.getRootEventBundledRelationship(); + if (Thread.hasServerSideSupport && bundledRelationship) { + this.replyCount = bundledRelationship.count; + this._currentUserParticipated = !!bundledRelationship.current_user_participated; + const mapper = this.client.getEventMapper(); + // re-insert roomId + this.lastEvent = mapper(_objectSpread(_objectSpread({}, bundledRelationship.latest_event), {}, { + room_id: this.roomId + })); + this.updatePendingReplyCount(); + await this.processEvent(this.lastEvent); + } + } + updatePendingReplyCount() { + const unfilteredPendingEvents = this.pendingEventOrdering === _client.PendingEventOrdering.Detached ? this.room.getPendingEvents() : this.events; + const pendingEvents = unfilteredPendingEvents.filter(ev => ev.threadRootId === this.id && ev.isRelation(THREAD_RELATION_TYPE.name) && ev.status !== null && ev.getId() !== this.lastEvent?.getId()); + this.lastPendingEvent = pendingEvents.length ? pendingEvents[pendingEvents.length - 1] : undefined; + this.pendingReplyCount = pendingEvents.length; + } + + /** + * Reset the live timeline of all timelineSets, and start new ones. + * + * <p>This is used when /sync returns a 'limited' timeline. 'Limited' means that there's a gap between the messages + * /sync returned, and the last known message in our timeline. In such a case, our live timeline isn't live anymore + * and has to be replaced by a new one. To make sure we can continue paginating our timelines correctly, we have to + * set new pagination tokens on the old and the new timeline. + * + * @param backPaginationToken - token for back-paginating the new timeline + * @param forwardPaginationToken - token for forward-paginating the old live timeline, + * if absent or null, all timelines are reset, removing old ones (including the previous live + * timeline which would otherwise be unable to paginate forwards without this token). + * Removing just the old live timeline whilst preserving previous ones is not supported. + */ + async resetLiveTimeline(backPaginationToken, forwardPaginationToken) { + const oldLive = this.liveTimeline; + this.timelineSet.resetLiveTimeline(backPaginationToken ?? undefined, forwardPaginationToken ?? undefined); + const newLive = this.liveTimeline; + + // FIXME: Remove the following as soon as https://github.com/matrix-org/synapse/issues/14830 is resolved. + // + // The pagination API for thread timelines currently can't handle the type of pagination tokens returned by sync + // + // To make this work anyway, we'll have to transform them into one of the types that the API can handle. + // One option is passing the tokens to /messages, which can handle sync tokens, and returns the right format. + // /messages does not return new tokens on requests with a limit of 0. + // This means our timelines might overlap a slight bit, but that's not an issue, as we deduplicate messages + // anyway. + + let newBackward; + let oldForward; + if (backPaginationToken) { + const res = await this.client.createMessagesRequest(this.roomId, backPaginationToken, 1, _eventTimeline.Direction.Forward); + newBackward = res.end; + } + if (forwardPaginationToken) { + const res = await this.client.createMessagesRequest(this.roomId, forwardPaginationToken, 1, _eventTimeline.Direction.Backward); + oldForward = res.start; + } + // Only replace the token if we don't have paginated away from this position already. This situation doesn't + // occur today, but if the above issue is resolved, we'd have to go down this path. + if (forwardPaginationToken && oldLive.getPaginationToken(_eventTimeline.Direction.Forward) === forwardPaginationToken) { + oldLive.setPaginationToken(oldForward ?? null, _eventTimeline.Direction.Forward); + } + if (backPaginationToken && newLive.getPaginationToken(_eventTimeline.Direction.Backward) === backPaginationToken) { + newLive.setPaginationToken(newBackward ?? null, _eventTimeline.Direction.Backward); + } + } + async updateThreadMetadata() { + this.updatePendingReplyCount(); + if (Thread.hasServerSideSupport) { + // Ensure we show *something* as soon as possible, we'll update it as soon as we get better data, but we + // don't want the thread preview to be empty if we can avoid it + if (!this.initialEventsFetched) { + await this.processRootEvent(); + } + await this.fetchRootEvent(); + } + await this.processRootEvent(); + if (!this.initialEventsFetched) { + this.initialEventsFetched = true; + // fetch initial event to allow proper pagination + try { + // if the thread has regular events, this will just load the last reply. + // if the thread is newly created, this will load the root event. + if (this.replyCount === 0 && this.rootEvent) { + this.timelineSet.addEventsToTimeline([this.rootEvent], true, this.liveTimeline, null); + this.liveTimeline.setPaginationToken(null, _eventTimeline.Direction.Backward); + } else { + await this.client.paginateEventTimeline(this.liveTimeline, { + backwards: true, + limit: Math.max(1, this.length) + }); + } + for (const event of this.replayEvents) { + this.addEvent(event, false); + } + this.replayEvents = null; + // just to make sure that, if we've created a timeline window for this thread before the thread itself + // existed (e.g. when creating a new thread), we'll make sure the panel is force refreshed correctly. + this.emit(_room.RoomEvent.TimelineReset, this.room, this.timelineSet, true); + } catch (e) { + _logger.logger.error("Failed to load start of newly created thread: ", e); + this.initialEventsFetched = false; + } + } + this.emit(ThreadEvent.Update, this); + } + + // XXX: Workaround for https://github.com/matrix-org/matrix-spec-proposals/pull/2676/files#r827240084 + async fetchEditsWhereNeeded(...events) { + const recursionSupport = this.client.canSupport.get(_feature.Feature.RelationsRecursion) ?? _feature.ServerSupport.Unsupported; + if (recursionSupport === _feature.ServerSupport.Unsupported) { + return Promise.all(events.filter(isAnEncryptedThreadMessage).map(async event => { + try { + const relations = await this.client.relations(this.roomId, event.getId(), _event.RelationType.Replace, event.getType(), { + limit: 1 + }); + if (relations.events.length) { + const editEvent = relations.events[0]; + event.makeReplaced(editEvent); + this.insertEventIntoTimeline(editEvent); + } + } catch (e) { + _logger.logger.error("Failed to load edits for encrypted thread event", e); + } + })); + } + } + setEventMetadata(event) { + if (event) { + _eventTimeline.EventTimeline.setEventMetadata(event, this.roomState, false); + event.setThread(this); + } + } + clearEventMetadata(event) { + if (event) { + event.setThread(undefined); + delete event.event?.unsigned?.["m.relations"]?.[THREAD_RELATION_TYPE.name]; + } + } + + /** + * Finds an event by ID in the current thread + */ + findEventById(eventId) { + return this.timelineSet.findEventById(eventId); + } + + /** + * Return last reply to the thread, if known. + */ + lastReply(matches = () => true) { + for (let i = this.timeline.length - 1; i >= 0; i--) { + const event = this.timeline[i]; + if (matches(event)) { + return event; + } + } + return null; + } + get roomId() { + return this.room.roomId; + } + + /** + * The number of messages in the thread + * Only count rel_type=m.thread as we want to + * exclude annotations from that number + */ + get length() { + return this.replyCount + this.pendingReplyCount; + } + + /** + * A getter for the last event of the thread. + * This might be a synthesized event, if so, it will not emit any events to listeners. + */ + get replyToEvent() { + return this.lastPendingEvent ?? this.lastEvent ?? this.lastReply(); + } + get events() { + return this.liveTimeline.getEvents(); + } + has(eventId) { + return this.timelineSet.findEventById(eventId) instanceof _event2.MatrixEvent; + } + get hasCurrentUserParticipated() { + return this._currentUserParticipated; + } + get liveTimeline() { + return this.timelineSet.getLiveTimeline(); + } + getUnfilteredTimelineSet() { + return this.timelineSet; + } + addReceipt(event, synthetic) { + throw new Error("Unsupported function on the thread model"); + } + + /** + * Get the ID of the event that a given user has read up to within this thread, + * or null if we have received no read receipt (at all) from them. + * @param userId - The user ID to get read receipt event ID for + * @param ignoreSynthesized - If true, return only receipts that have been + * sent by the server, not implicit ones generated + * by the JS SDK. + * @returns ID of the latest event that the given user has read, or null. + */ + getEventReadUpTo(userId, ignoreSynthesized) { + const isCurrentUser = userId === this.client.getUserId(); + const lastReply = this.timeline[this.timeline.length - 1]; + if (isCurrentUser && lastReply) { + // If the last activity in a thread is prior to the first threaded read receipt + // sent in the room (suggesting that it was sent before the user started + // using a client that supported threaded read receipts), we want to + // consider this thread as read. + const beforeFirstThreadedReceipt = lastReply.getTs() < this.room.getOldestThreadedReceiptTs(); + const lastReplyId = lastReply.getId(); + // Some unsent events do not have an ID, we do not want to consider them read + if (beforeFirstThreadedReceipt && lastReplyId) { + return lastReplyId; + } + } + const readUpToId = super.getEventReadUpTo(userId, ignoreSynthesized); + + // Check whether the unthreaded read receipt for that user is more recent + // than the read receipt inside that thread. + if (lastReply) { + const unthreadedReceipt = this.room.getLastUnthreadedReceiptFor(userId); + if (!unthreadedReceipt) { + return readUpToId; + } + for (let i = this.timeline?.length - 1; i >= 0; --i) { + const ev = this.timeline[i]; + // If we encounter the `readUpToId` we do not need to look further + // there is no "more recent" unthreaded read receipt + if (ev.getId() === readUpToId) return readUpToId; + + // Inspecting events from most recent to oldest, we're checking + // whether an unthreaded read receipt is more recent that the current event. + // We usually prefer relying on the order of the DAG but in this scenario + // it is not possible and we have to rely on timestamp + if (ev.getTs() < unthreadedReceipt.ts) return ev.getId() ?? readUpToId; + } + } + return readUpToId; + } + + /** + * Determine if the given user has read a particular event. + * + * It is invalid to call this method with an event that is not part of this thread. + * + * This is not a definitive check as it only checks the events that have been + * loaded client-side at the time of execution. + * @param userId - The user ID to check the read state of. + * @param eventId - The event ID to check if the user read. + * @returns True if the user has read the event, false otherwise. + */ + hasUserReadEvent(userId, eventId) { + if (userId === this.client.getUserId()) { + // Consider an event read if it's part of a thread that is before the + // first threaded receipt sent in that room. It is likely that it is + // part of a thread that was created before MSC3771 was implemented. + // Or before the last unthreaded receipt for the logged in user + const beforeFirstThreadedReceipt = (this.lastReply()?.getTs() ?? 0) < this.room.getOldestThreadedReceiptTs(); + const unthreadedReceiptTs = this.room.getLastUnthreadedReceiptFor(userId)?.ts ?? 0; + const beforeLastUnthreadedReceipt = (this?.lastReply()?.getTs() ?? 0) < unthreadedReceiptTs; + if (beforeFirstThreadedReceipt || beforeLastUnthreadedReceipt) { + return true; + } + } + return super.hasUserReadEvent(userId, eventId); + } + setUnread(type, count) { + return this.room.setThreadUnreadNotificationCount(this.id, type, count); + } +} + +/** + * Decide whether an event deserves to have its potential edits fetched. + * + * @returns true if this event is encrypted and is a message that is part of a + * thread - either inside it, or a root. + */ +exports.Thread = Thread; +_defineProperty(Thread, "hasServerSideSupport", FeatureSupport.None); +_defineProperty(Thread, "hasServerSideListSupport", FeatureSupport.None); +_defineProperty(Thread, "hasServerSideFwdPaginationSupport", FeatureSupport.None); +function isAnEncryptedThreadMessage(event) { + return event.isEncrypted() && (event.isRelation(THREAD_RELATION_TYPE.name) || event.isThreadRoot); +} +const FILTER_RELATED_BY_SENDERS = new _NamespacedValue.ServerControlledNamespacedValue("related_by_senders", "io.element.relation_senders"); +exports.FILTER_RELATED_BY_SENDERS = FILTER_RELATED_BY_SENDERS; +const FILTER_RELATED_BY_REL_TYPES = new _NamespacedValue.ServerControlledNamespacedValue("related_by_rel_types", "io.element.relation_types"); +exports.FILTER_RELATED_BY_REL_TYPES = FILTER_RELATED_BY_REL_TYPES; +const THREAD_RELATION_TYPE = new _NamespacedValue.ServerControlledNamespacedValue("m.thread", "io.element.thread"); +exports.THREAD_RELATION_TYPE = THREAD_RELATION_TYPE; +let ThreadFilterType = /*#__PURE__*/function (ThreadFilterType) { + ThreadFilterType[ThreadFilterType["My"] = 0] = "My"; + ThreadFilterType[ThreadFilterType["All"] = 1] = "All"; + return ThreadFilterType; +}({}); +exports.ThreadFilterType = ThreadFilterType; +function threadFilterTypeToFilter(type) { + switch (type) { + case ThreadFilterType.My: + return "participated"; + default: + return "all"; + } +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/typed-event-emitter.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/typed-event-emitter.js new file mode 100644 index 0000000000..c1160948ba --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/typed-event-emitter.js @@ -0,0 +1,200 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.TypedEventEmitter = exports.EventEmitterEvents = void 0; +var _events = require("events"); +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +// eslint-disable-next-line no-restricted-imports +/** Events emitted by EventEmitter itself */ +let EventEmitterEvents = /*#__PURE__*/function (EventEmitterEvents) { + EventEmitterEvents["NewListener"] = "newListener"; + EventEmitterEvents["RemoveListener"] = "removeListener"; + EventEmitterEvents["Error"] = "error"; + return EventEmitterEvents; +}({}); +/** Base class for types mapping from event name to the type of listeners to that event */ +/** + * The expected type of a listener function for a particular event. + * + * Type parameters: + * * `E` - List of all events emitted by the `TypedEventEmitter`. Normally an enum type. + * * `A` - A type providing mappings from event names to listener types. + * * `T` - The name of the actual event that this listener is for. Normally one of the types in `E` or + * {@link EventEmitterEvents}. + */ +exports.EventEmitterEvents = EventEmitterEvents; +/** + * Typed Event Emitter class which can act as a Base Model for all our model + * and communication events. + * This makes it much easier for us to distinguish between events, as we now need + * to properly type this, so that our events are not stringly-based and prone + * to silly typos. + * + * Type parameters: + * * `Events` - List of all events emitted by this `TypedEventEmitter`. Normally an enum type. + * * `Arguments` - A {@link ListenerMap} type providing mappings from event names to listener types. + * * `SuperclassArguments` - TODO: not really sure. Alternative listener mappings, I think? But only honoured for `.emit`? + */ +class TypedEventEmitter extends _events.EventEmitter { + /** + * Alias for {@link TypedEventEmitter#on}. + */ + addListener(event, listener) { + return super.addListener(event, listener); + } + + /** + * Synchronously calls each of the listeners registered for the event named + * `event`, in the order they were registered, passing the supplied arguments + * to each. + * + * @param event - The name of the event to emit + * @param args - Arguments to pass to the listener + * @returns `true` if the event had listeners, `false` otherwise. + */ + + emit(event, ...args) { + return super.emit(event, ...args); + } + + /** + * Returns the number of listeners listening to the event named `event`. + * + * @param event - The name of the event being listened for + */ + listenerCount(event) { + return super.listenerCount(event); + } + + /** + * Returns a copy of the array of listeners for the event named `event`. + */ + listeners(event) { + return super.listeners(event); + } + + /** + * Alias for {@link TypedEventEmitter#removeListener} + */ + off(event, listener) { + return super.off(event, listener); + } + + /** + * Adds the `listener` function to the end of the listeners array for the + * event named `event`. + * + * No checks are made to see if the `listener` has already been added. Multiple calls + * passing the same combination of `event` and `listener` will result in the `listener` + * being added, and called, multiple times. + * + * By default, event listeners are invoked in the order they are added. The + * {@link TypedEventEmitter#prependListener} method can be used as an alternative to add the + * event listener to the beginning of the listeners array. + * + * @param event - The name of the event. + * @param listener - The callback function + * + * @returns a reference to the `EventEmitter`, so that calls can be chained. + */ + on(event, listener) { + return super.on(event, listener); + } + + /** + * Adds a **one-time** `listener` function for the event named `event`. The + * next time `event` is triggered, this listener is removed and then invoked. + * + * Returns a reference to the `EventEmitter`, so that calls can be chained. + * + * By default, event listeners are invoked in the order they are added. + * The {@link TypedEventEmitter#prependOnceListener} method can be used as an alternative to add the + * event listener to the beginning of the listeners array. + * + * @param event - The name of the event. + * @param listener - The callback function + * + * @returns a reference to the `EventEmitter`, so that calls can be chained. + */ + once(event, listener) { + return super.once(event, listener); + } + + /** + * Adds the `listener` function to the _beginning_ of the listeners array for the + * event named `event`. + * + * No checks are made to see if the `listener` has already been added. Multiple calls + * passing the same combination of `event` and `listener` will result in the `listener` + * being added, and called, multiple times. + * + * @param event - The name of the event. + * @param listener - The callback function + * + * @returns a reference to the `EventEmitter`, so that calls can be chained. + */ + prependListener(event, listener) { + return super.prependListener(event, listener); + } + + /** + * Adds a **one-time**`listener` function for the event named `event` to the _beginning_ of the listeners array. + * The next time `event` is triggered, this listener is removed, and then invoked. + * + * @param event - The name of the event. + * @param listener - The callback function + * + * @returns a reference to the `EventEmitter`, so that calls can be chained. + */ + prependOnceListener(event, listener) { + return super.prependOnceListener(event, listener); + } + + /** + * Removes all listeners, or those of the specified `event`. + * + * It is bad practice to remove listeners added elsewhere in the code, + * particularly when the `EventEmitter` instance was created by some other + * component or module (e.g. sockets or file streams). + * + * @param event - The name of the event. If undefined, all listeners everywhere are removed. + * @returns a reference to the `EventEmitter`, so that calls can be chained. + */ + removeAllListeners(event) { + return super.removeAllListeners(event); + } + + /** + * Removes the specified `listener` from the listener array for the event named `event`. + * + * @returns a reference to the `EventEmitter`, so that calls can be chained. + */ + removeListener(event, listener) { + return super.removeListener(event, listener); + } + + /** + * Returns a copy of the array of listeners for the event named `eventName`, + * including any wrappers (such as those created by `.once()`). + */ + rawListeners(event) { + return super.rawListeners(event); + } +} +exports.TypedEventEmitter = TypedEventEmitter;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/user.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/user.js new file mode 100644 index 0000000000..bc941a8bb3 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/user.js @@ -0,0 +1,211 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.UserEvent = exports.User = void 0; +var _typedEventEmitter = require("./typed-event-emitter"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2015 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +let UserEvent = /*#__PURE__*/function (UserEvent) { + UserEvent["DisplayName"] = "User.displayName"; + UserEvent["AvatarUrl"] = "User.avatarUrl"; + UserEvent["Presence"] = "User.presence"; + UserEvent["CurrentlyActive"] = "User.currentlyActive"; + UserEvent["LastPresenceTs"] = "User.lastPresenceTs"; + return UserEvent; +}({}); +exports.UserEvent = UserEvent; +class User extends _typedEventEmitter.TypedEventEmitter { + /** + * Construct a new User. A User must have an ID and can optionally have extra information associated with it. + * @param userId - Required. The ID of this user. + */ + constructor(userId) { + super(); + this.userId = userId; + _defineProperty(this, "modified", -1); + /** + * The 'displayname' of the user if known. + * @privateRemarks + * Should be read-only + */ + _defineProperty(this, "displayName", void 0); + _defineProperty(this, "rawDisplayName", void 0); + /** + * The 'avatar_url' of the user if known. + * @privateRemarks + * Should be read-only + */ + _defineProperty(this, "avatarUrl", void 0); + /** + * The presence status message if known. + * @privateRemarks + * Should be read-only + */ + _defineProperty(this, "presenceStatusMsg", void 0); + /** + * The presence enum if known. + * @privateRemarks + * Should be read-only + */ + _defineProperty(this, "presence", "offline"); + /** + * Timestamp (ms since the epoch) for when we last received presence data for this user. + * We can subtract lastActiveAgo from this to approximate an absolute value for when a user was last active. + * @privateRemarks + * Should be read-only + */ + _defineProperty(this, "lastActiveAgo", 0); + /** + * The time elapsed in ms since the user interacted proactively with the server, + * or we saw a message from the user + * @privateRemarks + * Should be read-only + */ + _defineProperty(this, "lastPresenceTs", 0); + /** + * Whether we should consider lastActiveAgo to be an approximation + * and that the user should be seen as active 'now' + * @privateRemarks + * Should be read-only + */ + _defineProperty(this, "currentlyActive", false); + /** + * The events describing this user. + * @privateRemarks + * Should be read-only + */ + _defineProperty(this, "events", {}); + this.displayName = userId; + this.rawDisplayName = userId; + this.updateModifiedTime(); + } + + /** + * Update this User with the given presence event. May fire "User.presence", + * "User.avatarUrl" and/or "User.displayName" if this event updates this user's + * properties. + * @param event - The `m.presence` event. + * + * @remarks + * Fires {@link UserEvent.Presence} + * Fires {@link UserEvent.DisplayName} + * Fires {@link UserEvent.AvatarUrl} + */ + setPresenceEvent(event) { + if (event.getType() !== "m.presence") { + return; + } + const firstFire = this.events.presence === null; + this.events.presence = event; + const eventsToFire = []; + if (event.getContent().presence !== this.presence || firstFire) { + eventsToFire.push(UserEvent.Presence); + } + if (event.getContent().avatar_url && event.getContent().avatar_url !== this.avatarUrl) { + eventsToFire.push(UserEvent.AvatarUrl); + } + if (event.getContent().displayname && event.getContent().displayname !== this.displayName) { + eventsToFire.push(UserEvent.DisplayName); + } + if (event.getContent().currently_active !== undefined && event.getContent().currently_active !== this.currentlyActive) { + eventsToFire.push(UserEvent.CurrentlyActive); + } + this.presence = event.getContent().presence; + eventsToFire.push(UserEvent.LastPresenceTs); + if (event.getContent().status_msg) { + this.presenceStatusMsg = event.getContent().status_msg; + } + if (event.getContent().displayname) { + this.displayName = event.getContent().displayname; + } + if (event.getContent().avatar_url) { + this.avatarUrl = event.getContent().avatar_url; + } + this.lastActiveAgo = event.getContent().last_active_ago; + this.lastPresenceTs = Date.now(); + this.currentlyActive = event.getContent().currently_active; + this.updateModifiedTime(); + for (const eventToFire of eventsToFire) { + this.emit(eventToFire, event, this); + } + } + + /** + * Manually set this user's display name. No event is emitted in response to this + * as there is no underlying MatrixEvent to emit with. + * @param name - The new display name. + */ + setDisplayName(name) { + const oldName = this.displayName; + this.displayName = name; + if (name !== oldName) { + this.updateModifiedTime(); + } + } + + /** + * Manually set this user's non-disambiguated display name. No event is emitted + * in response to this as there is no underlying MatrixEvent to emit with. + * @param name - The new display name. + */ + setRawDisplayName(name) { + this.rawDisplayName = name; + } + + /** + * Manually set this user's avatar URL. No event is emitted in response to this + * as there is no underlying MatrixEvent to emit with. + * @param url - The new avatar URL. + */ + setAvatarUrl(url) { + const oldUrl = this.avatarUrl; + this.avatarUrl = url; + if (url !== oldUrl) { + this.updateModifiedTime(); + } + } + + /** + * Update the last modified time to the current time. + */ + updateModifiedTime() { + this.modified = Date.now(); + } + + /** + * Get the timestamp when this User was last updated. This timestamp is + * updated when this User receives a new Presence event which has updated a + * property on this object. It is updated <i>before</i> firing events. + * @returns The timestamp + */ + getLastModifiedTime() { + return this.modified; + } + + /** + * Get the absolute timestamp when this User was last known active on the server. + * It is *NOT* accurate if this.currentlyActive is true. + * @returns The timestamp + */ + getLastActiveTs() { + return this.lastPresenceTs - this.lastActiveAgo; + } +} +exports.User = User;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/pushprocessor.js b/comm/chat/protocols/matrix/lib/matrix-sdk/pushprocessor.js new file mode 100644 index 0000000000..bc8e173bf4 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/pushprocessor.js @@ -0,0 +1,676 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.PushProcessor = void 0; +var _utils = require("./utils"); +var _logger = require("./logger"); +var _PushRules = require("./@types/PushRules"); +var _event = require("./@types/event"); +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2015 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +const RULEKINDS_IN_ORDER = [_PushRules.PushRuleKind.Override, _PushRules.PushRuleKind.ContentSpecific, _PushRules.PushRuleKind.RoomSpecific, _PushRules.PushRuleKind.SenderSpecific, _PushRules.PushRuleKind.Underride]; + +// The default override rules to apply to the push rules that arrive from the server. +// We do this for two reasons: +// 1. Synapse is unlikely to send us the push rule in an incremental sync - see +// https://github.com/matrix-org/synapse/pull/4867#issuecomment-481446072 for +// more details. +// 2. We often want to start using push rules ahead of the server supporting them, +// and so we can put them here. +const DEFAULT_OVERRIDE_RULES = [{ + // For homeservers which don't support MSC2153 yet + rule_id: ".m.rule.reaction", + default: true, + enabled: true, + conditions: [{ + kind: _PushRules.ConditionKind.EventMatch, + key: "type", + pattern: "m.reaction" + }], + actions: [_PushRules.PushRuleActionName.DontNotify] +}, { + rule_id: _PushRules.RuleId.IsUserMention, + default: true, + enabled: true, + conditions: [{ + kind: _PushRules.ConditionKind.EventPropertyContains, + key: "content.org\\.matrix\\.msc3952\\.mentions.user_ids", + value: "" // The user ID is dynamically added in rewriteDefaultRules. + }], + + actions: [_PushRules.PushRuleActionName.Notify, { + set_tweak: _PushRules.TweakName.Highlight + }] +}, { + rule_id: _PushRules.RuleId.IsRoomMention, + default: true, + enabled: true, + conditions: [{ + kind: _PushRules.ConditionKind.EventPropertyIs, + key: "content.org\\.matrix\\.msc3952\\.mentions.room", + value: true + }, { + kind: _PushRules.ConditionKind.SenderNotificationPermission, + key: "room" + }], + actions: [_PushRules.PushRuleActionName.Notify, { + set_tweak: _PushRules.TweakName.Highlight + }] +}, { + // For homeservers which don't support MSC3786 yet + rule_id: ".org.matrix.msc3786.rule.room.server_acl", + default: true, + enabled: true, + conditions: [{ + kind: _PushRules.ConditionKind.EventMatch, + key: "type", + pattern: _event.EventType.RoomServerAcl + }, { + kind: _PushRules.ConditionKind.EventMatch, + key: "state_key", + pattern: "" + }], + actions: [] +}]; +const DEFAULT_UNDERRIDE_RULES = [{ + // For homeservers which don't support MSC3914 yet + rule_id: ".org.matrix.msc3914.rule.room.call", + default: true, + enabled: true, + conditions: [{ + kind: _PushRules.ConditionKind.EventMatch, + key: "type", + pattern: "org.matrix.msc3401.call" + }, { + kind: _PushRules.ConditionKind.CallStarted + }], + actions: [_PushRules.PushRuleActionName.Notify, { + set_tweak: _PushRules.TweakName.Sound, + value: "default" + }] +}]; +class PushProcessor { + /** + * Construct a Push Processor. + * @param client - The Matrix client object to use + */ + constructor(client) { + this.client = client; + /** + * Maps the original key from the push rules to a list of property names + * after unescaping. + */ + _defineProperty(this, "parsedKeys", new Map()); + } + /** + * Convert a list of actions into a object with the actions as keys and their values + * @example + * eg. `[ 'notify', { set_tweak: 'sound', value: 'default' } ]` + * becomes `{ notify: true, tweaks: { sound: 'default' } }` + * @param actionList - The actions list + * + * @returns A object with key 'notify' (true or false) and an object of actions + */ + static actionListToActionsObject(actionList) { + const actionObj = { + notify: false, + tweaks: {} + }; + for (const action of actionList) { + if (action === _PushRules.PushRuleActionName.Notify) { + actionObj.notify = true; + } else if (typeof action === "object") { + if (action.value === undefined) { + action.value = true; + } + actionObj.tweaks[action.set_tweak] = action.value; + } + } + return actionObj; + } + + /** + * Rewrites conditions on a client's push rules to match the defaults + * where applicable. Useful for upgrading push rules to more strict + * conditions when the server is falling behind on defaults. + * @param incomingRules - The client's existing push rules + * @param userId - The Matrix ID of the client. + * @returns The rewritten rules + */ + static rewriteDefaultRules(incomingRules, userId = undefined) { + let newRules = JSON.parse(JSON.stringify(incomingRules)); // deep clone + + // These lines are mostly to make the tests happy. We shouldn't run into these + // properties missing in practice. + if (!newRules) newRules = {}; + if (!newRules.global) newRules.global = {}; + if (!newRules.global.override) newRules.global.override = []; + if (!newRules.global.underride) newRules.global.underride = []; + + // Merge the client-level defaults with the ones from the server + const globalOverrides = newRules.global.override; + for (const originalOverride of DEFAULT_OVERRIDE_RULES) { + const existingRule = globalOverrides.find(r => r.rule_id === originalOverride.rule_id); + + // Dynamically add the user ID as the value for the is_user_mention rule. + let override; + if (originalOverride.rule_id === _PushRules.RuleId.IsUserMention) { + // If the user ID wasn't provided, skip the rule. + if (!userId) { + continue; + } + override = JSON.parse(JSON.stringify(originalOverride)); // deep clone + override.conditions[0].value = userId; + } else { + override = originalOverride; + } + if (existingRule) { + // Copy over the actions, default, and conditions. Don't touch the user's preference. + existingRule.default = override.default; + existingRule.conditions = override.conditions; + existingRule.actions = override.actions; + } else { + // Add the rule + const ruleId = override.rule_id; + _logger.logger.warn(`Adding default global override for ${ruleId}`); + globalOverrides.push(override); + } + } + const globalUnderrides = newRules.global.underride ?? []; + for (const underride of DEFAULT_UNDERRIDE_RULES) { + const existingRule = globalUnderrides.find(r => r.rule_id === underride.rule_id); + if (existingRule) { + // Copy over the actions, default, and conditions. Don't touch the user's preference. + existingRule.default = underride.default; + existingRule.conditions = underride.conditions; + existingRule.actions = underride.actions; + } else { + // Add the rule + const ruleId = underride.rule_id; + _logger.logger.warn(`Adding default global underride for ${ruleId}`); + globalUnderrides.push(underride); + } + } + return newRules; + } + + /** + * Pre-caches the parsed keys for push rules and cleans out any obsolete cache + * entries. Should be called after push rules are updated. + * @param newRules - The new push rules. + */ + updateCachedPushRuleKeys(newRules) { + // These lines are mostly to make the tests happy. We shouldn't run into these + // properties missing in practice. + if (!newRules) newRules = {}; + if (!newRules.global) newRules.global = {}; + if (!newRules.global.override) newRules.global.override = []; + if (!newRules.global.room) newRules.global.room = []; + if (!newRules.global.sender) newRules.global.sender = []; + if (!newRules.global.underride) newRules.global.underride = []; + + // Process the 'key' property on event_match conditions pre-cache the + // values and clean-out any unused values. + const toRemoveKeys = new Set(this.parsedKeys.keys()); + for (const ruleset of [newRules.global.override, newRules.global.room, newRules.global.sender, newRules.global.underride]) { + for (const rule of ruleset) { + if (!rule.conditions) { + continue; + } + for (const condition of rule.conditions) { + if (condition.kind !== _PushRules.ConditionKind.EventMatch) { + continue; + } + + // Ensure we keep this key. + toRemoveKeys.delete(condition.key); + + // Pre-process the key. + this.parsedKeys.set(condition.key, PushProcessor.partsForDottedKey(condition.key)); + } + } + } + // Any keys that were previously cached, but are no longer needed should + // be removed. + toRemoveKeys.forEach(k => this.parsedKeys.delete(k)); + } + // $glob: RegExp + + matchingRuleFromKindSet(ev, kindset) { + for (const kind of RULEKINDS_IN_ORDER) { + const ruleset = kindset[kind]; + if (!ruleset) { + continue; + } + for (const rule of ruleset) { + if (!rule.enabled) { + continue; + } + const rawrule = this.templateRuleToRaw(kind, rule); + if (!rawrule) { + continue; + } + if (this.ruleMatchesEvent(rawrule, ev)) { + return _objectSpread(_objectSpread({}, rule), {}, { + kind + }); + } + } + } + return null; + } + templateRuleToRaw(kind, tprule) { + const rawrule = { + rule_id: tprule.rule_id, + actions: tprule.actions, + conditions: [] + }; + switch (kind) { + case _PushRules.PushRuleKind.Underride: + case _PushRules.PushRuleKind.Override: + rawrule.conditions = tprule.conditions; + break; + case _PushRules.PushRuleKind.RoomSpecific: + if (!tprule.rule_id) { + return null; + } + rawrule.conditions.push({ + kind: _PushRules.ConditionKind.EventMatch, + key: "room_id", + value: tprule.rule_id + }); + break; + case _PushRules.PushRuleKind.SenderSpecific: + if (!tprule.rule_id) { + return null; + } + rawrule.conditions.push({ + kind: _PushRules.ConditionKind.EventMatch, + key: "user_id", + value: tprule.rule_id + }); + break; + case _PushRules.PushRuleKind.ContentSpecific: + if (!tprule.pattern) { + return null; + } + rawrule.conditions.push({ + kind: _PushRules.ConditionKind.EventMatch, + key: "content.body", + pattern: tprule.pattern + }); + break; + } + return rawrule; + } + eventFulfillsCondition(cond, ev) { + switch (cond.kind) { + case _PushRules.ConditionKind.EventMatch: + return this.eventFulfillsEventMatchCondition(cond, ev); + case _PushRules.ConditionKind.EventPropertyIs: + return this.eventFulfillsEventPropertyIsCondition(cond, ev); + case _PushRules.ConditionKind.EventPropertyContains: + return this.eventFulfillsEventPropertyContains(cond, ev); + case _PushRules.ConditionKind.ContainsDisplayName: + return this.eventFulfillsDisplayNameCondition(cond, ev); + case _PushRules.ConditionKind.RoomMemberCount: + return this.eventFulfillsRoomMemberCountCondition(cond, ev); + case _PushRules.ConditionKind.SenderNotificationPermission: + return this.eventFulfillsSenderNotifPermCondition(cond, ev); + case _PushRules.ConditionKind.CallStarted: + case _PushRules.ConditionKind.CallStartedPrefix: + return this.eventFulfillsCallStartedCondition(cond, ev); + } + + // unknown conditions: we previously matched all unknown conditions, + // but given that rules can be added to the base rules on a server, + // it's probably better to not match unknown conditions. + return false; + } + eventFulfillsSenderNotifPermCondition(cond, ev) { + const notifLevelKey = cond["key"]; + if (!notifLevelKey) { + return false; + } + const room = this.client.getRoom(ev.getRoomId()); + if (!room?.currentState) { + return false; + } + + // Note that this should not be the current state of the room but the state at + // the point the event is in the DAG. Unfortunately the js-sdk does not store + // this. + return room.currentState.mayTriggerNotifOfType(notifLevelKey, ev.getSender()); + } + eventFulfillsRoomMemberCountCondition(cond, ev) { + if (!cond.is) { + return false; + } + const room = this.client.getRoom(ev.getRoomId()); + if (!room || !room.currentState || !room.currentState.members) { + return false; + } + const memberCount = room.currentState.getJoinedMemberCount(); + const m = cond.is.match(/^([=<>]*)(\d*)$/); + if (!m) { + return false; + } + const ineq = m[1]; + const rhs = parseInt(m[2]); + if (isNaN(rhs)) { + return false; + } + switch (ineq) { + case "": + case "==": + return memberCount == rhs; + case "<": + return memberCount < rhs; + case ">": + return memberCount > rhs; + case "<=": + return memberCount <= rhs; + case ">=": + return memberCount >= rhs; + default: + return false; + } + } + eventFulfillsDisplayNameCondition(cond, ev) { + let content = ev.getContent(); + if (ev.isEncrypted() && ev.getClearContent()) { + content = ev.getClearContent(); + } + if (!content || !content.body || typeof content.body != "string") { + return false; + } + const room = this.client.getRoom(ev.getRoomId()); + const member = room?.currentState?.getMember(this.client.credentials.userId); + if (!member) { + return false; + } + const displayName = member.name; + + // N.B. we can't use \b as it chokes on unicode. however \W seems to be okay + // as shorthand for [^0-9A-Za-z_]. + const pat = new RegExp("(^|\\W)" + (0, _utils.escapeRegExp)(displayName) + "(\\W|$)", "i"); + return content.body.search(pat) > -1; + } + + /** + * Check whether the given event matches the push rule condition by fetching + * the property from the event and comparing against the condition's glob-based + * pattern. + * @param cond - The push rule condition to check for a match. + * @param ev - The event to check for a match. + */ + eventFulfillsEventMatchCondition(cond, ev) { + if (!cond.key) { + return false; + } + const val = this.valueForDottedKey(cond.key, ev); + if (typeof val !== "string") { + return false; + } + + // XXX This does not match in a case-insensitive manner. + // + // See https://spec.matrix.org/v1.5/client-server-api/#conditions-1 + if (cond.value) { + return cond.value === val; + } + if (typeof cond.pattern !== "string") { + return false; + } + const regex = cond.key === "content.body" ? this.createCachedRegex("(^|\\W)", cond.pattern, "(\\W|$)") : this.createCachedRegex("^", cond.pattern, "$"); + return !!val.match(regex); + } + + /** + * Check whether the given event matches the push rule condition by fetching + * the property from the event and comparing exactly against the condition's + * value. + * @param cond - The push rule condition to check for a match. + * @param ev - The event to check for a match. + */ + eventFulfillsEventPropertyIsCondition(cond, ev) { + if (!cond.key || cond.value === undefined) { + return false; + } + return cond.value === this.valueForDottedKey(cond.key, ev); + } + + /** + * Check whether the given event matches the push rule condition by fetching + * the property from the event and comparing exactly against the condition's + * value. + * @param cond - The push rule condition to check for a match. + * @param ev - The event to check for a match. + */ + eventFulfillsEventPropertyContains(cond, ev) { + if (!cond.key || cond.value === undefined) { + return false; + } + const val = this.valueForDottedKey(cond.key, ev); + if (!Array.isArray(val)) { + return false; + } + return val.includes(cond.value); + } + eventFulfillsCallStartedCondition(_cond, ev) { + // Since servers don't support properly sending push notification + // about MSC3401 call events, we do the handling ourselves + return ["m.ring", "m.prompt"].includes(ev.getContent()["m.intent"]) && !("m.terminated" in ev.getContent()) && (ev.getPrevContent()["m.terminated"] !== ev.getContent()["m.terminated"] || (0, _utils.deepCompare)(ev.getPrevContent(), {})); + } + createCachedRegex(prefix, glob, suffix) { + if (PushProcessor.cachedGlobToRegex[glob]) { + return PushProcessor.cachedGlobToRegex[glob]; + } + PushProcessor.cachedGlobToRegex[glob] = new RegExp(prefix + (0, _utils.globToRegexp)(glob) + suffix, "i") // Case insensitive + ; + + return PushProcessor.cachedGlobToRegex[glob]; + } + + /** + * Parse the key into the separate fields to search by splitting on + * unescaped ".", and then removing any escape characters. + * + * @param str - The key of the push rule condition: a dotted field. + * @returns The unescaped parts to fetch. + * @internal + */ + static partsForDottedKey(str) { + const result = []; + + // The current field and whether the previous character was the escape + // character (a backslash). + let part = ""; + let escaped = false; + + // Iterate over each character, and decide whether to append to the current + // part (following the escape rules) or to start a new part (based on the + // field separator). + for (const c of str) { + // If the previous character was the escape character (a backslash) + // then decide what to append to the current part. + if (escaped) { + if (c === "\\" || c === ".") { + // An escaped backslash or dot just gets added. + part += c; + } else { + // A character that shouldn't be escaped gets the backslash prepended. + part += "\\" + c; + } + // This always resets being escaped. + escaped = false; + continue; + } + if (c == ".") { + // The field separator creates a new part. + result.push(part); + part = ""; + } else if (c == "\\") { + // A backslash adds no characters, but starts an escape sequence. + escaped = true; + } else { + // Otherwise, just add the current character. + part += c; + } + } + + // Ensure the final part is included. If there's an open escape sequence + // it should be included. + if (escaped) { + part += "\\"; + } + result.push(part); + return result; + } + + /** + * For a dotted field and event, fetch the value at that position, if one + * exists. + * + * @param key - The key of the push rule condition: a dotted field to fetch. + * @param ev - The matrix event to fetch the field from. + * @returns The value at the dotted path given by key. + */ + valueForDottedKey(key, ev) { + // The key should already have been parsed via updateCachedPushRuleKeys, + // but if it hasn't (maybe via an old consumer of the SDK which hasn't + // been updated?) then lazily calculate it here. + let parts = this.parsedKeys.get(key); + if (parts === undefined) { + parts = PushProcessor.partsForDottedKey(key); + this.parsedKeys.set(key, parts); + } + let val; + + // special-case the first component to deal with encrypted messages + const firstPart = parts[0]; + let currentIndex = 0; + if (firstPart === "content") { + val = ev.getContent(); + ++currentIndex; + } else if (firstPart === "type") { + val = ev.getType(); + ++currentIndex; + } else { + // use the raw event for any other fields + val = ev.event; + } + for (; currentIndex < parts.length; ++currentIndex) { + // The previous iteration resulted in null or undefined, bail (and + // avoid the type error of attempting to retrieve a property). + if ((0, _utils.isNullOrUndefined)(val)) { + return undefined; + } + const thisPart = parts[currentIndex]; + val = val[thisPart]; + } + return val; + } + matchingRuleForEventWithRulesets(ev, rulesets) { + if (!rulesets) { + return null; + } + if (ev.getSender() === this.client.getSafeUserId()) { + return null; + } + return this.matchingRuleFromKindSet(ev, rulesets.global); + } + pushActionsForEventAndRulesets(ev, rulesets) { + const rule = this.matchingRuleForEventWithRulesets(ev, rulesets); + if (!rule) { + return {}; + } + const actionObj = PushProcessor.actionListToActionsObject(rule.actions); + + // Some actions are implicit in some situations: we add those here + if (actionObj.tweaks.highlight === undefined) { + // if it isn't specified, highlight if it's a content + // rule but otherwise not + actionObj.tweaks.highlight = rule.kind == _PushRules.PushRuleKind.ContentSpecific; + } + return { + actions: actionObj, + rule + }; + } + ruleMatchesEvent(rule, ev) { + // Disable the deprecated mentions push rules if the new mentions property exists. + if (this.client.supportsIntentionalMentions() && ev.getContent()["org.matrix.msc3952.mentions"] !== undefined && (rule.rule_id === _PushRules.RuleId.ContainsUserName || rule.rule_id === _PushRules.RuleId.ContainsDisplayName || rule.rule_id === _PushRules.RuleId.AtRoomNotification)) { + return false; + } + return !rule.conditions?.some(cond => !this.eventFulfillsCondition(cond, ev)); + } + + /** + * Get the user's push actions for the given event + */ + actionsForEvent(ev) { + const { + actions + } = this.pushActionsForEventAndRulesets(ev, this.client.pushRules); + return actions || {}; + } + actionsAndRuleForEvent(ev) { + return this.pushActionsForEventAndRulesets(ev, this.client.pushRules); + } + + /** + * Get one of the users push rules by its ID + * + * @param ruleId - The ID of the rule to search for + * @returns The push rule, or null if no such rule was found + */ + getPushRuleById(ruleId) { + const result = this.getPushRuleAndKindById(ruleId); + return result?.rule ?? null; + } + + /** + * Get one of the users push rules by its ID + * + * @param ruleId - The ID of the rule to search for + * @returns rule The push rule, or null if no such rule was found + * @returns kind - The PushRuleKind of the rule to search for + */ + getPushRuleAndKindById(ruleId) { + for (const scope of ["global"]) { + if (this.client.pushRules?.[scope] === undefined) continue; + for (const kind of RULEKINDS_IN_ORDER) { + if (this.client.pushRules[scope][kind] === undefined) continue; + for (const rule of this.client.pushRules[scope][kind]) { + if (rule.rule_id === ruleId) return { + rule, + kind + }; + } + } + } + return null; + } +} +exports.PushProcessor = PushProcessor; +_defineProperty(PushProcessor, "cachedGlobToRegex", {});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/randomstring.js b/comm/chat/protocols/matrix/lib/matrix-sdk/randomstring.js new file mode 100644 index 0000000000..2e2032a10a --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/randomstring.js @@ -0,0 +1,44 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.randomLowercaseString = randomLowercaseString; +exports.randomString = randomString; +exports.randomUppercaseString = randomUppercaseString; +/* +Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +const LOWERCASE = "abcdefghijklmnopqrstuvwxyz"; +const UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const DIGITS = "0123456789"; +function randomString(len) { + return randomStringFrom(len, UPPERCASE + LOWERCASE + DIGITS); +} +function randomLowercaseString(len) { + return randomStringFrom(len, LOWERCASE); +} +function randomUppercaseString(len) { + return randomStringFrom(len, UPPERCASE); +} +function randomStringFrom(len, chars) { + let ret = ""; + for (let i = 0; i < len; ++i) { + ret += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return ret; +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/realtime-callbacks.js b/comm/chat/protocols/matrix/lib/matrix-sdk/realtime-callbacks.js new file mode 100644 index 0000000000..56b9ef0a1c --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/realtime-callbacks.js @@ -0,0 +1,179 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.clearTimeout = clearTimeout; +exports.setTimeout = setTimeout; +var _logger = require("./logger"); +/* +Copyright 2016 OpenMarket Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* A re-implementation of the javascript callback functions (setTimeout, + * clearTimeout; setInterval and clearInterval are not yet implemented) which + * try to improve handling of large clock jumps (as seen when + * suspending/resuming the system). + * + * In particular, if a timeout would have fired while the system was suspended, + * it will instead fire as soon as possible after resume. + */ + +// we schedule a callback at least this often, to check if we've missed out on +// some wall-clock time due to being suspended. +const TIMER_CHECK_PERIOD_MS = 1000; + +// counter, for making up ids to return from setTimeout +let count = 0; + +// the key for our callback with the real global.setTimeout +let realCallbackKey; +// a sorted list of the callbacks to be run. +// each is an object with keys [runAt, func, params, key]. +const callbackList = []; + +// var debuglog = logger.log.bind(logger); +/* istanbul ignore next */ +const debuglog = function (...params) {}; + +/** + * reimplementation of window.setTimeout, which will call the callback if + * the wallclock time goes past the deadline. + * + * @param func - callback to be called after a delay + * @param delayMs - number of milliseconds to delay by + * + * @returns an identifier for this callback, which may be passed into + * clearTimeout later. + */ +function setTimeout(func, delayMs, ...params) { + delayMs = delayMs || 0; + if (delayMs < 0) { + delayMs = 0; + } + const runAt = Date.now() + delayMs; + const key = count++; + debuglog("setTimeout: scheduling cb", key, "at", runAt, "(delay", delayMs, ")"); + const data = { + runAt: runAt, + func: func, + params: params, + key: key + }; + + // figure out where it goes in the list + const idx = binarySearch(callbackList, function (el) { + return el.runAt - runAt; + }); + callbackList.splice(idx, 0, data); + scheduleRealCallback(); + return key; +} + +/** + * reimplementation of window.clearTimeout, which mirrors setTimeout + * + * @param key - result from an earlier setTimeout call + */ +function clearTimeout(key) { + if (callbackList.length === 0) { + return; + } + + // remove the element from the list + let i; + for (i = 0; i < callbackList.length; i++) { + const cb = callbackList[i]; + if (cb.key == key) { + callbackList.splice(i, 1); + break; + } + } + + // iff it was the first one in the list, reschedule our callback. + if (i === 0) { + scheduleRealCallback(); + } +} + +// use the real global.setTimeout to schedule a callback to runCallbacks. +function scheduleRealCallback() { + if (realCallbackKey) { + global.clearTimeout(realCallbackKey); + } + const first = callbackList[0]; + if (!first) { + debuglog("scheduleRealCallback: no more callbacks, not rescheduling"); + return; + } + const timestamp = Date.now(); + const delayMs = Math.min(first.runAt - timestamp, TIMER_CHECK_PERIOD_MS); + debuglog("scheduleRealCallback: now:", timestamp, "delay:", delayMs); + realCallbackKey = global.setTimeout(runCallbacks, delayMs); +} +function runCallbacks() { + const timestamp = Date.now(); + debuglog("runCallbacks: now:", timestamp); + + // get the list of things to call + const callbacksToRun = []; + // eslint-disable-next-line + while (true) { + const first = callbackList[0]; + if (!first || first.runAt > timestamp) { + break; + } + const cb = callbackList.shift(); + debuglog("runCallbacks: popping", cb.key); + callbacksToRun.push(cb); + } + + // reschedule the real callback before running our functions, to + // keep the codepaths the same whether or not our functions + // register their own setTimeouts. + scheduleRealCallback(); + for (const cb of callbacksToRun) { + try { + cb.func.apply(global, cb.params); + } catch (e) { + _logger.logger.error("Uncaught exception in callback function", e); + } + } +} + +/* search in a sorted array. + * + * returns the index of the last element for which func returns + * greater than zero, or array.length if no such element exists. + */ +function binarySearch(array, func) { + // min is inclusive, max exclusive. + let min = 0; + let max = array.length; + while (min < max) { + const mid = min + max >> 1; + const res = func(array[mid]); + if (res > 0) { + // the element at 'mid' is too big; set it as the new max. + max = mid; + } else { + // the element at 'mid' is too small. 'min' is inclusive, so +1. + min = mid + 1; + } + } + // presumably, min==max now. + return min; +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/receipt-accumulator.js b/comm/chat/protocols/matrix/lib/matrix-sdk/receipt-accumulator.js new file mode 100644 index 0000000000..f5b42f750b --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/receipt-accumulator.js @@ -0,0 +1,169 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.ReceiptAccumulator = void 0; +var _event = require("./@types/event"); +var _utils = require("./utils"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/** + * Summarises the read receipts within a room. Used by the sync accumulator. + * + * Given receipts for users, picks the most recently-received one and provides + * the results in a new fake receipt event returned from + * buildAccumulatedReceiptEvent(). + * + * Handles unthreaded receipts and receipts in each thread separately, so the + * returned event contains the most recently received unthreaded receipt, and + * the most recently received receipt in each thread. + */ +class ReceiptAccumulator { + constructor() { + /** user_id -\> most-recently-received unthreaded receipt */ + _defineProperty(this, "unthreadedReadReceipts", new Map()); + /** thread_id -\> user_id -\> most-recently-received receipt for this thread */ + _defineProperty(this, "threadedReadReceipts", new _utils.MapWithDefault(() => new Map())); + } + /** + * Provide an unthreaded receipt for this user. Overwrites any other + * unthreaded receipt we have for this user. + */ + setUnthreaded(userId, receipt) { + this.unthreadedReadReceipts.set(userId, receipt); + } + + /** + * Provide a receipt for this user in this thread. Overwrites any other + * receipt we have for this user in this thread. + */ + setThreaded(threadId, userId, receipt) { + this.threadedReadReceipts.getOrCreate(threadId).set(userId, receipt); + } + + /** + * @returns an iterator of pairs of [userId, AccumulatedReceipt] - all the + * most recently-received unthreaded receipts for each user. + */ + allUnthreaded() { + return this.unthreadedReadReceipts.entries(); + } + + /** + * @returns an iterator of pairs of [userId, AccumulatedReceipt] - all the + * most recently-received threaded receipts for each user, in all + * threads. + */ + *allThreaded() { + for (const receiptsForThread of this.threadedReadReceipts.values()) { + for (const e of receiptsForThread.entries()) { + yield e; + } + } + } + + /** + * Given a list of ephemeral events, find the receipts and store the + * relevant ones to be returned later from buildAccumulatedReceiptEvent(). + */ + consumeEphemeralEvents(events) { + events?.forEach(e => { + if (e.type !== _event.EventType.Receipt || !e.content) { + // This means we'll drop unknown ephemeral events but that + // seems okay. + return; + } + + // Handle m.receipt events. They clobber based on: + // (user_id, receipt_type) + // but they are keyed in the event as: + // content:{ $event_id: { $receipt_type: { $user_id: {json} }}} + // so store them in the former so we can accumulate receipt deltas + // quickly and efficiently (we expect a lot of them). Fold the + // receipt type into the key name since we only have 1 at the + // moment (m.read) and nested JSON objects are slower and more + // of a hassle to work with. We'll inflate this back out when + // getJSON() is called. + Object.keys(e.content).forEach(eventId => { + Object.entries(e.content[eventId]).forEach(([key, value]) => { + if (!(0, _utils.isSupportedReceiptType)(key)) return; + for (const userId of Object.keys(value)) { + const data = e.content[eventId][key][userId]; + const receipt = { + data: e.content[eventId][key][userId], + type: key, + eventId + }; + + // In a world that supports threads, read receipts normally have + // a `thread_id` which is either the thread they belong in or + // `MAIN_ROOM_TIMELINE`, so we normally use `setThreaded(...)` + // here. The `MAIN_ROOM_TIMELINE` is just treated as another + // thread. + // + // We still encounter read receipts that are "unthreaded" + // (missing the `thread_id` property). These come from clients + // that don't support threads, and from threaded clients that + // are doing a "Mark room as read" operation. Unthreaded + // receipts mark everything "before" them as read, in all + // threads, where "before" means in Sync Order i.e. the order + // the events were received from the homeserver in a sync. + // [Note: we have some bugs where we use timestamp order instead + // of Sync Order, because we don't correctly remember the Sync + // Order. See #3325.] + // + // Calling the wrong method will cause incorrect behavior like + // messages re-appearing as "new" when you already read them + // previously. + if (!data.thread_id) { + this.setUnthreaded(userId, receipt); + } else { + this.setThreaded(data.thread_id, userId, receipt); + } + } + }); + }); + }); + } + + /** + * Build a receipt event that contains all relevant information for this + * room, taking the most recently received receipt for each user in an + * unthreaded context, and in each thread. + */ + buildAccumulatedReceiptEvent(roomId) { + const receiptEvent = { + type: _event.EventType.Receipt, + room_id: roomId, + content: { + // $event_id: { "m.read": { $user_id: $json } } + } + }; + const receiptEventContent = new _utils.MapWithDefault(() => new _utils.MapWithDefault(() => new Map())); + for (const [userId, receiptData] of this.allUnthreaded()) { + receiptEventContent.getOrCreate(receiptData.eventId).getOrCreate(receiptData.type).set(userId, receiptData.data); + } + for (const [userId, receiptData] of this.allThreaded()) { + receiptEventContent.getOrCreate(receiptData.eventId).getOrCreate(receiptData.type).set(userId, receiptData.data); + } + receiptEvent.content = (0, _utils.recursiveMapToObject)(receiptEventContent); + return receiptEventContent.size > 0 ? receiptEvent : null; + } +} +exports.ReceiptAccumulator = ReceiptAccumulator;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/MSC3906Rendezvous.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/MSC3906Rendezvous.js new file mode 100644 index 0000000000..50da6ab883 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/MSC3906Rendezvous.js @@ -0,0 +1,240 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.MSC3906Rendezvous = void 0; +var _matrixEventsSdk = require("matrix-events-sdk"); +var _ = require("."); +var _client = require("../client"); +var _feature = require("../feature"); +var _logger = require("../logger"); +var _utils = require("../utils"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2022 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +var PayloadType = /*#__PURE__*/function (PayloadType) { + PayloadType["Start"] = "m.login.start"; + PayloadType["Finish"] = "m.login.finish"; + PayloadType["Progress"] = "m.login.progress"; + return PayloadType; +}(PayloadType || {}); +var Outcome = /*#__PURE__*/function (Outcome) { + Outcome["Success"] = "success"; + Outcome["Failure"] = "failure"; + Outcome["Verified"] = "verified"; + Outcome["Declined"] = "declined"; + Outcome["Unsupported"] = "unsupported"; + return Outcome; +}(Outcome || {}); +const LOGIN_TOKEN_PROTOCOL = new _matrixEventsSdk.UnstableValue("login_token", "org.matrix.msc3906.login_token"); + +/** + * Implements MSC3906 to allow a user to sign in on a new device using QR code. + * This implementation only supports generating a QR code on a device that is already signed in. + * Note that this is UNSTABLE and may have breaking changes without notice. + */ +class MSC3906Rendezvous { + /** + * @param channel - The secure channel used for communication + * @param client - The Matrix client in used on the device already logged in + * @param onFailure - Callback for when the rendezvous fails + */ + constructor(channel, client, onFailure) { + this.channel = channel; + this.client = client; + this.onFailure = onFailure; + _defineProperty(this, "newDeviceId", void 0); + _defineProperty(this, "newDeviceKey", void 0); + _defineProperty(this, "ourIntent", _.RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE); + _defineProperty(this, "_code", void 0); + } + + /** + * Returns the code representing the rendezvous suitable for rendering in a QR code or undefined if not generated yet. + */ + get code() { + return this._code; + } + + /** + * Generate the code including doing partial set up of the channel where required. + */ + async generateCode() { + if (this._code) { + return; + } + this._code = JSON.stringify(await this.channel.generateCode(this.ourIntent)); + } + async startAfterShowingCode() { + const checksum = await this.channel.connect(); + _logger.logger.info(`Connected to secure channel with checksum: ${checksum} our intent is ${this.ourIntent}`); + + // in r1 of MSC3882 the availability is exposed as a capability + const capabilities = await this.client.getCapabilities(); + // in r0 of MSC3882 the availability is exposed as a feature flag + const features = await (0, _feature.buildFeatureSupportMap)(await this.client.getVersions()); + const capability = _client.UNSTABLE_MSC3882_CAPABILITY.findIn(capabilities); + + // determine available protocols + if (!capability?.enabled && features.get(_feature.Feature.LoginTokenRequest) === _feature.ServerSupport.Unsupported) { + _logger.logger.info("Server doesn't support MSC3882"); + await this.send({ + type: PayloadType.Finish, + outcome: Outcome.Unsupported + }); + await this.cancel(_.RendezvousFailureReason.HomeserverLacksSupport); + return undefined; + } + await this.send({ + type: PayloadType.Progress, + protocols: [LOGIN_TOKEN_PROTOCOL.name] + }); + _logger.logger.info("Waiting for other device to chose protocol"); + const { + type, + protocol, + outcome + } = await this.receive(); + if (type === PayloadType.Finish) { + // new device decided not to complete + switch (outcome ?? "") { + case "unsupported": + await this.cancel(_.RendezvousFailureReason.UnsupportedAlgorithm); + break; + default: + await this.cancel(_.RendezvousFailureReason.Unknown); + } + return undefined; + } + if (type !== PayloadType.Progress) { + await this.cancel(_.RendezvousFailureReason.Unknown); + return undefined; + } + if (!protocol || !LOGIN_TOKEN_PROTOCOL.matches(protocol)) { + await this.cancel(_.RendezvousFailureReason.UnsupportedAlgorithm); + return undefined; + } + return checksum; + } + async receive() { + return await this.channel.receive(); + } + async send(payload) { + await this.channel.send(payload); + } + async declineLoginOnExistingDevice() { + _logger.logger.info("User declined sign in"); + await this.send({ + type: PayloadType.Finish, + outcome: Outcome.Declined + }); + } + async approveLoginOnExistingDevice(loginToken) { + // eslint-disable-next-line camelcase + await this.send({ + type: PayloadType.Progress, + login_token: loginToken, + homeserver: this.client.baseUrl + }); + _logger.logger.info("Waiting for outcome"); + const res = await this.receive(); + if (!res) { + return undefined; + } + const { + outcome, + device_id: deviceId, + device_key: deviceKey + } = res; + if (outcome !== "success") { + throw new Error("Linking failed"); + } + this.newDeviceId = deviceId; + this.newDeviceKey = deviceKey; + return deviceId; + } + async verifyAndCrossSignDevice(deviceInfo) { + if (!this.client.crypto) { + throw new Error("Crypto not available on client"); + } + if (!this.newDeviceId) { + throw new Error("No new device ID set"); + } + + // check that keys received from the server for the new device match those received from the device itself + if (deviceInfo.getFingerprint() !== this.newDeviceKey) { + throw new Error(`New device has different keys than expected: ${this.newDeviceKey} vs ${deviceInfo.getFingerprint()}`); + } + const userId = this.client.getUserId(); + if (!userId) { + throw new Error("No user ID set"); + } + // mark the device as verified locally + cross sign + _logger.logger.info(`Marking device ${this.newDeviceId} as verified`); + const info = await this.client.crypto.setDeviceVerification(userId, this.newDeviceId, true, false, true); + const masterPublicKey = this.client.crypto.crossSigningInfo.getId("master"); + await this.send({ + type: PayloadType.Finish, + outcome: Outcome.Verified, + verifying_device_id: this.client.getDeviceId(), + verifying_device_key: this.client.getDeviceEd25519Key(), + master_key: masterPublicKey + }); + return info; + } + + /** + * Verify the device and cross-sign it. + * @param timeout - time in milliseconds to wait for device to come online + * @returns the new device info if the device was verified + */ + async verifyNewDeviceOnExistingDevice(timeout = 10 * 1000) { + if (!this.newDeviceId) { + throw new Error("No new device to sign"); + } + if (!this.newDeviceKey) { + _logger.logger.info("No new device key to sign"); + return undefined; + } + if (!this.client.crypto) { + throw new Error("Crypto not available on client"); + } + const userId = this.client.getUserId(); + if (!userId) { + throw new Error("No user ID set"); + } + let deviceInfo = this.client.crypto.getStoredDevice(userId, this.newDeviceId); + if (!deviceInfo) { + _logger.logger.info("Going to wait for new device to be online"); + await (0, _utils.sleep)(timeout); + deviceInfo = this.client.crypto.getStoredDevice(userId, this.newDeviceId); + } + if (deviceInfo) { + return await this.verifyAndCrossSignDevice(deviceInfo); + } + throw new Error("Device not online within timeout"); + } + async cancel(reason) { + this.onFailure?.(reason); + await this.channel.cancel(reason); + } + async close() { + await this.channel.close(); + } +} +exports.MSC3906Rendezvous = MSC3906Rendezvous;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousChannel.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousChannel.js new file mode 100644 index 0000000000..430afc16cd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousChannel.js @@ -0,0 +1,5 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousCode.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousCode.js new file mode 100644 index 0000000000..430afc16cd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousCode.js @@ -0,0 +1,5 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousError.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousError.js new file mode 100644 index 0000000000..5190ebbb76 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousError.js @@ -0,0 +1,29 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.RendezvousError = void 0; +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +class RendezvousError extends Error { + constructor(message, code) { + super(message); + this.code = code; + } +} +exports.RendezvousError = RendezvousError;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousFailureReason.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousFailureReason.js new file mode 100644 index 0000000000..ee6a9b987f --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousFailureReason.js @@ -0,0 +1,36 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.RendezvousFailureReason = void 0; +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +let RendezvousFailureReason = /*#__PURE__*/function (RendezvousFailureReason) { + RendezvousFailureReason["UserDeclined"] = "user_declined"; + RendezvousFailureReason["OtherDeviceNotSignedIn"] = "other_device_not_signed_in"; + RendezvousFailureReason["OtherDeviceAlreadySignedIn"] = "other_device_already_signed_in"; + RendezvousFailureReason["Unknown"] = "unknown"; + RendezvousFailureReason["Expired"] = "expired"; + RendezvousFailureReason["UserCancelled"] = "user_cancelled"; + RendezvousFailureReason["InvalidCode"] = "invalid_code"; + RendezvousFailureReason["UnsupportedAlgorithm"] = "unsupported_algorithm"; + RendezvousFailureReason["DataMismatch"] = "data_mismatch"; + RendezvousFailureReason["UnsupportedTransport"] = "unsupported_transport"; + RendezvousFailureReason["HomeserverLacksSupport"] = "homeserver_lacks_support"; + return RendezvousFailureReason; +}({}); +exports.RendezvousFailureReason = RendezvousFailureReason;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousIntent.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousIntent.js new file mode 100644 index 0000000000..5393ac57b9 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousIntent.js @@ -0,0 +1,27 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.RendezvousIntent = void 0; +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +let RendezvousIntent = /*#__PURE__*/function (RendezvousIntent) { + RendezvousIntent["LOGIN_ON_NEW_DEVICE"] = "login.start"; + RendezvousIntent["RECIPROCATE_LOGIN_ON_EXISTING_DEVICE"] = "login.reciprocate"; + return RendezvousIntent; +}({}); +exports.RendezvousIntent = RendezvousIntent;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousTransport.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousTransport.js new file mode 100644 index 0000000000..430afc16cd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousTransport.js @@ -0,0 +1,5 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.js new file mode 100644 index 0000000000..3c9e5793bc --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.js @@ -0,0 +1,194 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.MSC3903ECDHv2RendezvousChannel = void 0; +var _ = require(".."); +var _olmlib = require("../../crypto/olmlib"); +var _crypto = require("../../crypto/crypto"); +var _SASDecimal = require("../../crypto/verification/SASDecimal"); +var _NamespacedValue = require("../../NamespacedValue"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +const ECDH_V2 = new _NamespacedValue.UnstableValue("m.rendezvous.v2.curve25519-aes-sha256", "org.matrix.msc3903.rendezvous.v2.curve25519-aes-sha256"); +async function importKey(key) { + if (!_crypto.subtleCrypto) { + throw new Error("Web Crypto is not available"); + } + const imported = _crypto.subtleCrypto.importKey("raw", key, { + name: "AES-GCM" + }, false, ["encrypt", "decrypt"]); + return imported; +} + +/** + * Implementation of the unstable [MSC3903](https://github.com/matrix-org/matrix-spec-proposals/pull/3903) + * X25519/ECDH key agreement based secure rendezvous channel. + * Note that this is UNSTABLE and may have breaking changes without notice. + */ +class MSC3903ECDHv2RendezvousChannel { + constructor(transport, theirPublicKey, onFailure) { + this.transport = transport; + this.theirPublicKey = theirPublicKey; + this.onFailure = onFailure; + _defineProperty(this, "olmSAS", void 0); + _defineProperty(this, "ourPublicKey", void 0); + _defineProperty(this, "aesKey", void 0); + _defineProperty(this, "connected", false); + this.olmSAS = new global.Olm.SAS(); + this.ourPublicKey = (0, _olmlib.decodeBase64)(this.olmSAS.get_pubkey()); + } + async generateCode(intent) { + if (this.transport.ready) { + throw new Error("Code already generated"); + } + await this.transport.send({ + algorithm: ECDH_V2.name + }); + const rendezvous = { + rendezvous: { + algorithm: ECDH_V2.name, + key: (0, _olmlib.encodeUnpaddedBase64)(this.ourPublicKey), + transport: await this.transport.details() + }, + intent + }; + return rendezvous; + } + async connect() { + if (this.connected) { + throw new Error("Channel already connected"); + } + if (!this.olmSAS) { + throw new Error("Channel closed"); + } + const isInitiator = !this.theirPublicKey; + if (isInitiator) { + // wait for the other side to send us their public key + const rawRes = await this.transport.receive(); + if (!rawRes) { + throw new Error("No response from other device"); + } + const res = rawRes; + const { + key, + algorithm + } = res; + if (!algorithm || !ECDH_V2.matches(algorithm) || !key) { + throw new _.RendezvousError("Unsupported algorithm: " + algorithm, _.RendezvousFailureReason.UnsupportedAlgorithm); + } + this.theirPublicKey = (0, _olmlib.decodeBase64)(key); + } else { + // send our public key unencrypted + await this.transport.send({ + algorithm: ECDH_V2.name, + key: (0, _olmlib.encodeUnpaddedBase64)(this.ourPublicKey) + }); + } + this.connected = true; + this.olmSAS.set_their_key((0, _olmlib.encodeUnpaddedBase64)(this.theirPublicKey)); + const initiatorKey = isInitiator ? this.ourPublicKey : this.theirPublicKey; + const recipientKey = isInitiator ? this.theirPublicKey : this.ourPublicKey; + let aesInfo = ECDH_V2.name; + aesInfo += `|${(0, _olmlib.encodeUnpaddedBase64)(initiatorKey)}`; + aesInfo += `|${(0, _olmlib.encodeUnpaddedBase64)(recipientKey)}`; + const aesKeyBytes = this.olmSAS.generate_bytes(aesInfo, 32); + this.aesKey = await importKey(aesKeyBytes); + + // blank the bytes out to make sure not kept in memory + aesKeyBytes.fill(0); + const rawChecksum = this.olmSAS.generate_bytes(aesInfo, 5); + return (0, _SASDecimal.generateDecimalSas)(Array.from(rawChecksum)).join("-"); + } + async encrypt(data) { + if (!_crypto.subtleCrypto) { + throw new Error("Web Crypto is not available"); + } + const iv = new Uint8Array(32); + _crypto.crypto.getRandomValues(iv); + const encodedData = new _crypto.TextEncoder().encode(JSON.stringify(data)); + const ciphertext = await _crypto.subtleCrypto.encrypt({ + name: "AES-GCM", + iv, + tagLength: 128 + }, this.aesKey, encodedData); + return { + iv: (0, _olmlib.encodeUnpaddedBase64)(iv), + ciphertext: (0, _olmlib.encodeUnpaddedBase64)(ciphertext) + }; + } + async send(payload) { + if (!this.olmSAS) { + throw new Error("Channel closed"); + } + if (!this.aesKey) { + throw new Error("Shared secret not set up"); + } + return this.transport.send(await this.encrypt(payload)); + } + async decrypt({ + iv, + ciphertext + }) { + if (!ciphertext || !iv) { + throw new Error("Missing ciphertext and/or iv"); + } + const ciphertextBytes = (0, _olmlib.decodeBase64)(ciphertext); + if (!_crypto.subtleCrypto) { + throw new Error("Web Crypto is not available"); + } + const plaintext = await _crypto.subtleCrypto.decrypt({ + name: "AES-GCM", + iv: (0, _olmlib.decodeBase64)(iv), + tagLength: 128 + }, this.aesKey, ciphertextBytes); + return JSON.parse(new TextDecoder().decode(new Uint8Array(plaintext))); + } + async receive() { + if (!this.olmSAS) { + throw new Error("Channel closed"); + } + if (!this.aesKey) { + throw new Error("Shared secret not set up"); + } + const rawData = await this.transport.receive(); + if (!rawData) { + return undefined; + } + const data = rawData; + if (data.ciphertext && data.iv) { + return this.decrypt(data); + } + throw new Error("Data received but no ciphertext"); + } + async close() { + if (this.olmSAS) { + this.olmSAS.free(); + this.olmSAS = undefined; + } + } + async cancel(reason) { + try { + await this.transport.cancel(reason); + } finally { + await this.close(); + } + } +} +exports.MSC3903ECDHv2RendezvousChannel = MSC3903ECDHv2RendezvousChannel;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/channels/index.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/channels/index.js new file mode 100644 index 0000000000..e2d30513fd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/channels/index.js @@ -0,0 +1,16 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +var _MSC3903ECDHv2RendezvousChannel = require("./MSC3903ECDHv2RendezvousChannel"); +Object.keys(_MSC3903ECDHv2RendezvousChannel).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (key in exports && exports[key] === _MSC3903ECDHv2RendezvousChannel[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _MSC3903ECDHv2RendezvousChannel[key]; + } + }); +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/index.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/index.js new file mode 100644 index 0000000000..141c9da4fc --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/index.js @@ -0,0 +1,82 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +var _MSC3906Rendezvous = require("./MSC3906Rendezvous"); +Object.keys(_MSC3906Rendezvous).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (key in exports && exports[key] === _MSC3906Rendezvous[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _MSC3906Rendezvous[key]; + } + }); +}); +var _RendezvousChannel = require("./RendezvousChannel"); +Object.keys(_RendezvousChannel).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (key in exports && exports[key] === _RendezvousChannel[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _RendezvousChannel[key]; + } + }); +}); +var _RendezvousCode = require("./RendezvousCode"); +Object.keys(_RendezvousCode).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (key in exports && exports[key] === _RendezvousCode[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _RendezvousCode[key]; + } + }); +}); +var _RendezvousError = require("./RendezvousError"); +Object.keys(_RendezvousError).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (key in exports && exports[key] === _RendezvousError[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _RendezvousError[key]; + } + }); +}); +var _RendezvousFailureReason = require("./RendezvousFailureReason"); +Object.keys(_RendezvousFailureReason).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (key in exports && exports[key] === _RendezvousFailureReason[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _RendezvousFailureReason[key]; + } + }); +}); +var _RendezvousIntent = require("./RendezvousIntent"); +Object.keys(_RendezvousIntent).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (key in exports && exports[key] === _RendezvousIntent[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _RendezvousIntent[key]; + } + }); +}); +var _RendezvousTransport = require("./RendezvousTransport"); +Object.keys(_RendezvousTransport).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (key in exports && exports[key] === _RendezvousTransport[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _RendezvousTransport[key]; + } + }); +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.js new file mode 100644 index 0000000000..6347229aca --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.js @@ -0,0 +1,176 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.MSC3886SimpleHttpRendezvousTransport = void 0; +var _matrixEventsSdk = require("matrix-events-sdk"); +var _logger = require("../../logger"); +var _utils = require("../../utils"); +var _ = require(".."); +var _httpApi = require("../../http-api"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2022 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +const TYPE = new _matrixEventsSdk.UnstableValue("http.v1", "org.matrix.msc3886.http.v1"); +/** + * Implementation of the unstable [MSC3886](https://github.com/matrix-org/matrix-spec-proposals/pull/3886) + * simple HTTP rendezvous protocol. + * Note that this is UNSTABLE and may have breaking changes without notice. + */ +class MSC3886SimpleHttpRendezvousTransport { + constructor({ + onFailure, + client, + fallbackRzServer, + fetchFn + }) { + _defineProperty(this, "uri", void 0); + _defineProperty(this, "etag", void 0); + _defineProperty(this, "expiresAt", void 0); + _defineProperty(this, "client", void 0); + _defineProperty(this, "fallbackRzServer", void 0); + _defineProperty(this, "fetchFn", void 0); + _defineProperty(this, "cancelled", false); + _defineProperty(this, "_ready", false); + _defineProperty(this, "onFailure", void 0); + this.fetchFn = fetchFn; + this.onFailure = onFailure; + this.client = client; + this.fallbackRzServer = fallbackRzServer; + } + get ready() { + return this._ready; + } + async details() { + if (!this.uri) { + throw new Error("Rendezvous not set up"); + } + return { + type: TYPE.name, + uri: this.uri + }; + } + fetch(resource, options) { + if (this.fetchFn) { + return this.fetchFn(resource, options); + } + return global.fetch(resource, options); + } + async getPostEndpoint() { + try { + if (await this.client.doesServerSupportUnstableFeature("org.matrix.msc3886")) { + return `${this.client.baseUrl}${_httpApi.ClientPrefix.Unstable}/org.matrix.msc3886/rendezvous`; + } + } catch (err) { + _logger.logger.warn("Failed to get unstable features", err); + } + return this.fallbackRzServer; + } + async send(data) { + if (this.cancelled) { + return; + } + const method = this.uri ? "PUT" : "POST"; + const uri = this.uri ?? (await this.getPostEndpoint()); + if (!uri) { + throw new Error("Invalid rendezvous URI"); + } + const headers = { + "content-type": "application/json" + }; + if (this.etag) { + headers["if-match"] = this.etag; + } + const res = await this.fetch(uri, { + method, + headers, + body: JSON.stringify(data) + }); + if (res.status === 404) { + return this.cancel(_.RendezvousFailureReason.Unknown); + } + this.etag = res.headers.get("etag") ?? undefined; + if (method === "POST") { + const location = res.headers.get("location"); + if (!location) { + throw new Error("No rendezvous URI given"); + } + const expires = res.headers.get("expires"); + if (expires) { + this.expiresAt = new Date(expires); + } + // we would usually expect the final `url` to be set by a proper fetch implementation. + // however, if a polyfill based on XHR is used it won't be set, we we use existing URI as fallback + const baseUrl = res.url ?? uri; + // resolve location header which could be relative or absolute + this.uri = new URL(location, `${baseUrl}${baseUrl.endsWith("/") ? "" : "/"}`).href; + this._ready = true; + } + } + async receive() { + if (!this.uri) { + throw new Error("Rendezvous not set up"); + } + // eslint-disable-next-line no-constant-condition + while (true) { + if (this.cancelled) { + return undefined; + } + const headers = {}; + if (this.etag) { + headers["if-none-match"] = this.etag; + } + const poll = await this.fetch(this.uri, { + method: "GET", + headers + }); + if (poll.status === 404) { + this.cancel(_.RendezvousFailureReason.Unknown); + return undefined; + } + + // rely on server expiring the channel rather than checking ourselves + + if (poll.headers.get("content-type") !== "application/json") { + this.etag = poll.headers.get("etag") ?? undefined; + } else if (poll.status === 200) { + this.etag = poll.headers.get("etag") ?? undefined; + return poll.json(); + } + await (0, _utils.sleep)(1000); + } + } + async cancel(reason) { + if (reason === _.RendezvousFailureReason.Unknown && this.expiresAt && this.expiresAt.getTime() < Date.now()) { + reason = _.RendezvousFailureReason.Expired; + } + this.cancelled = true; + this._ready = false; + this.onFailure?.(reason); + if (this.uri && reason === _.RendezvousFailureReason.UserDeclined) { + try { + await this.fetch(this.uri, { + method: "DELETE" + }); + } catch (e) { + _logger.logger.warn(e); + } + } + } +} +exports.MSC3886SimpleHttpRendezvousTransport = MSC3886SimpleHttpRendezvousTransport;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/transports/index.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/transports/index.js new file mode 100644 index 0000000000..a1a4c56c64 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/transports/index.js @@ -0,0 +1,16 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +var _MSC3886SimpleHttpRendezvousTransport = require("./MSC3886SimpleHttpRendezvousTransport"); +Object.keys(_MSC3886SimpleHttpRendezvousTransport).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (key in exports && exports[key] === _MSC3886SimpleHttpRendezvousTransport[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _MSC3886SimpleHttpRendezvousTransport[key]; + } + }); +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/room-hierarchy.js b/comm/chat/protocols/matrix/lib/matrix-sdk/room-hierarchy.js new file mode 100644 index 0000000000..3e7c0c0094 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/room-hierarchy.js @@ -0,0 +1,133 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.RoomHierarchy = void 0; +var _event = require("./@types/event"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +class RoomHierarchy { + /** + * Construct a new RoomHierarchy + * + * A RoomHierarchy instance allows you to easily make use of the /hierarchy API and paginate it. + * + * @param root - the root of this hierarchy + * @param pageSize - the maximum number of rooms to return per page, can be overridden per load request. + * @param maxDepth - the maximum depth to traverse the hierarchy to + * @param suggestedOnly - whether to only return rooms with suggested=true. + */ + constructor(root, pageSize, maxDepth, suggestedOnly = false) { + this.root = root; + this.pageSize = pageSize; + this.maxDepth = maxDepth; + this.suggestedOnly = suggestedOnly; + // Map from room id to list of servers which are listed as a via somewhere in the loaded hierarchy + _defineProperty(this, "viaMap", new Map()); + // Map from room id to list of rooms which claim this room as their child + _defineProperty(this, "backRefs", new Map()); + // Map from room id to object + _defineProperty(this, "roomMap", new Map()); + _defineProperty(this, "loadRequest", void 0); + _defineProperty(this, "nextBatch", void 0); + _defineProperty(this, "_rooms", void 0); + _defineProperty(this, "serverSupportError", void 0); + } + get noSupport() { + return !!this.serverSupportError; + } + get canLoadMore() { + return !!this.serverSupportError || !!this.nextBatch || !this._rooms; + } + get loading() { + return !!this.loadRequest; + } + get rooms() { + return this._rooms; + } + async load(pageSize = this.pageSize) { + if (this.loadRequest) return this.loadRequest.then(r => r.rooms); + this.loadRequest = this.root.client.getRoomHierarchy(this.root.roomId, pageSize, this.maxDepth, this.suggestedOnly, this.nextBatch); + let rooms; + try { + ({ + rooms, + next_batch: this.nextBatch + } = await this.loadRequest); + } catch (e) { + if (e.errcode === "M_UNRECOGNIZED") { + this.serverSupportError = e; + } else { + throw e; + } + return []; + } finally { + this.loadRequest = undefined; + } + if (this._rooms) { + this._rooms = this._rooms.concat(rooms); + } else { + this._rooms = rooms; + } + rooms.forEach(room => { + this.roomMap.set(room.room_id, room); + room.children_state.forEach(ev => { + if (ev.type !== _event.EventType.SpaceChild) return; + const childRoomId = ev.state_key; + + // track backrefs for quicker hierarchy navigation + if (!this.backRefs.has(childRoomId)) { + this.backRefs.set(childRoomId, []); + } + this.backRefs.get(childRoomId).push(room.room_id); + + // fill viaMap + if (Array.isArray(ev.content.via)) { + if (!this.viaMap.has(childRoomId)) { + this.viaMap.set(childRoomId, new Set()); + } + const vias = this.viaMap.get(childRoomId); + ev.content.via.forEach(via => vias.add(via)); + } + }); + }); + return rooms; + } + getRelation(parentId, childId) { + return this.roomMap.get(parentId)?.children_state.find(e => e.state_key === childId); + } + isSuggested(parentId, childId) { + return this.getRelation(parentId, childId)?.content.suggested; + } + + // locally remove a relation as a form of local echo + removeRelation(parentId, childId) { + const backRefs = this.backRefs.get(childId); + if (backRefs?.length === 1) { + this.backRefs.delete(childId); + } else if (backRefs?.length) { + this.backRefs.set(childId, backRefs.filter(ref => ref !== parentId)); + } + const room = this.roomMap.get(parentId); + if (room) { + room.children_state = room.children_state.filter(ev => ev.state_key !== childId); + } + } +} +exports.RoomHierarchy = RoomHierarchy;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/CrossSigningIdentity.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/CrossSigningIdentity.js new file mode 100644 index 0000000000..3bb17a0cef --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/CrossSigningIdentity.js @@ -0,0 +1,93 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.CrossSigningIdentity = void 0; +var _logger = require("../logger"); +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** Manages the cross-signing keys for our own user. + */ +class CrossSigningIdentity { + constructor(olmMachine, outgoingRequestProcessor) { + this.olmMachine = olmMachine; + this.outgoingRequestProcessor = outgoingRequestProcessor; + } + + /** + * Initialise our cross-signing keys by creating new keys if they do not exist, and uploading to the server + */ + async bootstrapCrossSigning(opts) { + if (opts.setupNewCrossSigning) { + await this.resetCrossSigning(opts.authUploadDeviceSigningKeys); + return; + } + const olmDeviceStatus = await this.olmMachine.crossSigningStatus(); + const privateKeysInSecretStorage = false; // TODO + const olmDeviceHasKeys = olmDeviceStatus.hasMaster && olmDeviceStatus.hasUserSigning && olmDeviceStatus.hasSelfSigning; + + // Log all relevant state for easier parsing of debug logs. + _logger.logger.log("bootStrapCrossSigning: starting", { + setupNewCrossSigning: opts.setupNewCrossSigning, + olmDeviceHasMaster: olmDeviceStatus.hasMaster, + olmDeviceHasUserSigning: olmDeviceStatus.hasUserSigning, + olmDeviceHasSelfSigning: olmDeviceStatus.hasSelfSigning, + privateKeysInSecretStorage + }); + if (!olmDeviceHasKeys && !privateKeysInSecretStorage) { + _logger.logger.log("bootStrapCrossSigning: Cross-signing private keys not found locally or in secret storage, creating new keys"); + await this.resetCrossSigning(opts.authUploadDeviceSigningKeys); + } else if (olmDeviceHasKeys) { + _logger.logger.log("bootStrapCrossSigning: Olm device has private keys: exporting to secret storage"); + await this.exportCrossSigningKeysToStorage(); + } else if (privateKeysInSecretStorage) { + _logger.logger.log("bootStrapCrossSigning: Cross-signing private keys not found locally, but they are available " + "in secret storage, reading storage and caching locally"); + throw new Error("TODO"); + } + + // TODO: we might previously have bootstrapped cross-signing but not completed uploading the keys to the + // server -- in which case we should call OlmDevice.bootstrap_cross_signing. How do we know? + _logger.logger.log("bootStrapCrossSigning: complete"); + } + + /** Reset our cross-signing keys + * + * This method will: + * * Tell the OlmMachine to create new keys + * * Upload the new public keys and the device signature to the server + * * Upload the private keys to SSSS, if it is set up + */ + async resetCrossSigning(authUploadDeviceSigningKeys) { + const outgoingRequests = await this.olmMachine.bootstrapCrossSigning(true); + _logger.logger.log("bootStrapCrossSigning: publishing keys to server"); + for (const req of outgoingRequests) { + await this.outgoingRequestProcessor.makeOutgoingRequest(req, authUploadDeviceSigningKeys); + } + await this.exportCrossSigningKeysToStorage(); + } + + /** + * Extract the cross-signing keys from the olm machine and save them to secret storage, if it is configured + * + * (If secret storage is *not* configured, we assume that the export will happen when it is set up) + */ + async exportCrossSigningKeysToStorage() { + // TODO + } +} +exports.CrossSigningIdentity = CrossSigningIdentity;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/KeyClaimManager.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/KeyClaimManager.js new file mode 100644 index 0000000000..a560259504 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/KeyClaimManager.js @@ -0,0 +1,78 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.KeyClaimManager = void 0; +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * KeyClaimManager: linearises calls to OlmMachine.getMissingSessions to avoid races + * + * We have one of these per `RustCrypto` (and hence per `MatrixClient`). + */ +class KeyClaimManager { + constructor(olmMachine, outgoingRequestProcessor) { + this.olmMachine = olmMachine; + this.outgoingRequestProcessor = outgoingRequestProcessor; + _defineProperty(this, "currentClaimPromise", void 0); + _defineProperty(this, "stopped", false); + this.currentClaimPromise = Promise.resolve(); + } + + /** + * Tell the KeyClaimManager to immediately stop processing requests. + * + * Any further calls, and any still in the queue, will fail with an error. + */ + stop() { + this.stopped = true; + } + + /** + * Given a list of users, attempt to ensure that we have Olm Sessions active with each of their devices + * + * If we don't have an active olm session, we will claim a one-time key and start one. + * + * @param userList - list of userIDs to claim + */ + ensureSessionsForUsers(userList) { + // The Rust-SDK requires that we only have one getMissingSessions process in flight at once. This little dance + // ensures that, by only having one call to ensureSessionsForUsersInner active at once (and making them + // queue up in order). + const prom = this.currentClaimPromise.catch(() => { + // any errors in the previous claim will have been reported already, so there is nothing to do here. + // we just throw away the error and start anew. + }).then(() => this.ensureSessionsForUsersInner(userList)); + this.currentClaimPromise = prom; + return prom; + } + async ensureSessionsForUsersInner(userList) { + // bail out quickly if we've been stopped. + if (this.stopped) { + throw new Error(`Cannot ensure Olm sessions: shutting down`); + } + const claimRequest = await this.olmMachine.getMissingSessions(userList); + if (claimRequest) { + await this.outgoingRequestProcessor.makeOutgoingRequest(claimRequest); + } + } +} +exports.KeyClaimManager = KeyClaimManager;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/OutgoingRequestProcessor.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/OutgoingRequestProcessor.js new file mode 100644 index 0000000000..cbf10b51ba --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/OutgoingRequestProcessor.js @@ -0,0 +1,117 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.OutgoingRequestProcessor = void 0; +var _matrixSdkCryptoJs = require("@matrix-org/matrix-sdk-crypto-js"); +var _logger = require("../logger"); +var _httpApi = require("../http-api"); +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/** + * Common interface for all the request types returned by `OlmMachine.outgoingRequests`. + */ + +/** + * OutgoingRequestManager: turns `OutgoingRequest`s from the rust sdk into HTTP requests + * + * We have one of these per `RustCrypto` (and hence per `MatrixClient`), not that it does anything terribly complicated. + * It's responsible for: + * + * * holding the reference to the `MatrixHttpApi` + * * turning `OutgoingRequest`s from the rust backend into HTTP requests, and sending them + * * sending the results of such requests back to the rust backend. + */ +class OutgoingRequestProcessor { + constructor(olmMachine, http) { + this.olmMachine = olmMachine; + this.http = http; + } + async makeOutgoingRequest(msg, uiaCallback) { + let resp; + + /* refer https://docs.rs/matrix-sdk-crypto/0.6.0/matrix_sdk_crypto/requests/enum.OutgoingRequests.html + * for the complete list of request types + */ + if (msg instanceof _matrixSdkCryptoJs.KeysUploadRequest) { + resp = await this.rawJsonRequest(_httpApi.Method.Post, "/_matrix/client/v3/keys/upload", {}, msg.body); + } else if (msg instanceof _matrixSdkCryptoJs.KeysQueryRequest) { + resp = await this.rawJsonRequest(_httpApi.Method.Post, "/_matrix/client/v3/keys/query", {}, msg.body); + } else if (msg instanceof _matrixSdkCryptoJs.KeysClaimRequest) { + resp = await this.rawJsonRequest(_httpApi.Method.Post, "/_matrix/client/v3/keys/claim", {}, msg.body); + } else if (msg instanceof _matrixSdkCryptoJs.SignatureUploadRequest) { + resp = await this.rawJsonRequest(_httpApi.Method.Post, "/_matrix/client/v3/keys/signatures/upload", {}, msg.body); + } else if (msg instanceof _matrixSdkCryptoJs.KeysBackupRequest) { + resp = await this.rawJsonRequest(_httpApi.Method.Put, "/_matrix/client/v3/room_keys/keys", {}, msg.body); + } else if (msg instanceof _matrixSdkCryptoJs.ToDeviceRequest) { + const path = `/_matrix/client/v3/sendToDevice/${encodeURIComponent(msg.event_type)}/` + encodeURIComponent(msg.txn_id); + resp = await this.rawJsonRequest(_httpApi.Method.Put, path, {}, msg.body); + } else if (msg instanceof _matrixSdkCryptoJs.RoomMessageRequest) { + const path = `/_matrix/client/v3/room/${encodeURIComponent(msg.room_id)}/send/` + `${encodeURIComponent(msg.event_type)}/${encodeURIComponent(msg.txn_id)}`; + resp = await this.rawJsonRequest(_httpApi.Method.Put, path, {}, msg.body); + } else if (msg instanceof _matrixSdkCryptoJs.SigningKeysUploadRequest) { + resp = await this.makeRequestWithUIA(_httpApi.Method.Post, "/_matrix/client/v3/keys/device_signing/upload", {}, msg.body, uiaCallback); + } else { + _logger.logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg)); + resp = ""; + } + if (msg.id) { + await this.olmMachine.markRequestAsSent(msg.id, msg.type, resp); + } + } + async makeRequestWithUIA(method, path, queryParams, body, uiaCallback) { + if (!uiaCallback) { + return await this.rawJsonRequest(method, path, queryParams, body); + } + const parsedBody = JSON.parse(body); + const makeRequest = async auth => { + const newBody = _objectSpread(_objectSpread({}, parsedBody), {}, { + auth + }); + const resp = await this.rawJsonRequest(method, path, queryParams, JSON.stringify(newBody)); + return JSON.parse(resp); + }; + const resp = await uiaCallback(makeRequest); + return JSON.stringify(resp); + } + async rawJsonRequest(method, path, queryParams, body) { + const opts = { + // inhibit the JSON stringification and parsing within HttpApi. + json: false, + // nevertheless, we are sending, and accept, JSON. + headers: { + "Content-Type": "application/json", + "Accept": "application/json" + }, + // we use the full prefix + prefix: "" + }; + try { + const response = await this.http.authedRequest(method, path, queryParams, body, opts); + _logger.logger.info(`rust-crypto: successfully made HTTP request: ${method} ${path}`); + return response; + } catch (e) { + _logger.logger.warn(`rust-crypto: error making HTTP request: ${method} ${path}: ${e}`); + throw e; + } + } +} +exports.OutgoingRequestProcessor = OutgoingRequestProcessor;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/RoomEncryptor.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/RoomEncryptor.js new file mode 100644 index 0000000000..48a8665761 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/RoomEncryptor.js @@ -0,0 +1,124 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.RoomEncryptor = void 0; +var _matrixSdkCryptoJs = require("@matrix-org/matrix-sdk-crypto-js"); +var _event = require("../@types/event"); +var _logger = require("../logger"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/** + * RoomEncryptor: responsible for encrypting messages to a given room + */ +class RoomEncryptor { + /** + * @param olmMachine - The rust-sdk's OlmMachine + * @param keyClaimManager - Our KeyClaimManager, which manages the queue of one-time-key claim requests + * @param room - The room we want to encrypt for + * @param encryptionSettings - body of the m.room.encryption event currently in force in this room + */ + constructor(olmMachine, keyClaimManager, outgoingRequestProcessor, room, encryptionSettings) { + this.olmMachine = olmMachine; + this.keyClaimManager = keyClaimManager; + this.outgoingRequestProcessor = outgoingRequestProcessor; + this.room = room; + this.encryptionSettings = encryptionSettings; + _defineProperty(this, "prefixedLogger", void 0); + this.prefixedLogger = _logger.logger.withPrefix(`[${room.roomId} encryption]`); + } + + /** + * Handle a new `m.room.encryption` event in this room + * + * @param config - The content of the encryption event + */ + onCryptoEvent(config) { + if (JSON.stringify(this.encryptionSettings) != JSON.stringify(config)) { + this.prefixedLogger.error(`Ignoring m.room.encryption event which requests a change of config`); + } + } + + /** + * Handle a new `m.room.member` event in this room + * + * @param member - new membership state + */ + onRoomMembership(member) { + this.prefixedLogger.debug(`${member.membership} event for ${member.userId}`); + if (member.membership == "join" || member.membership == "invite" && this.room.shouldEncryptForInvitedMembers()) { + // make sure we are tracking the deviceList for this user + this.prefixedLogger.debug(`starting to track devices for: ${member.userId}`); + this.olmMachine.updateTrackedUsers([new _matrixSdkCryptoJs.UserId(member.userId)]); + } + + // TODO: handle leaves (including our own) + } + + /** + * Prepare to encrypt events in this room. + * + * This ensures that we have a megolm session ready to use and that we have shared its key with all the devices + * in the room. + */ + async ensureEncryptionSession() { + if (this.encryptionSettings.algorithm !== "m.megolm.v1.aes-sha2") { + throw new Error(`Cannot encrypt in ${this.room.roomId} for unsupported algorithm '${this.encryptionSettings.algorithm}'`); + } + const members = await this.room.getEncryptionTargetMembers(); + this.prefixedLogger.debug(`Encrypting for users (shouldEncryptForInvitedMembers: ${this.room.shouldEncryptForInvitedMembers()}):`, members.map(u => `${u.userId} (${u.membership})`)); + const userList = members.map(u => new _matrixSdkCryptoJs.UserId(u.userId)); + await this.keyClaimManager.ensureSessionsForUsers(userList); + this.prefixedLogger.debug("Sessions for users are ready; now sharing room key"); + const rustEncryptionSettings = new _matrixSdkCryptoJs.EncryptionSettings(); + /* FIXME historyVisibility, rotation, etc */ + + const shareMessages = await this.olmMachine.shareRoomKey(new _matrixSdkCryptoJs.RoomId(this.room.roomId), userList, rustEncryptionSettings); + if (shareMessages) { + for (const m of shareMessages) { + await this.outgoingRequestProcessor.makeOutgoingRequest(m); + } + } + } + + /** + * Discard any existing group session for this room + */ + async forceDiscardSession() { + const r = await this.olmMachine.invalidateGroupSession(new _matrixSdkCryptoJs.RoomId(this.room.roomId)); + if (r) { + this.prefixedLogger.info("Discarded existing group session"); + } + } + + /** + * Encrypt an event for this room + * + * This will ensure that we have a megolm session for this room, share it with the devices in the room, and + * then encrypt the event using the session. + * + * @param event - Event to be encrypted. + */ + async encryptEvent(event) { + await this.ensureEncryptionSession(); + const encryptedContent = await this.olmMachine.encryptRoomEvent(new _matrixSdkCryptoJs.RoomId(this.room.roomId), event.getType(), JSON.stringify(event.getContent())); + event.makeEncrypted(_event.EventType.RoomMessageEncrypted, JSON.parse(encryptedContent), this.olmMachine.identityKeys.curve25519.toBase64(), this.olmMachine.identityKeys.ed25519.toBase64()); + } +} +exports.RoomEncryptor = RoomEncryptor;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/browserify-index.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/browserify-index.js new file mode 100644 index 0000000000..5876cbad6a --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/browserify-index.js @@ -0,0 +1,31 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.initRustCrypto = initRustCrypto; +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* This file replaces rust-crypto/index.ts when the js-sdk is being built for browserify. + * + * It is a stub, so that we do not import the whole of the base64'ed wasm artifact into the browserify bundle. + * It deliberately does nothing except raise an exception. + */ + +async function initRustCrypto(_http, _userId, _deviceId) { + throw new Error("Rust crypto is not supported under browserify."); +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/constants.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/constants.js new file mode 100644 index 0000000000..cd1599ff0b --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/constants.js @@ -0,0 +1,25 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.RUST_SDK_STORE_PREFIX = void 0; +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** The prefix used on indexeddbs created by rust-crypto */ +const RUST_SDK_STORE_PREFIX = "matrix-js-sdk"; +exports.RUST_SDK_STORE_PREFIX = RUST_SDK_STORE_PREFIX;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/device-converter.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/device-converter.js new file mode 100644 index 0000000000..83623fca0a --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/device-converter.js @@ -0,0 +1,121 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.deviceKeysToDeviceMap = deviceKeysToDeviceMap; +exports.downloadDeviceToJsDevice = downloadDeviceToJsDevice; +exports.rustDeviceToJsDevice = rustDeviceToJsDevice; +var RustSdkCryptoJs = _interopRequireWildcard(require("@matrix-org/matrix-sdk-crypto-js")); +var _device = require("../models/device"); +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Convert a {@link RustSdkCryptoJs.Device} to a {@link Device} + * @param device - Rust Sdk device + * @param userId - owner of the device + */ +function rustDeviceToJsDevice(device, userId) { + // Copy rust device keys to Device.keys + const keys = new Map(); + for (const [keyId, key] of device.keys.entries()) { + keys.set(keyId.toString(), key.toBase64()); + } + + // Compute verified from device state + let verified = _device.DeviceVerification.Unverified; + if (device.isBlacklisted()) { + verified = _device.DeviceVerification.Blocked; + } else if (device.isVerified()) { + verified = _device.DeviceVerification.Verified; + } + + // Convert rust signatures to Device.signatures + const signatures = new Map(); + const mayBeSignatureMap = device.signatures.get(userId); + if (mayBeSignatureMap) { + const convertedSignatures = new Map(); + // Convert maybeSignatures map to a Map<string, string> + for (const [key, value] of mayBeSignatureMap.entries()) { + if (value.isValid() && value.signature) { + convertedSignatures.set(key, value.signature.toBase64()); + } + } + signatures.set(userId.toString(), convertedSignatures); + } + + // Convert rust algorithms to algorithms + const rustAlgorithms = device.algorithms; + // Use set to ensure that algorithms are not duplicated + const algorithms = new Set(); + rustAlgorithms.forEach(algorithm => { + switch (algorithm) { + case RustSdkCryptoJs.EncryptionAlgorithm.MegolmV1AesSha2: + algorithms.add("m.megolm.v1.aes-sha2"); + break; + case RustSdkCryptoJs.EncryptionAlgorithm.OlmV1Curve25519AesSha2: + default: + algorithms.add("m.olm.v1.curve25519-aes-sha2"); + break; + } + }); + return new _device.Device({ + deviceId: device.deviceId.toString(), + userId: userId.toString(), + keys, + algorithms: Array.from(algorithms), + verified, + signatures, + displayName: device.displayName + }); +} + +/** + * Convert {@link DeviceKeys} from `/keys/query` request to a `Map<string, Device>` + * @param deviceKeys - Device keys object to convert + */ +function deviceKeysToDeviceMap(deviceKeys) { + return new Map(Object.entries(deviceKeys).map(([deviceId, device]) => [deviceId, downloadDeviceToJsDevice(device)])); +} + +// Device from `/keys/query` request + +/** + * Convert `/keys/query` {@link QueryDevice} device to {@link Device} + * @param device - Device from `/keys/query` request + */ +function downloadDeviceToJsDevice(device) { + const keys = new Map(Object.entries(device.keys)); + const displayName = device.unsigned?.device_display_name; + const signatures = new Map(); + if (device.signatures) { + for (const userId in device.signatures) { + signatures.set(userId, new Map(Object.entries(device.signatures[userId]))); + } + } + return new _device.Device({ + deviceId: device.device_id, + userId: device.user_id, + keys, + algorithms: device.algorithms, + verified: _device.DeviceVerification.Unverified, + signatures, + displayName + }); +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/index.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/index.js new file mode 100644 index 0000000000..2ba47c5f40 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/index.js @@ -0,0 +1,54 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.initRustCrypto = initRustCrypto; +var RustSdkCryptoJs = _interopRequireWildcard(require("@matrix-org/matrix-sdk-crypto-js")); +var _rustCrypto = require("./rust-crypto"); +var _logger = require("../logger"); +var _constants = require("./constants"); +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Create a new `RustCrypto` implementation + * + * @param http - Low-level HTTP interface: used to make outgoing requests required by the rust SDK. + * We expect it to set the access token, etc. + * @param userId - The local user's User ID. + * @param deviceId - The local user's Device ID. + * @param secretStorage - Interface to server-side secret storage. + */ +async function initRustCrypto(http, userId, deviceId, secretStorage) { + // initialise the rust matrix-sdk-crypto-js, if it hasn't already been done + await RustSdkCryptoJs.initAsync(); + + // enable tracing in the rust-sdk + new RustSdkCryptoJs.Tracing(RustSdkCryptoJs.LoggerLevel.Trace).turnOn(); + const u = new RustSdkCryptoJs.UserId(userId); + const d = new RustSdkCryptoJs.DeviceId(deviceId); + _logger.logger.info("Init OlmMachine"); + + // TODO: use the pickle key for the passphrase + const olmMachine = await RustSdkCryptoJs.OlmMachine.initialize(u, d, _constants.RUST_SDK_STORE_PREFIX, "test pass"); + const rustCrypto = new _rustCrypto.RustCrypto(olmMachine, http, userId, deviceId, secretStorage); + await olmMachine.registerRoomKeyUpdatedCallback(sessions => rustCrypto.onRoomKeysUpdated(sessions)); + _logger.logger.info("Completed rust crypto-sdk setup"); + return rustCrypto; +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/rust-crypto.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/rust-crypto.js new file mode 100644 index 0000000000..b55d2fb64b --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/rust-crypto.js @@ -0,0 +1,574 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.RustCrypto = void 0; +var RustSdkCryptoJs = _interopRequireWildcard(require("@matrix-org/matrix-sdk-crypto-js")); +var _logger = require("../logger"); +var _httpApi = require("../http-api"); +var _CrossSigning = require("../crypto/CrossSigning"); +var _RoomEncryptor = require("./RoomEncryptor"); +var _OutgoingRequestProcessor = require("./OutgoingRequestProcessor"); +var _KeyClaimManager = require("./KeyClaimManager"); +var _utils = require("../utils"); +var _cryptoApi = require("../crypto-api"); +var _deviceConverter = require("./device-converter"); +var _api = require("../crypto/api"); +var _CrossSigningIdentity = require("./CrossSigningIdentity"); +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2022-2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/** + * An implementation of {@link CryptoBackend} using the Rust matrix-sdk-crypto. + */ +class RustCrypto { + constructor( /** The `OlmMachine` from the underlying rust crypto sdk. */ + olmMachine, + /** + * Low-level HTTP interface: used to make outgoing requests required by the rust SDK. + * + * We expect it to set the access token, etc. + */ + http, /** The local user's User ID. */ + _userId, /** The local user's Device ID. */ + _deviceId, /** Interface to server-side secret storage */ + _secretStorage) { + this.olmMachine = olmMachine; + this.http = http; + _defineProperty(this, "globalErrorOnUnknownDevices", false); + _defineProperty(this, "_trustCrossSignedDevices", true); + /** whether {@link stop} has been called */ + _defineProperty(this, "stopped", false); + /** whether {@link outgoingRequestLoop} is currently running */ + _defineProperty(this, "outgoingRequestLoopRunning", false); + /** mapping of roomId → encryptor class */ + _defineProperty(this, "roomEncryptors", {}); + _defineProperty(this, "eventDecryptor", void 0); + _defineProperty(this, "keyClaimManager", void 0); + _defineProperty(this, "outgoingRequestProcessor", void 0); + _defineProperty(this, "crossSigningIdentity", void 0); + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // + // CryptoApi implementation + // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + _defineProperty(this, "globalBlacklistUnverifiedDevices", false); + this.outgoingRequestProcessor = new _OutgoingRequestProcessor.OutgoingRequestProcessor(olmMachine, http); + this.keyClaimManager = new _KeyClaimManager.KeyClaimManager(olmMachine, this.outgoingRequestProcessor); + this.eventDecryptor = new EventDecryptor(olmMachine); + this.crossSigningIdentity = new _CrossSigningIdentity.CrossSigningIdentity(olmMachine, this.outgoingRequestProcessor); + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // + // CryptoBackend implementation + // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + stop() { + // stop() may be called multiple times, but attempting to close() the OlmMachine twice + // will cause an error. + if (this.stopped) { + return; + } + this.stopped = true; + this.keyClaimManager.stop(); + + // make sure we close() the OlmMachine; doing so means that all the Rust objects will be + // cleaned up; in particular, the indexeddb connections will be closed, which means they + // can then be deleted. + this.olmMachine.close(); + } + async encryptEvent(event, _room) { + const roomId = event.getRoomId(); + const encryptor = this.roomEncryptors[roomId]; + if (!encryptor) { + throw new Error(`Cannot encrypt event in unconfigured room ${roomId}`); + } + await encryptor.encryptEvent(event); + } + async decryptEvent(event) { + const roomId = event.getRoomId(); + if (!roomId) { + // presumably, a to-device message. These are normally decrypted in preprocessToDeviceMessages + // so the fact it has come back here suggests that decryption failed. + // + // once we drop support for the libolm crypto implementation, we can stop passing to-device messages + // through decryptEvent and hence get rid of this case. + throw new Error("to-device event was not decrypted in preprocessToDeviceMessages"); + } + return await this.eventDecryptor.attemptEventDecryption(event); + } + getEventEncryptionInfo(event) { + // TODO: make this work properly. Or better, replace it. + + const ret = {}; + ret.senderKey = event.getSenderKey() ?? undefined; + ret.algorithm = event.getWireContent().algorithm; + if (!ret.senderKey || !ret.algorithm) { + ret.encrypted = false; + return ret; + } + ret.encrypted = true; + ret.authenticated = true; + ret.mismatchedSender = true; + return ret; + } + checkUserTrust(userId) { + // TODO + return new _CrossSigning.UserTrustLevel(false, false, false); + } + + /** + * Finds a DM verification request that is already in progress for the given room id + * + * @param roomId - the room to use for verification + * + * @returns the VerificationRequest that is in progress, if any + */ + findVerificationRequestDMInProgress(roomId) { + // TODO + return; + } + + /** + * Get the cross signing information for a given user. + * + * The cross-signing API is currently UNSTABLE and may change without notice. + * + * @param userId - the user ID to get the cross-signing info for. + * + * @returns the cross signing information for the user. + */ + getStoredCrossSigningForUser(userId) { + // TODO + return null; + } + async userHasCrossSigningKeys() { + // TODO + return false; + } + prepareToEncrypt(room) { + const encryptor = this.roomEncryptors[room.roomId]; + if (encryptor) { + encryptor.ensureEncryptionSession(); + } + } + forceDiscardSession(roomId) { + return this.roomEncryptors[roomId]?.forceDiscardSession(); + } + async exportRoomKeys() { + // TODO + return []; + } + + /** + * Get the device information for the given list of users. + * + * @param userIds - The users to fetch. + * @param downloadUncached - If true, download the device list for users whose device list we are not + * currently tracking. Defaults to false, in which case such users will not appear at all in the result map. + * + * @returns A map `{@link DeviceMap}`. + */ + async getUserDeviceInfo(userIds, downloadUncached = false) { + const deviceMapByUserId = new Map(); + const rustTrackedUsers = await this.olmMachine.trackedUsers(); + + // Convert RustSdkCryptoJs.UserId to a `Set<string>` + const trackedUsers = new Set(); + rustTrackedUsers.forEach(rustUserId => trackedUsers.add(rustUserId.toString())); + + // Keep untracked user to download their keys after + const untrackedUsers = new Set(); + for (const userId of userIds) { + // if this is a tracked user, we can just fetch the device list from the rust-sdk + // (NB: this is probably ok even if we race with a leave event such that we stop tracking the user's + // devices: the rust-sdk will return the last-known device list, which will be good enough.) + if (trackedUsers.has(userId)) { + deviceMapByUserId.set(userId, await this.getUserDevices(userId)); + } else { + untrackedUsers.add(userId); + } + } + + // for any users whose device lists we are not tracking, fall back to downloading the device list + // over HTTP. + if (downloadUncached && untrackedUsers.size >= 1) { + const queryResult = await this.downloadDeviceList(untrackedUsers); + Object.entries(queryResult.device_keys).forEach(([userId, deviceKeys]) => deviceMapByUserId.set(userId, (0, _deviceConverter.deviceKeysToDeviceMap)(deviceKeys))); + } + return deviceMapByUserId; + } + + /** + * Get the device list for the given user from the olm machine + * @param userId - Rust SDK UserId + */ + async getUserDevices(userId) { + const rustUserId = new RustSdkCryptoJs.UserId(userId); + const devices = await this.olmMachine.getUserDevices(rustUserId); + return new Map(devices.devices().map(device => [device.deviceId.toString(), (0, _deviceConverter.rustDeviceToJsDevice)(device, rustUserId)])); + } + + /** + * Download the given user keys by calling `/keys/query` request + * @param untrackedUsers - download keys of these users + */ + async downloadDeviceList(untrackedUsers) { + const queryBody = { + device_keys: {} + }; + untrackedUsers.forEach(user => queryBody.device_keys[user] = []); + return await this.http.authedRequest(_httpApi.Method.Post, "/_matrix/client/v3/keys/query", undefined, queryBody, { + prefix: "" + }); + } + + /** + * Implementation of {@link CryptoApi#getTrustCrossSignedDevices}. + */ + getTrustCrossSignedDevices() { + return this._trustCrossSignedDevices; + } + + /** + * Implementation of {@link CryptoApi#setTrustCrossSignedDevices}. + */ + setTrustCrossSignedDevices(val) { + this._trustCrossSignedDevices = val; + // TODO: legacy crypto goes through the list of known devices and emits DeviceVerificationChanged + // events. Maybe we need to do the same? + } + + /** + * Implementation of {@link CryptoApi#getDeviceVerificationStatus}. + */ + async getDeviceVerificationStatus(userId, deviceId) { + const device = await this.olmMachine.getDevice(new RustSdkCryptoJs.UserId(userId), new RustSdkCryptoJs.DeviceId(deviceId)); + if (!device) return null; + return new _cryptoApi.DeviceVerificationStatus({ + signedByOwner: device.isCrossSignedByOwner(), + crossSigningVerified: device.isCrossSigningTrusted(), + localVerified: device.isLocallyTrusted(), + trustCrossSignedDevices: this._trustCrossSignedDevices + }); + } + + /** + * Implementation of {@link CryptoApi#isCrossSigningReady} + */ + async isCrossSigningReady() { + return false; + } + + /** + * Implementation of {@link CryptoApi#getCrossSigningKeyId} + */ + async getCrossSigningKeyId(type = _api.CrossSigningKey.Master) { + // TODO + return null; + } + + /** + * Implementation of {@link CryptoApi#boostrapCrossSigning} + */ + async bootstrapCrossSigning(opts) { + await this.crossSigningIdentity.bootstrapCrossSigning(opts); + } + + /** + * Implementation of {@link CryptoApi#isSecretStorageReady} + */ + async isSecretStorageReady() { + return false; + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // + // SyncCryptoCallbacks implementation + // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * Apply sync changes to the olm machine + * @param events - the received to-device messages + * @param oneTimeKeysCounts - the received one time key counts + * @param unusedFallbackKeys - the received unused fallback keys + * @param devices - the received device list updates + * @returns A list of preprocessed to-device messages. + */ + async receiveSyncChanges({ + events, + oneTimeKeysCounts = new Map(), + unusedFallbackKeys, + devices = new RustSdkCryptoJs.DeviceLists() + }) { + const result = await this.olmMachine.receiveSyncChanges(events ? JSON.stringify(events) : "[]", devices, oneTimeKeysCounts, unusedFallbackKeys); + + // receiveSyncChanges returns a JSON-encoded list of decrypted to-device messages. + return JSON.parse(result); + } + + /** called by the sync loop to preprocess incoming to-device messages + * + * @param events - the received to-device messages + * @returns A list of preprocessed to-device messages. + */ + preprocessToDeviceMessages(events) { + // send the received to-device messages into receiveSyncChanges. We have no info on device-list changes, + // one-time-keys, or fallback keys, so just pass empty data. + return this.receiveSyncChanges({ + events + }); + } + + /** called by the sync loop to process one time key counts and unused fallback keys + * + * @param oneTimeKeysCounts - the received one time key counts + * @param unusedFallbackKeys - the received unused fallback keys + */ + async processKeyCounts(oneTimeKeysCounts, unusedFallbackKeys) { + const mapOneTimeKeysCount = oneTimeKeysCounts && new Map(Object.entries(oneTimeKeysCounts)); + const setUnusedFallbackKeys = unusedFallbackKeys && new Set(unusedFallbackKeys); + if (mapOneTimeKeysCount !== undefined || setUnusedFallbackKeys !== undefined) { + await this.receiveSyncChanges({ + oneTimeKeysCounts: mapOneTimeKeysCount, + unusedFallbackKeys: setUnusedFallbackKeys + }); + } + } + + /** called by the sync loop to process the notification that device lists have + * been changed. + * + * @param deviceLists - device_lists field from /sync + */ + async processDeviceLists(deviceLists) { + const devices = new RustSdkCryptoJs.DeviceLists(deviceLists.changed?.map(userId => new RustSdkCryptoJs.UserId(userId)), deviceLists.left?.map(userId => new RustSdkCryptoJs.UserId(userId))); + await this.receiveSyncChanges({ + devices + }); + } + + /** called by the sync loop on m.room.encrypted events + * + * @param room - in which the event was received + * @param event - encryption event to be processed + */ + async onCryptoEvent(room, event) { + const config = event.getContent(); + const existingEncryptor = this.roomEncryptors[room.roomId]; + if (existingEncryptor) { + existingEncryptor.onCryptoEvent(config); + } else { + this.roomEncryptors[room.roomId] = new _RoomEncryptor.RoomEncryptor(this.olmMachine, this.keyClaimManager, this.outgoingRequestProcessor, room, config); + } + + // start tracking devices for any users already known to be in this room. + const members = await room.getEncryptionTargetMembers(); + _logger.logger.debug(`[${room.roomId} encryption] starting to track devices for: `, members.map(u => `${u.userId} (${u.membership})`)); + await this.olmMachine.updateTrackedUsers(members.map(u => new RustSdkCryptoJs.UserId(u.userId))); + } + + /** called by the sync loop after processing each sync. + * + * TODO: figure out something equivalent for sliding sync. + * + * @param syncState - information on the completed sync. + */ + onSyncCompleted(syncState) { + // Processing the /sync may have produced new outgoing requests which need sending, so kick off the outgoing + // request loop, if it's not already running. + this.outgoingRequestLoop(); + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // + // Other public functions + // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + /** called by the MatrixClient on a room membership event + * + * @param event - The matrix event which caused this event to fire. + * @param member - The member whose RoomMember.membership changed. + * @param oldMembership - The previous membership state. Null if it's a new member. + */ + onRoomMembership(event, member, oldMembership) { + const enc = this.roomEncryptors[event.getRoomId()]; + if (!enc) { + // not encrypting in this room + return; + } + enc.onRoomMembership(member); + } + + /** Callback for OlmMachine.registerRoomKeyUpdatedCallback + * + * Called by the rust-sdk whenever there is an update to (megolm) room keys. We + * check if we have any events waiting for the given keys, and schedule them for + * a decryption retry if so. + * + * @param keys - details of the updated keys + */ + async onRoomKeysUpdated(keys) { + for (const key of keys) { + this.onRoomKeyUpdated(key); + } + } + onRoomKeyUpdated(key) { + _logger.logger.debug(`Got update for session ${key.senderKey.toBase64()}|${key.sessionId} in ${key.roomId.toString()}`); + const pendingList = this.eventDecryptor.getEventsPendingRoomKey(key); + if (pendingList.length === 0) return; + _logger.logger.debug("Retrying decryption on events:", pendingList.map(e => `${e.getId()}`)); + + // Have another go at decrypting events with this key. + // + // We don't want to end up blocking the callback from Rust, which could otherwise end up dropping updates, + // so we don't wait for the decryption to complete. In any case, there is no need to wait: + // MatrixEvent.attemptDecryption ensures that there is only one decryption attempt happening at once, + // and deduplicates repeated attempts for the same event. + for (const ev of pendingList) { + ev.attemptDecryption(this, { + isRetry: true + }).catch(_e => { + _logger.logger.info(`Still unable to decrypt event ${ev.getId()} after receiving key`); + }); + } + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // + // Outgoing requests + // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + async outgoingRequestLoop() { + if (this.outgoingRequestLoopRunning) { + return; + } + this.outgoingRequestLoopRunning = true; + try { + while (!this.stopped) { + const outgoingRequests = await this.olmMachine.outgoingRequests(); + if (outgoingRequests.length == 0 || this.stopped) { + // no more messages to send (or we have been told to stop): exit the loop + return; + } + for (const msg of outgoingRequests) { + await this.outgoingRequestProcessor.makeOutgoingRequest(msg); + } + } + } catch (e) { + _logger.logger.error("Error processing outgoing-message requests from rust crypto-sdk", e); + } finally { + this.outgoingRequestLoopRunning = false; + } + } +} +exports.RustCrypto = RustCrypto; +class EventDecryptor { + constructor(olmMachine) { + this.olmMachine = olmMachine; + /** + * Events which we couldn't decrypt due to unknown sessions / indexes. + * + * Map from senderKey to sessionId to Set of MatrixEvents + */ + _defineProperty(this, "eventsPendingKey", new _utils.MapWithDefault(() => new _utils.MapWithDefault(() => new Set()))); + } + async attemptEventDecryption(event) { + _logger.logger.info("Attempting decryption of event", event); + // add the event to the pending list *before* attempting to decrypt. + // then, if the key turns up while decryption is in progress (and + // decryption fails), we will schedule a retry. + // (fixes https://github.com/vector-im/element-web/issues/5001) + this.addEventToPendingList(event); + const res = await this.olmMachine.decryptRoomEvent(JSON.stringify({ + event_id: event.getId(), + type: event.getWireType(), + sender: event.getSender(), + state_key: event.getStateKey(), + content: event.getWireContent(), + origin_server_ts: event.getTs() + }), new RustSdkCryptoJs.RoomId(event.getRoomId())); + + // Success. We can remove the event from the pending list, if + // that hasn't already happened. + this.removeEventFromPendingList(event); + return { + clearEvent: JSON.parse(res.event), + claimedEd25519Key: res.senderClaimedEd25519Key, + senderCurve25519Key: res.senderCurve25519Key, + forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain + }; + } + + /** + * Look for events which are waiting for a given megolm session + * + * Returns a list of events which were encrypted by `session` and could not be decrypted + * + * @param session - + */ + getEventsPendingRoomKey(session) { + const senderPendingEvents = this.eventsPendingKey.get(session.senderKey.toBase64()); + if (!senderPendingEvents) return []; + const sessionPendingEvents = senderPendingEvents.get(session.sessionId); + if (!sessionPendingEvents) return []; + const roomId = session.roomId.toString(); + return [...sessionPendingEvents].filter(ev => ev.getRoomId() === roomId); + } + + /** + * Add an event to the list of those awaiting their session keys. + */ + addEventToPendingList(event) { + const content = event.getWireContent(); + const senderKey = content.sender_key; + const sessionId = content.session_id; + const senderPendingEvents = this.eventsPendingKey.getOrCreate(senderKey); + const sessionPendingEvents = senderPendingEvents.getOrCreate(sessionId); + sessionPendingEvents.add(event); + } + + /** + * Remove an event from the list of those awaiting their session keys. + */ + removeEventFromPendingList(event) { + const content = event.getWireContent(); + const senderKey = content.sender_key; + const sessionId = content.session_id; + const senderPendingEvents = this.eventsPendingKey.get(senderKey); + if (!senderPendingEvents) return; + const sessionPendingEvents = senderPendingEvents.get(sessionId); + if (!sessionPendingEvents) return; + sessionPendingEvents.delete(event); + + // also clean up the higher-level maps if they are now empty + if (sessionPendingEvents.size === 0) { + senderPendingEvents.delete(sessionId); + if (senderPendingEvents.size === 0) { + this.eventsPendingKey.delete(senderKey); + } + } + } +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/scheduler.js b/comm/chat/protocols/matrix/lib/matrix-sdk/scheduler.js new file mode 100644 index 0000000000..424ca31e2e --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/scheduler.js @@ -0,0 +1,314 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.MatrixScheduler = void 0; +var _logger = require("./logger"); +var _event = require("./@types/event"); +var _utils = require("./utils"); +var _httpApi = require("./http-api"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2015 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ /** + * This is an internal module which manages queuing, scheduling and retrying + * of requests. + */ +const DEBUG = false; // set true to enable console logging. + +/** + * The function to invoke to process (send) events in the queue. + * @param event - The event to send. + * @returns Resolved/rejected depending on the outcome of the request. + */ + +// eslint-disable-next-line camelcase +class MatrixScheduler { + /** + * Retries events up to 4 times using exponential backoff. This produces wait + * times of 2, 4, 8, and 16 seconds (30s total) after which we give up. If the + * failure was due to a rate limited request, the time specified in the error is + * waited before being retried. + * @param attempts - Number of attempts that have been made, including the one that just failed (ie. starting at 1) + * @see retryAlgorithm + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + static RETRY_BACKOFF_RATELIMIT(event, attempts, err) { + if (err.httpStatus === 400 || err.httpStatus === 403 || err.httpStatus === 401) { + // client error; no amount of retrying with save you now. + return -1; + } + if (err instanceof _httpApi.ConnectionError) { + return -1; + } + + // if event that we are trying to send is too large in any way then retrying won't help + if (err.name === "M_TOO_LARGE") { + return -1; + } + if (err.name === "M_LIMIT_EXCEEDED") { + const waitTime = err.data.retry_after_ms; + if (waitTime > 0) { + return waitTime; + } + } + if (attempts > 4) { + return -1; // give up + } + + return 1000 * Math.pow(2, attempts); + } + + /** + * Queues `m.room.message` events and lets other events continue + * concurrently. + * @see queueAlgorithm + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + static QUEUE_MESSAGES(event) { + // enqueue messages or events that associate with another event (redactions and relations) + if (event.getType() === _event.EventType.RoomMessage || event.hasAssociation()) { + // put these events in the 'message' queue. + return "message"; + } + // allow all other events continue concurrently. + return null; + } + + // queueName: [{ + // event: MatrixEvent, // event to send + // defer: Deferred, // defer to resolve/reject at the END of the retries + // attempts: Number // number of times we've called processFn + // }, ...] + + /** + * Construct a scheduler for Matrix. Requires + * {@link MatrixScheduler#setProcessFunction} to be provided + * with a way of processing events. + * @param retryAlgorithm - Optional. The retry + * algorithm to apply when determining when to try to send an event again. + * Defaults to {@link MatrixScheduler.RETRY_BACKOFF_RATELIMIT}. + * @param queueAlgorithm - Optional. The queuing + * algorithm to apply when determining which events should be sent before the + * given event. Defaults to {@link MatrixScheduler.QUEUE_MESSAGES}. + */ + constructor( + /** + * The retry algorithm to apply when retrying events. To stop retrying, return + * `-1`. If this event was part of a queue, it will be removed from + * the queue. + * @param event - The event being retried. + * @param attempts - The number of failed attempts. This will always be \>= 1. + * @param err - The most recent error message received when trying + * to send this event. + * @returns The number of milliseconds to wait before trying again. If + * this is 0, the request will be immediately retried. If this is + * `-1`, the event will be marked as + * {@link EventStatus.NOT_SENT} and will not be retried. + */ + retryAlgorithm = MatrixScheduler.RETRY_BACKOFF_RATELIMIT, + /** + * The queuing algorithm to apply to events. This function must be idempotent as + * it may be called multiple times with the same event. All queues created are + * serviced in a FIFO manner. To send the event ASAP, return `null` + * which will not put this event in a queue. Events that fail to send that form + * part of a queue will be removed from the queue and the next event in the + * queue will be sent. + * @param event - The event to be sent. + * @returns The name of the queue to put the event into. If a queue with + * this name does not exist, it will be created. If this is `null`, + * the event is not put into a queue and will be sent concurrently. + */ + queueAlgorithm = MatrixScheduler.QUEUE_MESSAGES) { + this.retryAlgorithm = retryAlgorithm; + this.queueAlgorithm = queueAlgorithm; + _defineProperty(this, "queues", {}); + _defineProperty(this, "activeQueues", []); + _defineProperty(this, "procFn", null); + _defineProperty(this, "processQueue", queueName => { + // get head of queue + const obj = this.peekNextEvent(queueName); + if (!obj) { + this.disableQueue(queueName); + return; + } + debuglog("Queue '%s' has %s pending events", queueName, this.queues[queueName].length); + // fire the process function and if it resolves, resolve the deferred. Else + // invoke the retry algorithm. + + // First wait for a resolved promise, so the resolve handlers for + // the deferred of the previously sent event can run. + // This way enqueued relations/redactions to enqueued events can receive + // the remove id of their target before being sent. + Promise.resolve().then(() => { + return this.procFn(obj.event); + }).then(res => { + // remove this from the queue + this.removeNextEvent(queueName); + debuglog("Queue '%s' sent event %s", queueName, obj.event.getId()); + obj.defer.resolve(res); + // keep processing + this.processQueue(queueName); + }, err => { + obj.attempts += 1; + // ask the retry algorithm when/if we should try again + const waitTimeMs = this.retryAlgorithm(obj.event, obj.attempts, err); + debuglog("retry(%s) err=%s event_id=%s waitTime=%s", obj.attempts, err, obj.event.getId(), waitTimeMs); + if (waitTimeMs === -1) { + // give up (you quitter!) + _logger.logger.info("Queue '%s' giving up on event %s", queueName, obj.event.getId()); + // remove this from the queue + this.clearQueue(queueName, err); + } else { + setTimeout(this.processQueue, waitTimeMs, queueName); + } + }); + }); + } + + /** + * Retrieve a queue based on an event. The event provided does not need to be in + * the queue. + * @param event - An event to get the queue for. + * @returns A shallow copy of events in the queue or null. + * Modifying this array will not modify the list itself. Modifying events in + * this array <i>will</i> modify the underlying event in the queue. + * @see MatrixScheduler.removeEventFromQueue To remove an event from the queue. + */ + getQueueForEvent(event) { + const name = this.queueAlgorithm(event); + if (!name || !this.queues[name]) { + return null; + } + return this.queues[name].map(function (obj) { + return obj.event; + }); + } + + /** + * Remove this event from the queue. The event is equal to another event if they + * have the same ID returned from event.getId(). + * @param event - The event to remove. + * @returns True if this event was removed. + */ + removeEventFromQueue(event) { + const name = this.queueAlgorithm(event); + if (!name || !this.queues[name]) { + return false; + } + let removed = false; + (0, _utils.removeElement)(this.queues[name], element => { + if (element.event.getId() === event.getId()) { + // XXX we should probably reject the promise? + // https://github.com/matrix-org/matrix-js-sdk/issues/496 + removed = true; + return true; + } + return false; + }); + return removed; + } + + /** + * Set the process function. Required for events in the queue to be processed. + * If set after events have been added to the queue, this will immediately start + * processing them. + * @param fn - The function that can process events + * in the queue. + */ + setProcessFunction(fn) { + this.procFn = fn; + this.startProcessingQueues(); + } + + /** + * Queue an event if it is required and start processing queues. + * @param event - The event that may be queued. + * @returns A promise if the event was queued, which will be + * resolved or rejected in due time, else null. + */ + queueEvent(event) { + const queueName = this.queueAlgorithm(event); + if (!queueName) { + return null; + } + // add the event to the queue and make a deferred for it. + if (!this.queues[queueName]) { + this.queues[queueName] = []; + } + const deferred = (0, _utils.defer)(); + this.queues[queueName].push({ + event: event, + defer: deferred, + attempts: 0 + }); + debuglog("Queue algorithm dumped event %s into queue '%s'", event.getId(), queueName); + this.startProcessingQueues(); + return deferred.promise; + } + startProcessingQueues() { + if (!this.procFn) return; + // for each inactive queue with events in them + Object.keys(this.queues).filter(queueName => { + return this.activeQueues.indexOf(queueName) === -1 && this.queues[queueName].length > 0; + }).forEach(queueName => { + // mark the queue as active + this.activeQueues.push(queueName); + // begin processing the head of the queue + debuglog("Spinning up queue: '%s'", queueName); + this.processQueue(queueName); + }); + } + disableQueue(queueName) { + // queue is empty. Mark as inactive and stop recursing. + const index = this.activeQueues.indexOf(queueName); + if (index >= 0) { + this.activeQueues.splice(index, 1); + } + _logger.logger.info("Stopping queue '%s' as it is now empty", queueName); + } + clearQueue(queueName, err) { + _logger.logger.info("clearing queue '%s'", queueName); + let obj; + while (obj = this.removeNextEvent(queueName)) { + obj.defer.reject(err); + } + this.disableQueue(queueName); + } + peekNextEvent(queueName) { + const queue = this.queues[queueName]; + if (!Array.isArray(queue)) { + return undefined; + } + return queue[0]; + } + removeNextEvent(queueName) { + const queue = this.queues[queueName]; + if (!Array.isArray(queue)) { + return undefined; + } + return queue.shift(); + } +} + +/* istanbul ignore next */ +exports.MatrixScheduler = MatrixScheduler; +function debuglog(...args) { + if (DEBUG) { + _logger.logger.log(...args); + } +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/secret-storage.js b/comm/chat/protocols/matrix/lib/matrix-sdk/secret-storage.js new file mode 100644 index 0000000000..badd379148 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/secret-storage.js @@ -0,0 +1,431 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.ServerSideSecretStorageImpl = exports.SECRET_STORAGE_ALGORITHM_V1_AES = void 0; +exports.trimTrailingEquals = trimTrailingEquals; +var _client = require("./client"); +var _aes = require("./crypto/aes"); +var _randomstring = require("./randomstring"); +var _logger = require("./logger"); +/* +Copyright 2021-2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Implementation of server-side secret storage + * + * @see https://spec.matrix.org/v1.6/client-server-api/#storage + */ + +const SECRET_STORAGE_ALGORITHM_V1_AES = "m.secret_storage.v1.aes-hmac-sha2"; + +/** + * Common base interface for Secret Storage Keys. + * + * The common properties for all encryption keys used in server-side secret storage. + * + * @see https://spec.matrix.org/v1.6/client-server-api/#key-storage + */ + +/** + * Properties for a SSSS key using the `m.secret_storage.v1.aes-hmac-sha2` algorithm. + * + * Corresponds to `AesHmacSha2KeyDescription` in the specification. + * + * @see https://spec.matrix.org/v1.6/client-server-api/#msecret_storagev1aes-hmac-sha2 + */ + +/** + * Union type for secret storage keys. + * + * For now, this is only {@link SecretStorageKeyDescriptionAesV1}, but other interfaces may be added in future. + */ + +/** + * Information on how to generate the key from a passphrase. + * + * @see https://spec.matrix.org/v1.6/client-server-api/#deriving-keys-from-passphrases + */ + +/** + * Options for {@link ServerSideSecretStorageImpl#addKey}. + */ + +/** + * Return type for {@link ServerSideSecretStorageImpl#getKey}. + */ + +/** + * Return type for {@link ServerSideSecretStorageImpl#addKey}. + */ + +/** Interface for managing account data on the server. + * + * A subset of {@link MatrixClient}. + */ + +/** + * Application callbacks for use with {@link SecretStorage.ServerSideSecretStorageImpl} + */ + +/** + * Interface provided by SecretStorage implementations + * + * Normally this will just be an {@link ServerSideSecretStorageImpl}, but for backwards + * compatibility some methods allow other implementations. + */ +exports.SECRET_STORAGE_ALGORITHM_V1_AES = SECRET_STORAGE_ALGORITHM_V1_AES; +/** + * Implementation of Server-side secret storage. + * + * Secret *sharing* is *not* implemented here: this class is strictly about the storage component of + * SSSS. + * + * @see https://spec.matrix.org/v1.6/client-server-api/#storage + */ +class ServerSideSecretStorageImpl { + /** + * Construct a new `SecretStorage`. + * + * Normally, it is unnecessary to call this directly, since MatrixClient automatically constructs one. + * However, it may be useful to construct a new `SecretStorage`, if custom `callbacks` are required, for example. + * + * @param accountDataAdapter - interface for fetching and setting account data on the server. Normally an instance + * of {@link MatrixClient}. + * @param callbacks - application level callbacks for retrieving secret keys + */ + constructor(accountDataAdapter, callbacks) { + this.accountDataAdapter = accountDataAdapter; + this.callbacks = callbacks; + } + + /** + * Get the current default key ID for encrypting secrets. + * + * @returns The default key ID or null if no default key ID is set + */ + async getDefaultKeyId() { + const defaultKey = await this.accountDataAdapter.getAccountDataFromServer("m.secret_storage.default_key"); + if (!defaultKey) return null; + return defaultKey.key; + } + + /** + * Set the default key ID for encrypting secrets. + * + * @param keyId - The new default key ID + */ + setDefaultKeyId(keyId) { + return new Promise((resolve, reject) => { + const listener = ev => { + if (ev.getType() === "m.secret_storage.default_key" && ev.getContent().key === keyId) { + this.accountDataAdapter.removeListener(_client.ClientEvent.AccountData, listener); + resolve(); + } + }; + this.accountDataAdapter.on(_client.ClientEvent.AccountData, listener); + this.accountDataAdapter.setAccountData("m.secret_storage.default_key", { + key: keyId + }).catch(e => { + this.accountDataAdapter.removeListener(_client.ClientEvent.AccountData, listener); + reject(e); + }); + }); + } + + /** + * Add a key for encrypting secrets. + * + * @param algorithm - the algorithm used by the key. + * @param opts - the options for the algorithm. The properties used + * depend on the algorithm given. + * @param keyId - the ID of the key. If not given, a random + * ID will be generated. + * + * @returns An object with: + * keyId: the ID of the key + * keyInfo: details about the key (iv, mac, passphrase) + */ + async addKey(algorithm, opts = {}, keyId) { + if (algorithm !== SECRET_STORAGE_ALGORITHM_V1_AES) { + throw new Error(`Unknown key algorithm ${algorithm}`); + } + const keyInfo = { + algorithm + }; + if (opts.name) { + keyInfo.name = opts.name; + } + if (opts.passphrase) { + keyInfo.passphrase = opts.passphrase; + } + if (opts.key) { + const { + iv, + mac + } = await (0, _aes.calculateKeyCheck)(opts.key); + keyInfo.iv = iv; + keyInfo.mac = mac; + } + + // Create a unique key id. XXX: this is racey. + if (!keyId) { + do { + keyId = (0, _randomstring.randomString)(32); + } while (await this.accountDataAdapter.getAccountDataFromServer(`m.secret_storage.key.${keyId}`)); + } + await this.accountDataAdapter.setAccountData(`m.secret_storage.key.${keyId}`, keyInfo); + return { + keyId, + keyInfo + }; + } + + /** + * Get the key information for a given ID. + * + * @param keyId - The ID of the key to check + * for. Defaults to the default key ID if not provided. + * @returns If the key was found, the return value is an array of + * the form [keyId, keyInfo]. Otherwise, null is returned. + * XXX: why is this an array when addKey returns an object? + */ + async getKey(keyId) { + if (!keyId) { + keyId = await this.getDefaultKeyId(); + } + if (!keyId) { + return null; + } + const keyInfo = await this.accountDataAdapter.getAccountDataFromServer("m.secret_storage.key." + keyId); + return keyInfo ? [keyId, keyInfo] : null; + } + + /** + * Check whether we have a key with a given ID. + * + * @param keyId - The ID of the key to check + * for. Defaults to the default key ID if not provided. + * @returns Whether we have the key. + */ + async hasKey(keyId) { + const key = await this.getKey(keyId); + return Boolean(key); + } + + /** + * Check whether a key matches what we expect based on the key info + * + * @param key - the key to check + * @param info - the key info + * + * @returns whether or not the key matches + */ + async checkKey(key, info) { + if (info.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { + if (info.mac) { + const { + mac + } = await (0, _aes.calculateKeyCheck)(key, info.iv); + return trimTrailingEquals(info.mac) === trimTrailingEquals(mac); + } else { + // if we have no information, we have to assume the key is right + return true; + } + } else { + throw new Error("Unknown algorithm"); + } + } + + /** + * Store an encrypted secret on the server. + * + * Details of the encryption keys to be used must previously have been stored in account data + * (for example, via {@link ServerSideSecretStorageImpl#addKey}. {@link SecretStorageCallbacks#getSecretStorageKey} will be called to obtain a secret storage + * key to decrypt the secret. + * + * @param name - The name of the secret - i.e., the "event type" to be stored in the account data + * @param secret - The secret contents. + * @param keys - The IDs of the keys to use to encrypt the secret, or null/undefined to use the default key. + */ + async store(name, secret, keys) { + const encrypted = {}; + if (!keys) { + const defaultKeyId = await this.getDefaultKeyId(); + if (!defaultKeyId) { + throw new Error("No keys specified and no default key present"); + } + keys = [defaultKeyId]; + } + if (keys.length === 0) { + throw new Error("Zero keys given to encrypt with!"); + } + for (const keyId of keys) { + // get key information from key storage + const keyInfo = await this.accountDataAdapter.getAccountDataFromServer("m.secret_storage.key." + keyId); + if (!keyInfo) { + throw new Error("Unknown key: " + keyId); + } + + // encrypt secret, based on the algorithm + if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { + const keys = { + [keyId]: keyInfo + }; + const [, encryption] = await this.getSecretStorageKey(keys, name); + encrypted[keyId] = await encryption.encrypt(secret); + } else { + _logger.logger.warn("unknown algorithm for secret storage key " + keyId + ": " + keyInfo.algorithm); + // do nothing if we don't understand the encryption algorithm + } + } + + // save encrypted secret + await this.accountDataAdapter.setAccountData(name, { + encrypted + }); + } + + /** + * Get a secret from storage, and decrypt it. + * + * {@link SecretStorageCallbacks#getSecretStorageKey} will be called to obtain a secret storage + * key to decrypt the secret. + * + * @param name - the name of the secret - i.e., the "event type" stored in the account data + * + * @returns the decrypted contents of the secret, or "undefined" if `name` is not found in + * the user's account data. + */ + async get(name) { + const secretInfo = await this.accountDataAdapter.getAccountDataFromServer(name); + if (!secretInfo) { + return; + } + if (!secretInfo.encrypted) { + throw new Error("Content is not encrypted!"); + } + + // get possible keys to decrypt + const keys = {}; + for (const keyId of Object.keys(secretInfo.encrypted)) { + // get key information from key storage + const keyInfo = await this.accountDataAdapter.getAccountDataFromServer("m.secret_storage.key." + keyId); + const encInfo = secretInfo.encrypted[keyId]; + // only use keys we understand the encryption algorithm of + if (keyInfo?.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { + if (encInfo.iv && encInfo.ciphertext && encInfo.mac) { + keys[keyId] = keyInfo; + } + } + } + if (Object.keys(keys).length === 0) { + throw new Error(`Could not decrypt ${name} because none of ` + `the keys it is encrypted with are for a supported algorithm`); + } + + // fetch private key from app + const [keyId, decryption] = await this.getSecretStorageKey(keys, name); + const encInfo = secretInfo.encrypted[keyId]; + return decryption.decrypt(encInfo); + } + + /** + * Check if a secret is stored on the server. + * + * @param name - the name of the secret + * + * @returns map of key name to key info the secret is encrypted + * with, or null if it is not present or not encrypted with a trusted + * key + */ + async isStored(name) { + // check if secret exists + const secretInfo = await this.accountDataAdapter.getAccountDataFromServer(name); + if (!secretInfo?.encrypted) return null; + const ret = {}; + + // filter secret encryption keys with supported algorithm + for (const keyId of Object.keys(secretInfo.encrypted)) { + // get key information from key storage + const keyInfo = await this.accountDataAdapter.getAccountDataFromServer("m.secret_storage.key." + keyId); + if (!keyInfo) continue; + const encInfo = secretInfo.encrypted[keyId]; + + // only use keys we understand the encryption algorithm of + if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { + if (encInfo.iv && encInfo.ciphertext && encInfo.mac) { + ret[keyId] = keyInfo; + } + } + } + return Object.keys(ret).length ? ret : null; + } + async getSecretStorageKey(keys, name) { + if (!this.callbacks.getSecretStorageKey) { + throw new Error("No getSecretStorageKey callback supplied"); + } + const returned = await this.callbacks.getSecretStorageKey({ + keys + }, name); + if (!returned) { + throw new Error("getSecretStorageKey callback returned falsey"); + } + if (returned.length < 2) { + throw new Error("getSecretStorageKey callback returned invalid data"); + } + const [keyId, privateKey] = returned; + if (!keys[keyId]) { + throw new Error("App returned unknown key from getSecretStorageKey!"); + } + if (keys[keyId].algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { + const decryption = { + encrypt: function (secret) { + return (0, _aes.encryptAES)(secret, privateKey, name); + }, + decrypt: function (encInfo) { + return (0, _aes.decryptAES)(encInfo, privateKey, name); + } + }; + return [keyId, decryption]; + } else { + throw new Error("Unknown key type: " + keys[keyId].algorithm); + } + } +} + +/** trim trailing instances of '=' from a string + * + * @internal + * + * @param input - input string + */ +exports.ServerSideSecretStorageImpl = ServerSideSecretStorageImpl; +function trimTrailingEquals(input) { + // according to Sonar and CodeQL, a regex such as /=+$/ is superlinear. + // Not sure I believe it, but it's easy enough to work around. + + // find the number of characters before the trailing = + let i = input.length; + while (i >= 1 && input.charCodeAt(i - 1) == 0x3d) i--; + + // trim to the calculated length + if (i < input.length) { + return input.substring(0, i); + } else { + return input; + } +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/service-types.js b/comm/chat/protocols/matrix/lib/matrix-sdk/service-types.js new file mode 100644 index 0000000000..fd01a116bf --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/service-types.js @@ -0,0 +1,27 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.SERVICE_TYPES = void 0; +/* +Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +let SERVICE_TYPES = /*#__PURE__*/function (SERVICE_TYPES) { + SERVICE_TYPES["IS"] = "SERVICE_TYPE_IS"; + SERVICE_TYPES["IM"] = "SERVICE_TYPE_IM"; + return SERVICE_TYPES; +}({}); // An integration manager +exports.SERVICE_TYPES = SERVICE_TYPES;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/sliding-sync-sdk.js b/comm/chat/protocols/matrix/lib/matrix-sdk/sliding-sync-sdk.js new file mode 100644 index 0000000000..9d5ec1453d --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/sliding-sync-sdk.js @@ -0,0 +1,861 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.SlidingSyncSdk = void 0; +var _room = require("./models/room"); +var _logger = require("./logger"); +var _utils = require("./utils"); +var _eventTimeline = require("./models/event-timeline"); +var _client = require("./client"); +var _sync = require("./sync"); +var _httpApi = require("./http-api"); +var _slidingSync = require("./sliding-sync"); +var _event = require("./@types/event"); +var _roomState = require("./models/room-state"); +var _roomMember = require("./models/room-member"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2022 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +// Number of consecutive failed syncs that will lead to a syncState of ERROR as opposed +// to RECONNECTING. This is needed to inform the client of server issues when the +// keepAlive is successful but the server /sync fails. +const FAILED_SYNC_ERROR_THRESHOLD = 3; +class ExtensionE2EE { + constructor(crypto) { + this.crypto = crypto; + } + name() { + return "e2ee"; + } + when() { + return _slidingSync.ExtensionState.PreProcess; + } + onRequest(isInitial) { + if (!isInitial) { + return undefined; + } + return { + enabled: true // this is sticky so only send it on the initial request + }; + } + + async onResponse(data) { + // Handle device list updates + if (data.device_lists) { + await this.crypto.processDeviceLists(data.device_lists); + } + + // Handle one_time_keys_count and unused_fallback_key_types + await this.crypto.processKeyCounts(data.device_one_time_keys_count, data["device_unused_fallback_key_types"] || data["org.matrix.msc2732.device_unused_fallback_key_types"]); + this.crypto.onSyncCompleted({}); + } +} +class ExtensionToDevice { + constructor(client, cryptoCallbacks) { + this.client = client; + this.cryptoCallbacks = cryptoCallbacks; + _defineProperty(this, "nextBatch", null); + } + name() { + return "to_device"; + } + when() { + return _slidingSync.ExtensionState.PreProcess; + } + onRequest(isInitial) { + const extReq = { + since: this.nextBatch !== null ? this.nextBatch : undefined + }; + if (isInitial) { + extReq["limit"] = 100; + extReq["enabled"] = true; + } + return extReq; + } + async onResponse(data) { + const cancelledKeyVerificationTxns = []; + let events = data["events"] || []; + if (events.length > 0 && this.cryptoCallbacks) { + events = await this.cryptoCallbacks.preprocessToDeviceMessages(events); + } + events.map(this.client.getEventMapper()).map(toDeviceEvent => { + // map is a cheap inline forEach + // We want to flag m.key.verification.start events as cancelled + // if there's an accompanying m.key.verification.cancel event, so + // we pull out the transaction IDs from the cancellation events + // so we can flag the verification events as cancelled in the loop + // below. + if (toDeviceEvent.getType() === "m.key.verification.cancel") { + const txnId = toDeviceEvent.getContent()["transaction_id"]; + if (txnId) { + cancelledKeyVerificationTxns.push(txnId); + } + } + + // as mentioned above, .map is a cheap inline forEach, so return + // the unmodified event. + return toDeviceEvent; + }).forEach(toDeviceEvent => { + const content = toDeviceEvent.getContent(); + if (toDeviceEvent.getType() == "m.room.message" && content.msgtype == "m.bad.encrypted") { + // the mapper already logged a warning. + _logger.logger.log("Ignoring undecryptable to-device event from " + toDeviceEvent.getSender()); + return; + } + if (toDeviceEvent.getType() === "m.key.verification.start" || toDeviceEvent.getType() === "m.key.verification.request") { + const txnId = content["transaction_id"]; + if (cancelledKeyVerificationTxns.includes(txnId)) { + toDeviceEvent.flagCancelled(); + } + } + this.client.emit(_client.ClientEvent.ToDeviceEvent, toDeviceEvent); + }); + this.nextBatch = data.next_batch; + } +} +class ExtensionAccountData { + constructor(client) { + this.client = client; + } + name() { + return "account_data"; + } + when() { + return _slidingSync.ExtensionState.PostProcess; + } + onRequest(isInitial) { + if (!isInitial) { + return undefined; + } + return { + enabled: true + }; + } + onResponse(data) { + if (data.global && data.global.length > 0) { + this.processGlobalAccountData(data.global); + } + for (const roomId in data.rooms) { + const accountDataEvents = mapEvents(this.client, roomId, data.rooms[roomId]); + const room = this.client.getRoom(roomId); + if (!room) { + _logger.logger.warn("got account data for room but room doesn't exist on client:", roomId); + continue; + } + room.addAccountData(accountDataEvents); + accountDataEvents.forEach(e => { + this.client.emit(_client.ClientEvent.Event, e); + }); + } + } + processGlobalAccountData(globalAccountData) { + const events = mapEvents(this.client, undefined, globalAccountData); + const prevEventsMap = events.reduce((m, c) => { + m[c.getType()] = this.client.store.getAccountData(c.getType()); + return m; + }, {}); + this.client.store.storeAccountDataEvents(events); + events.forEach(accountDataEvent => { + // Honour push rules that come down the sync stream but also + // honour push rules that were previously cached. Base rules + // will be updated when we receive push rules via getPushRules + // (see sync) before syncing over the network. + if (accountDataEvent.getType() === _event.EventType.PushRules) { + const rules = accountDataEvent.getContent(); + this.client.setPushRules(rules); + } + const prevEvent = prevEventsMap[accountDataEvent.getType()]; + this.client.emit(_client.ClientEvent.AccountData, accountDataEvent, prevEvent); + return accountDataEvent; + }); + } +} +class ExtensionTyping { + constructor(client) { + this.client = client; + } + name() { + return "typing"; + } + when() { + return _slidingSync.ExtensionState.PostProcess; + } + onRequest(isInitial) { + if (!isInitial) { + return undefined; // don't send a JSON object for subsequent requests, we don't need to. + } + + return { + enabled: true + }; + } + onResponse(data) { + if (!data?.rooms) { + return; + } + for (const roomId in data.rooms) { + processEphemeralEvents(this.client, roomId, [data.rooms[roomId]]); + } + } +} +class ExtensionReceipts { + constructor(client) { + this.client = client; + } + name() { + return "receipts"; + } + when() { + return _slidingSync.ExtensionState.PostProcess; + } + onRequest(isInitial) { + if (isInitial) { + return { + enabled: true + }; + } + return undefined; // don't send a JSON object for subsequent requests, we don't need to. + } + + onResponse(data) { + if (!data?.rooms) { + return; + } + for (const roomId in data.rooms) { + processEphemeralEvents(this.client, roomId, [data.rooms[roomId]]); + } + } +} + +/** + * A copy of SyncApi such that it can be used as a drop-in replacement for sync v2. For the actual + * sliding sync API, see sliding-sync.ts or the class SlidingSync. + */ +class SlidingSyncSdk { + // accumulator of sync events in the current sync response + + constructor(slidingSync, client, opts, syncOpts) { + this.slidingSync = slidingSync; + this.client = client; + _defineProperty(this, "opts", void 0); + _defineProperty(this, "syncOpts", void 0); + _defineProperty(this, "syncState", null); + _defineProperty(this, "syncStateData", void 0); + _defineProperty(this, "lastPos", null); + _defineProperty(this, "failCount", 0); + _defineProperty(this, "notifEvents", []); + this.opts = (0, _sync.defaultClientOpts)(opts); + this.syncOpts = (0, _sync.defaultSyncApiOpts)(syncOpts); + if (client.getNotifTimelineSet()) { + client.reEmitter.reEmit(client.getNotifTimelineSet(), [_room.RoomEvent.Timeline, _room.RoomEvent.TimelineReset]); + } + this.slidingSync.on(_slidingSync.SlidingSyncEvent.Lifecycle, this.onLifecycle.bind(this)); + this.slidingSync.on(_slidingSync.SlidingSyncEvent.RoomData, this.onRoomData.bind(this)); + const extensions = [new ExtensionToDevice(this.client, this.syncOpts.cryptoCallbacks), new ExtensionAccountData(this.client), new ExtensionTyping(this.client), new ExtensionReceipts(this.client)]; + if (this.syncOpts.crypto) { + extensions.push(new ExtensionE2EE(this.syncOpts.crypto)); + } + extensions.forEach(ext => { + this.slidingSync.registerExtension(ext); + }); + } + onRoomData(roomId, roomData) { + let room = this.client.store.getRoom(roomId); + if (!room) { + if (!roomData.initial) { + _logger.logger.debug("initial flag not set but no stored room exists for room ", roomId, roomData); + return; + } + room = (0, _sync._createAndReEmitRoom)(this.client, roomId, this.opts); + } + this.processRoomData(this.client, room, roomData); + } + onLifecycle(state, resp, err) { + if (err) { + _logger.logger.debug("onLifecycle", state, err); + } + switch (state) { + case _slidingSync.SlidingSyncState.Complete: + this.purgeNotifications(); + if (!resp) { + break; + } + // Element won't stop showing the initial loading spinner unless we fire SyncState.Prepared + if (!this.lastPos) { + this.updateSyncState(_sync.SyncState.Prepared, { + oldSyncToken: undefined, + nextSyncToken: resp.pos, + catchingUp: false, + fromCache: false + }); + } + // Conversely, Element won't show the room list unless there is at least 1x SyncState.Syncing + // so hence for the very first sync we will fire prepared then immediately syncing. + this.updateSyncState(_sync.SyncState.Syncing, { + oldSyncToken: this.lastPos, + nextSyncToken: resp.pos, + catchingUp: false, + fromCache: false + }); + this.lastPos = resp.pos; + break; + case _slidingSync.SlidingSyncState.RequestFinished: + if (err) { + this.failCount += 1; + this.updateSyncState(this.failCount > FAILED_SYNC_ERROR_THRESHOLD ? _sync.SyncState.Error : _sync.SyncState.Reconnecting, { + error: new _httpApi.MatrixError(err) + }); + if (this.shouldAbortSync(new _httpApi.MatrixError(err))) { + return; // shouldAbortSync actually stops syncing too so we don't need to do anything. + } + } else { + this.failCount = 0; + } + break; + } + } + + /** + * Sync rooms the user has left. + * @returns Resolved when they've been added to the store. + */ + async syncLeftRooms() { + return []; // TODO + } + + /** + * Peek into a room. This will result in the room in question being synced so it + * is accessible via getRooms(). Live updates for the room will be provided. + * @param roomId - The room ID to peek into. + * @returns A promise which resolves once the room has been added to the + * store. + */ + async peek(_roomId) { + return null; // TODO + } + + /** + * Stop polling for updates in the peeked room. NOPs if there is no room being + * peeked. + */ + stopPeeking() { + // TODO + } + + /** + * Returns the current state of this sync object + * @see MatrixClient#event:"sync" + */ + getSyncState() { + 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. + */ + getSyncStateData() { + return this.syncStateData ?? null; + } + + // Helper functions which set up JS SDK structs are below and are identical to the sync v2 counterparts + + createRoom(roomId) { + // XXX cargoculted from sync.ts + const { + timelineSupport + } = this.client; + const room = new _room.Room(roomId, this.client, this.client.getUserId(), { + lazyLoadMembers: this.opts.lazyLoadMembers, + pendingEventOrdering: this.opts.pendingEventOrdering, + timelineSupport + }); + this.client.reEmitter.reEmit(room, [_room.RoomEvent.Name, _room.RoomEvent.Redaction, _room.RoomEvent.RedactionCancelled, _room.RoomEvent.Receipt, _room.RoomEvent.Tags, _room.RoomEvent.LocalEchoUpdated, _room.RoomEvent.AccountData, _room.RoomEvent.MyMembership, _room.RoomEvent.Timeline, _room.RoomEvent.TimelineReset]); + this.registerStateListeners(room); + return room; + } + registerStateListeners(room) { + // XXX cargoculted from sync.ts + // we need to also re-emit room state and room member events, so hook it up + // to the client now. We need to add a listener for RoomState.members in + // order to hook them correctly. + this.client.reEmitter.reEmit(room.currentState, [_roomState.RoomStateEvent.Events, _roomState.RoomStateEvent.Members, _roomState.RoomStateEvent.NewMember, _roomState.RoomStateEvent.Update]); + room.currentState.on(_roomState.RoomStateEvent.NewMember, (event, state, member) => { + member.user = this.client.getUser(member.userId) ?? undefined; + this.client.reEmitter.reEmit(member, [_roomMember.RoomMemberEvent.Name, _roomMember.RoomMemberEvent.Typing, _roomMember.RoomMemberEvent.PowerLevel, _roomMember.RoomMemberEvent.Membership]); + }); + } + + /* + private deregisterStateListeners(room: Room): void { // XXX cargoculted from sync.ts + // could do with a better way of achieving this. + room.currentState.removeAllListeners(RoomStateEvent.Events); + room.currentState.removeAllListeners(RoomStateEvent.Members); + room.currentState.removeAllListeners(RoomStateEvent.NewMember); + } */ + + shouldAbortSync(error) { + if (error.errcode === "M_UNKNOWN_TOKEN") { + // The logout already happened, we just need to stop. + _logger.logger.warn("Token no longer valid - assuming logout"); + this.stop(); + this.updateSyncState(_sync.SyncState.Error, { + error + }); + return true; + } + return false; + } + async processRoomData(client, room, roomData) { + roomData = ensureNameEvent(client, room.roomId, roomData); + const stateEvents = mapEvents(this.client, room.roomId, roomData.required_state); + // Prevent events from being decrypted ahead of time + // this helps large account to speed up faster + // room::decryptCriticalEvent is in charge of decrypting all the events + // required for a client to function properly + let timelineEvents = mapEvents(this.client, room.roomId, roomData.timeline, false); + const ephemeralEvents = []; // TODO this.mapSyncEventsFormat(joinObj.ephemeral); + + // TODO: handle threaded / beacon events + + if (roomData.initial) { + // we should not know about any of these timeline entries if this is a genuinely new room. + // If we do, then we've effectively done scrollback (e.g requesting timeline_limit: 1 for + // this room, then timeline_limit: 50). + const knownEvents = new Set(); + room.getLiveTimeline().getEvents().forEach(e => { + knownEvents.add(e.getId()); + }); + // all unknown events BEFORE a known event must be scrollback e.g: + // D E <-- what we know + // A B C D E F <-- what we just received + // means: + // A B C <-- scrollback + // D E <-- dupes + // F <-- new event + // We bucket events based on if we have seen a known event yet. + const oldEvents = []; + const newEvents = []; + let seenKnownEvent = false; + for (let i = timelineEvents.length - 1; i >= 0; i--) { + const recvEvent = timelineEvents[i]; + if (knownEvents.has(recvEvent.getId())) { + seenKnownEvent = true; + continue; // don't include this event, it's a dupe + } + + if (seenKnownEvent) { + // old -> new + oldEvents.push(recvEvent); + } else { + // old -> new + newEvents.unshift(recvEvent); + } + } + timelineEvents = newEvents; + if (oldEvents.length > 0) { + // old events are scrollback, insert them now + room.addEventsToTimeline(oldEvents, true, room.getLiveTimeline(), roomData.prev_batch); + } + } + const encrypted = this.client.isRoomEncrypted(room.roomId); + // we do this first so it's correct when any of the events fire + if (roomData.notification_count != null) { + room.setUnreadNotificationCount(_room.NotificationCountType.Total, roomData.notification_count); + } + if (roomData.highlight_count != null) { + // We track unread notifications ourselves in encrypted rooms, so don't + // bother setting it here. We trust our calculations better than the + // server's for this case, and therefore will assume that our non-zero + // count is accurate. + if (!encrypted || encrypted && room.getUnreadNotificationCount(_room.NotificationCountType.Highlight) <= 0) { + room.setUnreadNotificationCount(_room.NotificationCountType.Highlight, roomData.highlight_count); + } + } + if (Number.isInteger(roomData.invited_count)) { + room.currentState.setInvitedMemberCount(roomData.invited_count); + } + if (Number.isInteger(roomData.joined_count)) { + room.currentState.setJoinedMemberCount(roomData.joined_count); + } + if (roomData.invite_state) { + const inviteStateEvents = mapEvents(this.client, room.roomId, roomData.invite_state); + await this.injectRoomEvents(room, inviteStateEvents); + if (roomData.initial) { + room.recalculate(); + this.client.store.storeRoom(room); + this.client.emit(_client.ClientEvent.Room, room); + } + inviteStateEvents.forEach(e => { + this.client.emit(_client.ClientEvent.Event, e); + }); + room.updateMyMembership("invite"); + return; + } + if (roomData.initial) { + // set the back-pagination token. Do this *before* adding any + // events so that clients can start back-paginating. + room.getLiveTimeline().setPaginationToken(roomData.prev_batch ?? null, _eventTimeline.EventTimeline.BACKWARDS); + } + + /* TODO + else if (roomData.limited) { + let limited = true; + // we've got a limited sync, so we *probably* have a gap in the + // timeline, so should reset. But we might have been peeking or + // paginating and already have some of the events, in which + // case we just want to append any subsequent events to the end + // of the existing timeline. + // + // This is particularly important in the case that we already have + // *all* of the events in the timeline - in that case, if we reset + // the timeline, we'll end up with an entirely empty timeline, + // which we'll try to paginate but not get any new events (which + // will stop us linking the empty timeline into the chain). + // + for (let i = timelineEvents.length - 1; i >= 0; i--) { + const eventId = timelineEvents[i].getId(); + if (room.getTimelineForEvent(eventId)) { + logger.debug("Already have event " + eventId + " in limited " + + "sync - not resetting"); + limited = false; + // we might still be missing some of the events before i; + // we don't want to be adding them to the end of the + // timeline because that would put them out of order. + timelineEvents.splice(0, i); + // XXX: there's a problem here if the skipped part of the + // timeline modifies the state set in stateEvents, because + // we'll end up using the state from stateEvents rather + // than the later state from timelineEvents. We probably + // need to wind stateEvents forward over the events we're + // skipping. + break; + } + } + if (limited) { + room.resetLiveTimeline( + roomData.prev_batch, + null, // TODO this.syncOpts.canResetEntireTimeline(room.roomId) ? null : syncEventData.oldSyncToken, + ); + // We have to assume any gap in any timeline is + // reason to stop incrementally tracking notifications and + // reset the timeline. + this.client.resetNotifTimelineSet(); + this.registerStateListeners(room); + } + } */ + + await this.injectRoomEvents(room, stateEvents, timelineEvents, roomData.num_live); + + // we deliberately don't add ephemeral events to the timeline + room.addEphemeralEvents(ephemeralEvents); + + // local fields must be set before any async calls because call site assumes + // synchronous execution prior to emitting SlidingSyncState.Complete + room.updateMyMembership("join"); + room.recalculate(); + if (roomData.initial) { + client.store.storeRoom(room); + client.emit(_client.ClientEvent.Room, room); + } + + // check if any timeline events should bing and add them to the notifEvents array: + // we'll purge this once we've fully processed the sync response + this.addNotifications(timelineEvents); + const processRoomEvent = async e => { + client.emit(_client.ClientEvent.Event, e); + if (e.isState() && e.getType() == _event.EventType.RoomEncryption && this.syncOpts.cryptoCallbacks) { + await this.syncOpts.cryptoCallbacks.onCryptoEvent(room, e); + } + }; + await (0, _utils.promiseMapSeries)(stateEvents, processRoomEvent); + await (0, _utils.promiseMapSeries)(timelineEvents, processRoomEvent); + ephemeralEvents.forEach(function (e) { + client.emit(_client.ClientEvent.Event, e); + }); + + // Decrypt only the last message in all rooms to make sure we can generate a preview + // And decrypt all events after the recorded read receipt to ensure an accurate + // notification count + room.decryptCriticalEvents(); + } + + /** + * Injects events into a room's model. + * @param stateEventList - A list of state events. This is the state + * at the *START* of the timeline list if it is supplied. + * @param timelineEventList - A list of timeline events. Lower index + * is earlier in time. Higher index is later. + * @param numLive - the number of events in timelineEventList which just happened, + * supplied from the server. + */ + async injectRoomEvents(room, stateEventList, timelineEventList, numLive) { + timelineEventList = timelineEventList || []; + stateEventList = stateEventList || []; + numLive = numLive || 0; + + // If there are no events in the timeline yet, initialise it with + // the given state events + const liveTimeline = room.getLiveTimeline(); + const timelineWasEmpty = liveTimeline.getEvents().length == 0; + if (timelineWasEmpty) { + // Passing these events into initialiseState will freeze them, so we need + // to compute and cache the push actions for them now, otherwise sync dies + // with an attempt to assign to read only property. + // XXX: This is pretty horrible and is assuming all sorts of behaviour from + // these functions that it shouldn't be. We should probably either store the + // push actions cache elsewhere so we can freeze MatrixEvents, or otherwise + // find some solution where MatrixEvents are immutable but allow for a cache + // field. + for (const ev of stateEventList) { + this.client.getPushActionsForEvent(ev); + } + liveTimeline.initialiseState(stateEventList); + } + + // If the timeline wasn't empty, we process the state events here: they're + // defined as updates to the state before the start of the timeline, so this + // starts to roll the state forward. + // XXX: That's what we *should* do, but this can happen if we were previously + // peeking in a room, in which case we obviously do *not* want to add the + // state events here onto the end of the timeline. Historically, the js-sdk + // has just set these new state events on the old and new state. This seems + // very wrong because there could be events in the timeline that diverge the + // state, in which case this is going to leave things out of sync. However, + // for now I think it;s best to behave the same as the code has done previously. + if (!timelineWasEmpty) { + // XXX: As above, don't do this... + //room.addLiveEvents(stateEventList || []); + // Do this instead... + room.oldState.setStateEvents(stateEventList); + room.currentState.setStateEvents(stateEventList); + } + + // the timeline is broken into 'live' events which just happened and normal timeline events + // which are still to be appended to the end of the live timeline but happened a while ago. + // The live events are marked as fromCache=false to ensure that downstream components know + // this is a live event, not historical (from a remote server cache). + + let liveTimelineEvents = []; + if (numLive > 0) { + // last numLive events are live + liveTimelineEvents = timelineEventList.slice(-1 * numLive); + // everything else is not live + timelineEventList = timelineEventList.slice(0, -1 * liveTimelineEvents.length); + } + + // execute the timeline events. This will continue to diverge the current state + // if the timeline has any state events in it. + // This also needs to be done before running push rules on the events as they need + // to be decorated with sender etc. + await room.addLiveEvents(timelineEventList, { + fromCache: true + }); + if (liveTimelineEvents.length > 0) { + await room.addLiveEvents(liveTimelineEvents, { + fromCache: false + }); + } + room.recalculate(); + + // resolve invites now we have set the latest state + this.resolveInvites(room); + } + resolveInvites(room) { + if (!room || !this.opts.resolveInvitesToProfiles) { + return; + } + const client = this.client; + // For each invited room member we want to give them a displayname/avatar url + // if they have one (the m.room.member invites don't contain this). + room.getMembersWithMembership("invite").forEach(function (member) { + if (member.requestedProfileInfo) return; + member.requestedProfileInfo = true; + // try to get a cached copy first. + const user = client.getUser(member.userId); + let promise; + if (user) { + promise = Promise.resolve({ + avatar_url: user.avatarUrl, + displayname: user.displayName + }); + } else { + promise = client.getProfileInfo(member.userId); + } + promise.then(function (info) { + // slightly naughty by doctoring the invite event but this means all + // the code paths remain the same between invite/join display name stuff + // which is a worthy trade-off for some minor pollution. + const inviteEvent = member.events.member; + if (inviteEvent.getContent().membership !== "invite") { + // between resolving and now they have since joined, so don't clobber + return; + } + inviteEvent.getContent().avatar_url = info.avatar_url; + inviteEvent.getContent().displayname = info.displayname; + // fire listeners + member.setMembershipEvent(inviteEvent, room.currentState); + }, function (_err) { + // OH WELL. + }); + }); + } + retryImmediately() { + return true; + } + + /** + * Main entry point. Blocks until stop() is called. + */ + async sync() { + _logger.logger.debug("Sliding sync init loop"); + + // 1) We need to get push rules so we can check if events should bing as we get + // them from /sync. + while (!this.client.isGuest()) { + try { + _logger.logger.debug("Getting push rules..."); + const result = await this.client.getPushRules(); + _logger.logger.debug("Got push rules"); + this.client.pushRules = result; + break; + } catch (err) { + _logger.logger.error("Getting push rules failed", err); + if (this.shouldAbortSync(err)) { + return; + } + } + } + + // start syncing + await this.slidingSync.start(); + } + + /** + * Stops the sync object from syncing. + */ + stop() { + _logger.logger.debug("SyncApi.stop"); + this.slidingSync.stop(); + } + + /** + * Sets the sync state and emits an event to say so + * @param newState - The new state string + * @param data - Object of additional data to emit in the event + */ + updateSyncState(newState, data) { + const old = this.syncState; + this.syncState = newState; + this.syncStateData = data; + this.client.emit(_client.ClientEvent.Sync, this.syncState, old, data); + } + + /** + * Takes a list of timelineEvents and adds and adds to notifEvents + * as appropriate. + * This must be called after the room the events belong to has been stored. + * + * @param timelineEventList - A list of timeline events. Lower index + * is earlier in time. Higher index is later. + */ + addNotifications(timelineEventList) { + // gather our notifications into this.notifEvents + if (!this.client.getNotifTimelineSet()) { + return; + } + for (const timelineEvent of timelineEventList) { + const pushActions = this.client.getPushActionsForEvent(timelineEvent); + if (pushActions && pushActions.notify && pushActions.tweaks && pushActions.tweaks.highlight) { + this.notifEvents.push(timelineEvent); + } + } + } + + /** + * Purge any events in the notifEvents array. Used after a /sync has been complete. + * This should not be called at a per-room scope (e.g in onRoomData) because otherwise the ordering + * will be messed up e.g room A gets a bing, room B gets a newer bing, but both in the same /sync + * response. If we purge at a per-room scope then we could process room B before room A leading to + * room B appearing earlier in the notifications timeline, even though it has the higher origin_server_ts. + */ + purgeNotifications() { + this.notifEvents.sort(function (a, b) { + return a.getTs() - b.getTs(); + }); + this.notifEvents.forEach(event => { + this.client.getNotifTimelineSet()?.addLiveEvent(event); + }); + this.notifEvents = []; + } +} +exports.SlidingSyncSdk = SlidingSyncSdk; +function ensureNameEvent(client, roomId, roomData) { + // make sure m.room.name is in required_state if there is a name, replacing anything previously + // there if need be. This ensures clients transparently 'calculate' the right room name. Native + // sliding sync clients should just read the "name" field. + if (!roomData.name) { + return roomData; + } + for (const stateEvent of roomData.required_state) { + if (stateEvent.type === _event.EventType.RoomName && stateEvent.state_key === "") { + stateEvent.content = { + name: roomData.name + }; + return roomData; + } + } + roomData.required_state.push({ + event_id: "$fake-sliding-sync-name-event-" + roomId, + state_key: "", + type: _event.EventType.RoomName, + content: { + name: roomData.name + }, + sender: client.getUserId(), + origin_server_ts: new Date().getTime() + }); + return roomData; +} +// Helper functions which set up JS SDK structs are below and are identical to the sync v2 counterparts, +// just outside the class. +function mapEvents(client, roomId, events, decrypt = true) { + const mapper = client.getEventMapper({ + decrypt + }); + return events.map(function (e) { + e.room_id = roomId; + return mapper(e); + }); +} +function processEphemeralEvents(client, roomId, ephEvents) { + const ephemeralEvents = mapEvents(client, roomId, ephEvents); + const room = client.getRoom(roomId); + if (!room) { + _logger.logger.warn("got ephemeral events for room but room doesn't exist on client:", roomId); + return; + } + room.addEphemeralEvents(ephemeralEvents); + ephemeralEvents.forEach(e => { + client.emit(_client.ClientEvent.Event, e); + }); +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/sliding-sync.js b/comm/chat/protocols/matrix/lib/matrix-sdk/sliding-sync.js new file mode 100644 index 0000000000..b6b77cc924 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/sliding-sync.js @@ -0,0 +1,795 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.SlidingSyncState = exports.SlidingSyncEvent = exports.SlidingSync = exports.MSC3575_WILDCARD = exports.MSC3575_STATE_KEY_ME = exports.MSC3575_STATE_KEY_LAZY = exports.ExtensionState = void 0; +var _logger = require("./logger"); +var _typedEventEmitter = require("./models/typed-event-emitter"); +var _utils = require("./utils"); +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2022 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +// /sync requests allow you to set a timeout= but the request may continue +// beyond that and wedge forever, so we need to track how long we are willing +// to keep open the connection. This constant is *ADDED* to the timeout= value +// to determine the max time we're willing to wait. +const BUFFER_PERIOD_MS = 10 * 1000; +const MSC3575_WILDCARD = "*"; +exports.MSC3575_WILDCARD = MSC3575_WILDCARD; +const MSC3575_STATE_KEY_ME = "$ME"; +exports.MSC3575_STATE_KEY_ME = MSC3575_STATE_KEY_ME; +const MSC3575_STATE_KEY_LAZY = "$LAZY"; + +/** + * Represents a subscription to a room or set of rooms. Controls which events are returned. + */ + +/** + * Controls which rooms are returned in a given list. + */ + +/** + * Represents a list subscription. + */ + +/** + * A complete Sliding Sync request. + */ + +/** + * A complete Sliding Sync response + */ +exports.MSC3575_STATE_KEY_LAZY = MSC3575_STATE_KEY_LAZY; +let SlidingSyncState = /*#__PURE__*/function (SlidingSyncState) { + SlidingSyncState["RequestFinished"] = "FINISHED"; + SlidingSyncState["Complete"] = "COMPLETE"; + return SlidingSyncState; +}({}); +/** + * Internal Class. SlidingList represents a single list in sliding sync. The list can have filters, + * multiple sliding windows, and maintains the index-\>room_id mapping. + */ +exports.SlidingSyncState = SlidingSyncState; +class SlidingList { + /** + * Construct a new sliding list. + * @param list - The range, sort and filter values to use for this list. + */ + constructor(list) { + _defineProperty(this, "list", void 0); + _defineProperty(this, "isModified", void 0); + // returned data + _defineProperty(this, "roomIndexToRoomId", {}); + _defineProperty(this, "joinedCount", 0); + this.replaceList(list); + } + + /** + * Mark this list as modified or not. Modified lists will return sticky params with calls to getList. + * This is useful for the first time the list is sent, or if the list has changed in some way. + * @param modified - True to mark this list as modified so all sticky parameters will be re-sent. + */ + setModified(modified) { + this.isModified = modified; + } + + /** + * Update the list range for this list. Does not affect modified status as list ranges are non-sticky. + * @param newRanges - The new ranges for the list + */ + updateListRange(newRanges) { + this.list.ranges = JSON.parse(JSON.stringify(newRanges)); + } + + /** + * Replace list parameters. All fields will be replaced with the new list parameters. + * @param list - The new list parameters + */ + replaceList(list) { + list.filters = list.filters || {}; + list.ranges = list.ranges || []; + this.list = JSON.parse(JSON.stringify(list)); + this.isModified = true; + + // reset values as the join count may be very different (if filters changed) including the rooms + // (e.g. sort orders or sliding window ranges changed) + + // the constantly changing sliding window ranges. Not an array for performance reasons + // E.g. tracking ranges 0-99, 500-599, we don't want to have a 600 element array + this.roomIndexToRoomId = {}; + // the total number of joined rooms according to the server, always >= len(roomIndexToRoomId) + this.joinedCount = 0; + } + + /** + * Return a copy of the list suitable for a request body. + * @param forceIncludeAllParams - True to forcibly include all params even if the list + * hasn't been modified. Callers may want to do this if they are modifying the list prior to calling + * updateList. + */ + getList(forceIncludeAllParams) { + let list = { + ranges: JSON.parse(JSON.stringify(this.list.ranges)) + }; + if (this.isModified || forceIncludeAllParams) { + list = JSON.parse(JSON.stringify(this.list)); + } + return list; + } + + /** + * Check if a given index is within the list range. This is required even though the /sync API + * provides explicit updates with index positions because of the following situation: + * 0 1 2 3 4 5 6 7 8 indexes + * a b c d e f COMMANDS: SYNC 0 2 a b c; SYNC 6 8 d e f; + * a b c d _ f COMMAND: DELETE 7; + * e a b c d f COMMAND: INSERT 0 e; + * c=3 is wrong as we are not tracking it, ergo we need to see if `i` is in range else drop it + * @param i - The index to check + * @returns True if the index is within a sliding window + */ + isIndexInRange(i) { + for (const r of this.list.ranges) { + if (r[0] <= i && i <= r[1]) { + return true; + } + } + return false; + } +} + +/** + * When onResponse extensions should be invoked: before or after processing the main response. + */ +let ExtensionState = /*#__PURE__*/function (ExtensionState) { + ExtensionState["PreProcess"] = "ExtState.PreProcess"; + ExtensionState["PostProcess"] = "ExtState.PostProcess"; + return ExtensionState; +}({}); +/** + * An interface that must be satisfied to register extensions + */ +exports.ExtensionState = ExtensionState; +/** + * Events which can be fired by the SlidingSync class. These are designed to provide different levels + * of information when processing sync responses. + * - RoomData: concerns rooms, useful for SlidingSyncSdk to update its knowledge of rooms. + * - Lifecycle: concerns callbacks at various well-defined points in the sync process. + * - List: concerns lists, useful for UI layers to re-render room lists. + * Specifically, the order of event invocation is: + * - Lifecycle (state=RequestFinished) + * - RoomData (N times) + * - Lifecycle (state=Complete) + * - List (at most once per list) + */ +let SlidingSyncEvent = /*#__PURE__*/function (SlidingSyncEvent) { + SlidingSyncEvent["RoomData"] = "SlidingSync.RoomData"; + SlidingSyncEvent["Lifecycle"] = "SlidingSync.Lifecycle"; + SlidingSyncEvent["List"] = "SlidingSync.List"; + return SlidingSyncEvent; +}({}); +exports.SlidingSyncEvent = SlidingSyncEvent; +/** + * SlidingSync is a high-level data structure which controls the majority of sliding sync. + * It has no hooks into JS SDK except for needing a MatrixClient to perform the HTTP request. + * This means this class (and everything it uses) can be used in isolation from JS SDK if needed. + * To hook this up with the JS SDK, you need to use SlidingSyncSdk. + */ +class SlidingSync extends _typedEventEmitter.TypedEventEmitter { + /** + * Create a new sliding sync instance + * @param proxyBaseUrl - The base URL of the sliding sync proxy + * @param lists - The lists to use for sliding sync. + * @param roomSubscriptionInfo - The params to use for room subscriptions. + * @param client - The client to use for /sync calls. + * @param timeoutMS - The number of milliseconds to wait for a response. + */ + constructor(proxyBaseUrl, lists, roomSubscriptionInfo, client, timeoutMS) { + super(); + this.proxyBaseUrl = proxyBaseUrl; + this.roomSubscriptionInfo = roomSubscriptionInfo; + this.client = client; + this.timeoutMS = timeoutMS; + _defineProperty(this, "lists", void 0); + _defineProperty(this, "listModifiedCount", 0); + _defineProperty(this, "terminated", false); + // flag set when resend() is called because we cannot rely on detecting AbortError in JS SDK :( + _defineProperty(this, "needsResend", false); + // the txn_id to send with the next request. + _defineProperty(this, "txnId", null); + // a list (in chronological order of when they were sent) of objects containing the txn ID and + // a defer to resolve/reject depending on whether they were successfully sent or not. + _defineProperty(this, "txnIdDefers", []); + // map of extension name to req/resp handler + _defineProperty(this, "extensions", {}); + _defineProperty(this, "desiredRoomSubscriptions", new Set()); + // the *desired* room subscriptions + _defineProperty(this, "confirmedRoomSubscriptions", new Set()); + // map of custom subscription name to the subscription + _defineProperty(this, "customSubscriptions", new Map()); + // map of room ID to custom subscription name + _defineProperty(this, "roomIdToCustomSubscription", new Map()); + _defineProperty(this, "pendingReq", void 0); + _defineProperty(this, "abortController", void 0); + this.lists = new Map(); + lists.forEach((list, key) => { + this.lists.set(key, new SlidingList(list)); + }); + } + + /** + * Add a custom room subscription, referred to by an arbitrary name. If a subscription with this + * name already exists, it is replaced. No requests are sent by calling this method. + * @param name - The name of the subscription. Only used to reference this subscription in + * useCustomSubscription. + * @param sub - The subscription information. + */ + addCustomSubscription(name, sub) { + if (this.customSubscriptions.has(name)) { + _logger.logger.warn(`addCustomSubscription: ${name} already exists as a custom subscription, ignoring.`); + return; + } + this.customSubscriptions.set(name, sub); + } + + /** + * Use a custom subscription previously added via addCustomSubscription. No requests are sent + * by calling this method. Use modifyRoomSubscriptions to resend subscription information. + * @param roomId - The room to use the subscription in. + * @param name - The name of the subscription. If this name is unknown, the default subscription + * will be used. + */ + useCustomSubscription(roomId, name) { + // We already know about this custom subscription, as it is immutable, + // we don't need to unconfirm the subscription. + if (this.roomIdToCustomSubscription.get(roomId) === name) { + return; + } + this.roomIdToCustomSubscription.set(roomId, name); + // unconfirm this subscription so a resend() will send it up afresh. + this.confirmedRoomSubscriptions.delete(roomId); + } + + /** + * Get the room index data for a list. + * @param key - The list key + * @returns The list data which contains the rooms in this list + */ + getListData(key) { + const data = this.lists.get(key); + if (!data) { + return null; + } + return { + joinedCount: data.joinedCount, + roomIndexToRoomId: Object.assign({}, data.roomIndexToRoomId) + }; + } + + /** + * Get the full request list parameters for a list index. This function is provided for callers to use + * in conjunction with setList to update fields on an existing list. + * @param key - The list key to get the params for. + * @returns A copy of the list params or undefined. + */ + getListParams(key) { + const params = this.lists.get(key); + if (!params) { + return null; + } + return params.getList(true); + } + + /** + * Set new ranges for an existing list. Calling this function when _only_ the ranges have changed + * is more efficient than calling setList(index,list) as this function won't resend sticky params, + * whereas setList always will. + * @param key - The list key to modify + * @param ranges - The new ranges to apply. + * @returns A promise which resolves to the transaction ID when it has been received down sync + * (or rejects with the transaction ID if the action was not applied e.g the request was cancelled + * immediately after sending, in which case the action will be applied in the subsequent request) + */ + setListRanges(key, ranges) { + const list = this.lists.get(key); + if (!list) { + return Promise.reject(new Error("no list with key " + key)); + } + list.updateListRange(ranges); + return this.resend(); + } + + /** + * Add or replace a list. Calling this function will interrupt the /sync request to resend new + * lists. + * @param key - The key to modify + * @param list - The new list parameters. + * @returns A promise which resolves to the transaction ID when it has been received down sync + * (or rejects with the transaction ID if the action was not applied e.g the request was cancelled + * immediately after sending, in which case the action will be applied in the subsequent request) + */ + setList(key, list) { + const existingList = this.lists.get(key); + if (existingList) { + existingList.replaceList(list); + this.lists.set(key, existingList); + } else { + this.lists.set(key, new SlidingList(list)); + } + this.listModifiedCount += 1; + return this.resend(); + } + + /** + * Get the room subscriptions for the sync API. + * @returns A copy of the desired room subscriptions. + */ + getRoomSubscriptions() { + return new Set(Array.from(this.desiredRoomSubscriptions)); + } + + /** + * Modify the room subscriptions for the sync API. Calling this function will interrupt the + * /sync request to resend new subscriptions. If the /sync stream has not started, this will + * prepare the room subscriptions for when start() is called. + * @param s - The new desired room subscriptions. + * @returns A promise which resolves to the transaction ID when it has been received down sync + * (or rejects with the transaction ID if the action was not applied e.g the request was cancelled + * immediately after sending, in which case the action will be applied in the subsequent request) + */ + modifyRoomSubscriptions(s) { + this.desiredRoomSubscriptions = s; + return this.resend(); + } + + /** + * Modify which events to retrieve for room subscriptions. Invalidates all room subscriptions + * such that they will be sent up afresh. + * @param rs - The new room subscription fields to fetch. + * @returns A promise which resolves to the transaction ID when it has been received down sync + * (or rejects with the transaction ID if the action was not applied e.g the request was cancelled + * immediately after sending, in which case the action will be applied in the subsequent request) + */ + modifyRoomSubscriptionInfo(rs) { + this.roomSubscriptionInfo = rs; + this.confirmedRoomSubscriptions = new Set(); + return this.resend(); + } + + /** + * Register an extension to send with the /sync request. + * @param ext - The extension to register. + */ + registerExtension(ext) { + if (this.extensions[ext.name()]) { + throw new Error(`registerExtension: ${ext.name()} already exists as an extension`); + } + this.extensions[ext.name()] = ext; + } + getExtensionRequest(isInitial) { + const ext = {}; + Object.keys(this.extensions).forEach(extName => { + ext[extName] = this.extensions[extName].onRequest(isInitial); + }); + return ext; + } + onPreExtensionsResponse(ext) { + Object.keys(ext).forEach(extName => { + if (this.extensions[extName].when() == ExtensionState.PreProcess) { + this.extensions[extName].onResponse(ext[extName]); + } + }); + } + onPostExtensionsResponse(ext) { + Object.keys(ext).forEach(extName => { + if (this.extensions[extName].when() == ExtensionState.PostProcess) { + this.extensions[extName].onResponse(ext[extName]); + } + }); + } + + /** + * Invoke all attached room data listeners. + * @param roomId - The room which received some data. + * @param roomData - The raw sliding sync response JSON. + */ + invokeRoomDataListeners(roomId, roomData) { + if (!roomData.required_state) { + roomData.required_state = []; + } + if (!roomData.timeline) { + roomData.timeline = []; + } + this.emit(SlidingSyncEvent.RoomData, roomId, roomData); + } + + /** + * Invoke all attached lifecycle listeners. + * @param state - The Lifecycle state + * @param resp - The raw sync response JSON + * @param err - Any error that occurred when making the request e.g. network errors. + */ + invokeLifecycleListeners(state, resp, err) { + this.emit(SlidingSyncEvent.Lifecycle, state, resp, err); + } + shiftRight(listKey, hi, low) { + const list = this.lists.get(listKey); + if (!list) { + return; + } + // l h + // 0,1,2,3,4 <- before + // 0,1,2,2,3 <- after, hi is deleted and low is duplicated + for (let i = hi; i > low; i--) { + if (list.isIndexInRange(i)) { + list.roomIndexToRoomId[i] = list.roomIndexToRoomId[i - 1]; + } + } + } + shiftLeft(listKey, hi, low) { + const list = this.lists.get(listKey); + if (!list) { + return; + } + // l h + // 0,1,2,3,4 <- before + // 0,1,3,4,4 <- after, low is deleted and hi is duplicated + for (let i = low; i < hi; i++) { + if (list.isIndexInRange(i)) { + list.roomIndexToRoomId[i] = list.roomIndexToRoomId[i + 1]; + } + } + } + removeEntry(listKey, index) { + const list = this.lists.get(listKey); + if (!list) { + return; + } + // work out the max index + let max = -1; + for (const n in list.roomIndexToRoomId) { + if (Number(n) > max) { + max = Number(n); + } + } + if (max < 0 || index > max) { + return; + } + // Everything higher than the gap needs to be shifted left. + this.shiftLeft(listKey, max, index); + delete list.roomIndexToRoomId[max]; + } + addEntry(listKey, index) { + const list = this.lists.get(listKey); + if (!list) { + return; + } + // work out the max index + let max = -1; + for (const n in list.roomIndexToRoomId) { + if (Number(n) > max) { + max = Number(n); + } + } + if (max < 0 || index > max) { + return; + } + // Everything higher than the gap needs to be shifted right, +1 so we don't delete the highest element + this.shiftRight(listKey, max + 1, index); + } + processListOps(list, listKey) { + let gapIndex = -1; + const listData = this.lists.get(listKey); + if (!listData) { + return; + } + list.ops.forEach(op => { + if (!listData) { + return; + } + switch (op.op) { + case "DELETE": + { + _logger.logger.debug("DELETE", listKey, op.index, ";"); + delete listData.roomIndexToRoomId[op.index]; + if (gapIndex !== -1) { + // we already have a DELETE operation to process, so process it. + this.removeEntry(listKey, gapIndex); + } + gapIndex = op.index; + break; + } + case "INSERT": + { + _logger.logger.debug("INSERT", listKey, op.index, op.room_id, ";"); + if (listData.roomIndexToRoomId[op.index]) { + // something is in this space, shift items out of the way + if (gapIndex < 0) { + // we haven't been told where to shift from, so make way for a new room entry. + this.addEntry(listKey, op.index); + } else if (gapIndex > op.index) { + // the gap is further down the list, shift every element to the right + // starting at the gap so we can just shift each element in turn: + // [A,B,C,_] gapIndex=3, op.index=0 + // [A,B,C,C] i=3 + // [A,B,B,C] i=2 + // [A,A,B,C] i=1 + // Terminate. We'll assign into op.index next. + this.shiftRight(listKey, gapIndex, op.index); + } else if (gapIndex < op.index) { + // the gap is further up the list, shift every element to the left + // starting at the gap so we can just shift each element in turn + this.shiftLeft(listKey, op.index, gapIndex); + } + } + // forget the gap, we don't need it anymore. This is outside the check for + // a room being present in this index position because INSERTs always universally + // forget the gap, not conditionally based on the presence of a room in the INSERT + // position. Without this, DELETE 0; INSERT 0; would do the wrong thing. + gapIndex = -1; + listData.roomIndexToRoomId[op.index] = op.room_id; + break; + } + case "INVALIDATE": + { + const startIndex = op.range[0]; + for (let i = startIndex; i <= op.range[1]; i++) { + delete listData.roomIndexToRoomId[i]; + } + _logger.logger.debug("INVALIDATE", listKey, op.range[0], op.range[1], ";"); + break; + } + case "SYNC": + { + const startIndex = op.range[0]; + for (let i = startIndex; i <= op.range[1]; i++) { + const roomId = op.room_ids[i - startIndex]; + if (!roomId) { + break; // we are at the end of list + } + + listData.roomIndexToRoomId[i] = roomId; + } + _logger.logger.debug("SYNC", listKey, op.range[0], op.range[1], (op.room_ids || []).join(" "), ";"); + break; + } + } + }); + if (gapIndex !== -1) { + // we already have a DELETE operation to process, so process it + // Everything higher than the gap needs to be shifted left. + this.removeEntry(listKey, gapIndex); + } + } + + /** + * Resend a Sliding Sync request. Used when something has changed in the request. Resolves with + * the transaction ID of this request on success. Rejects with the transaction ID of this request + * on failure. + */ + resend() { + if (this.needsResend && this.txnIdDefers.length > 0) { + // we already have a resend queued, so just return the same promise + return this.txnIdDefers[this.txnIdDefers.length - 1].promise; + } + this.needsResend = true; + this.txnId = this.client.makeTxnId(); + const d = (0, _utils.defer)(); + this.txnIdDefers.push(_objectSpread(_objectSpread({}, d), {}, { + txnId: this.txnId + })); + this.abortController?.abort(); + this.abortController = new AbortController(); + return d.promise; + } + resolveTransactionDefers(txnId) { + if (!txnId) { + return; + } + // find the matching index + let txnIndex = -1; + for (let i = 0; i < this.txnIdDefers.length; i++) { + if (this.txnIdDefers[i].txnId === txnId) { + txnIndex = i; + break; + } + } + if (txnIndex === -1) { + // this shouldn't happen; we shouldn't be seeing txn_ids for things we don't know about, + // whine about it. + _logger.logger.warn(`resolveTransactionDefers: seen ${txnId} but it isn't a pending txn, ignoring.`); + return; + } + // This list is sorted in time, so if the input txnId ACKs in the middle of this array, + // then everything before it that hasn't been ACKed yet never will and we should reject them. + for (let i = 0; i < txnIndex; i++) { + this.txnIdDefers[i].reject(this.txnIdDefers[i].txnId); + } + this.txnIdDefers[txnIndex].resolve(txnId); + // clear out settled promises, including the one we resolved. + this.txnIdDefers = this.txnIdDefers.slice(txnIndex + 1); + } + + /** + * Stop syncing with the server. + */ + stop() { + this.terminated = true; + this.abortController?.abort(); + // remove all listeners so things can be GC'd + this.removeAllListeners(SlidingSyncEvent.Lifecycle); + this.removeAllListeners(SlidingSyncEvent.List); + this.removeAllListeners(SlidingSyncEvent.RoomData); + } + + /** + * Re-setup this connection e.g in the event of an expired session. + */ + resetup() { + _logger.logger.warn("SlidingSync: resetting connection info"); + // any pending txn ID defers will be forgotten already by the server, so clear them out + this.txnIdDefers.forEach(d => { + d.reject(d.txnId); + }); + this.txnIdDefers = []; + // resend sticky params and de-confirm all subscriptions + this.lists.forEach(l => { + l.setModified(true); + }); + this.confirmedRoomSubscriptions = new Set(); // leave desired ones alone though! + // reset the connection as we might be wedged + this.needsResend = true; + this.abortController?.abort(); + this.abortController = new AbortController(); + } + + /** + * Start syncing with the server. Blocks until stopped. + */ + async start() { + this.abortController = new AbortController(); + let currentPos; + while (!this.terminated) { + this.needsResend = false; + let doNotUpdateList = false; + let resp; + try { + const listModifiedCount = this.listModifiedCount; + const reqLists = {}; + this.lists.forEach((l, key) => { + reqLists[key] = l.getList(false); + }); + const reqBody = { + lists: reqLists, + pos: currentPos, + timeout: this.timeoutMS, + clientTimeout: this.timeoutMS + BUFFER_PERIOD_MS, + extensions: this.getExtensionRequest(currentPos === undefined) + }; + // check if we are (un)subscribing to a room and modify request this one time for it + const newSubscriptions = difference(this.desiredRoomSubscriptions, this.confirmedRoomSubscriptions); + const unsubscriptions = difference(this.confirmedRoomSubscriptions, this.desiredRoomSubscriptions); + if (unsubscriptions.size > 0) { + reqBody.unsubscribe_rooms = Array.from(unsubscriptions); + } + if (newSubscriptions.size > 0) { + reqBody.room_subscriptions = {}; + for (const roomId of newSubscriptions) { + const customSubName = this.roomIdToCustomSubscription.get(roomId); + let sub = this.roomSubscriptionInfo; + if (customSubName && this.customSubscriptions.has(customSubName)) { + sub = this.customSubscriptions.get(customSubName); + } + reqBody.room_subscriptions[roomId] = sub; + } + } + if (this.txnId) { + reqBody.txn_id = this.txnId; + this.txnId = null; + } + this.pendingReq = this.client.slidingSync(reqBody, this.proxyBaseUrl, this.abortController.signal); + resp = await this.pendingReq; + currentPos = resp.pos; + // update what we think we're subscribed to. + for (const roomId of newSubscriptions) { + this.confirmedRoomSubscriptions.add(roomId); + } + for (const roomId of unsubscriptions) { + this.confirmedRoomSubscriptions.delete(roomId); + } + if (listModifiedCount !== this.listModifiedCount) { + // the lists have been modified whilst we were waiting for 'await' to return, but the abort() + // call did nothing. It is NOT SAFE to modify the list array now. We'll process the response but + // not update list pointers. + _logger.logger.debug("list modified during await call, not updating list"); + doNotUpdateList = true; + } + // mark all these lists as having been sent as sticky so we don't keep sending sticky params + this.lists.forEach(l => { + l.setModified(false); + }); + // set default empty values so we don't need to null check + resp.lists = resp.lists || {}; + resp.rooms = resp.rooms || {}; + resp.extensions = resp.extensions || {}; + Object.keys(resp.lists).forEach(key => { + const list = this.lists.get(key); + if (!list || !resp) { + return; + } + list.joinedCount = resp.lists[key].count; + }); + this.invokeLifecycleListeners(SlidingSyncState.RequestFinished, resp); + } catch (err) { + if (err.httpStatus) { + this.invokeLifecycleListeners(SlidingSyncState.RequestFinished, null, err); + if (err.httpStatus === 400) { + // session probably expired TODO: assign an errcode + // so drop state and re-request + this.resetup(); + currentPos = undefined; + await (0, _utils.sleep)(50); // in case the 400 was for something else; don't tightloop + continue; + } // else fallthrough to generic error handling + } else if (this.needsResend || err.name === "AbortError") { + continue; // don't sleep as we caused this error by abort()ing the request. + } + + _logger.logger.error(err); + await (0, _utils.sleep)(5000); + } + if (!resp) { + continue; + } + this.onPreExtensionsResponse(resp.extensions); + Object.keys(resp.rooms).forEach(roomId => { + this.invokeRoomDataListeners(roomId, resp.rooms[roomId]); + }); + const listKeysWithUpdates = new Set(); + if (!doNotUpdateList) { + for (const [key, list] of Object.entries(resp.lists)) { + list.ops = list.ops || []; + if (list.ops.length > 0) { + listKeysWithUpdates.add(key); + } + this.processListOps(list, key); + } + } + this.invokeLifecycleListeners(SlidingSyncState.Complete, resp); + this.onPostExtensionsResponse(resp.extensions); + listKeysWithUpdates.forEach(listKey => { + const list = this.lists.get(listKey); + if (!list) { + return; + } + this.emit(SlidingSyncEvent.List, listKey, list.joinedCount, Object.assign({}, list.roomIndexToRoomId)); + }); + this.resolveTransactionDefers(resp.txn_id); + } + } +} +exports.SlidingSync = SlidingSync; +const difference = (setA, setB) => { + const diff = new Set(setA); + for (const elem of setB) { + diff.delete(elem); + } + return diff; +};
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/store/index.js b/comm/chat/protocols/matrix/lib/matrix-sdk/store/index.js new file mode 100644 index 0000000000..430afc16cd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/store/index.js @@ -0,0 +1,5 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-backend.js b/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-backend.js new file mode 100644 index 0000000000..430afc16cd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-backend.js @@ -0,0 +1,5 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-local-backend.js b/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-local-backend.js new file mode 100644 index 0000000000..ecc5538734 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-local-backend.js @@ -0,0 +1,569 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.LocalIndexedDBStoreBackend = void 0; +var _syncAccumulator = require("../sync-accumulator"); +var _utils = require("../utils"); +var _indexeddbHelpers = require("../indexeddb-helpers"); +var _logger = require("../logger"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2017 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +const DB_MIGRATIONS = [db => { + // Make user store, clobber based on user ID. (userId property of User objects) + db.createObjectStore("users", { + keyPath: ["userId"] + }); + + // Make account data store, clobber based on event type. + // (event.type property of MatrixEvent objects) + db.createObjectStore("accountData", { + keyPath: ["type"] + }); + + // Make /sync store (sync tokens, room data, etc), always clobber (const key). + db.createObjectStore("sync", { + keyPath: ["clobber"] + }); +}, db => { + const oobMembersStore = db.createObjectStore("oob_membership_events", { + keyPath: ["room_id", "state_key"] + }); + oobMembersStore.createIndex("room", "room_id"); +}, db => { + db.createObjectStore("client_options", { + keyPath: ["clobber"] + }); +}, db => { + db.createObjectStore("to_device_queue", { + autoIncrement: true + }); +} +// Expand as needed. +]; + +const VERSION = DB_MIGRATIONS.length; + +/** + * Helper method to collect results from a Cursor and promiseify it. + * @param store - The store to perform openCursor on. + * @param keyRange - Optional key range to apply on the cursor. + * @param resultMapper - A function which is repeatedly called with a + * Cursor. + * Return the data you want to keep. + * @returns Promise which resolves to an array of whatever you returned from + * resultMapper. + */ +function selectQuery(store, keyRange, resultMapper) { + const query = store.openCursor(keyRange); + return new Promise((resolve, reject) => { + const results = []; + query.onerror = () => { + reject(new Error("Query failed: " + query.error)); + }; + // collect results + query.onsuccess = () => { + const cursor = query.result; + if (!cursor) { + resolve(results); + return; // end of results + } + + results.push(resultMapper(cursor)); + cursor.continue(); + }; + }); +} +function txnAsPromise(txn) { + return new Promise((resolve, reject) => { + txn.oncomplete = function (event) { + resolve(event); + }; + txn.onerror = function () { + reject(txn.error); + }; + }); +} +function reqAsEventPromise(req) { + return new Promise((resolve, reject) => { + req.onsuccess = function (event) { + resolve(event); + }; + req.onerror = function () { + reject(req.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 => req.result); +} +class LocalIndexedDBStoreBackend { + static exists(indexedDB, dbName) { + dbName = "matrix-js-sdk:" + (dbName || "default"); + return (0, _indexeddbHelpers.exists)(indexedDB, dbName); + } + /** + * Does the actual reading from and writing to the indexeddb + * + * Construct a new Indexed Database store backend. This requires a call to + * `connect()` before this store can be used. + * @param indexedDB - The Indexed DB interface e.g + * `window.indexedDB` + * @param dbName - Optional database name. The same name must be used + * to open the same database. + */ + constructor(indexedDB, dbName = "default") { + this.indexedDB = indexedDB; + _defineProperty(this, "dbName", void 0); + _defineProperty(this, "syncAccumulator", void 0); + _defineProperty(this, "db", void 0); + _defineProperty(this, "disconnected", true); + _defineProperty(this, "_isNewlyCreated", false); + _defineProperty(this, "syncToDatabasePromise", void 0); + _defineProperty(this, "pendingUserPresenceData", []); + this.dbName = "matrix-js-sdk:" + dbName; + this.syncAccumulator = new _syncAccumulator.SyncAccumulator(); + } + + /** + * Attempt to connect to the database. This can fail if the user does not + * grant permission. + * @returns Promise which resolves if successfully connected. + */ + connect(onClose) { + if (!this.disconnected) { + _logger.logger.log(`LocalIndexedDBStoreBackend.connect: already connected or connecting`); + return Promise.resolve(); + } + this.disconnected = false; + _logger.logger.log(`LocalIndexedDBStoreBackend.connect: connecting...`); + const req = this.indexedDB.open(this.dbName, VERSION); + req.onupgradeneeded = ev => { + const db = req.result; + const oldVersion = ev.oldVersion; + _logger.logger.log(`LocalIndexedDBStoreBackend.connect: upgrading from ${oldVersion}`); + if (oldVersion < 1) { + // The database did not previously exist + this._isNewlyCreated = true; + } + DB_MIGRATIONS.forEach((migration, index) => { + if (oldVersion <= index) migration(db); + }); + }; + req.onblocked = () => { + _logger.logger.log(`can't yet open LocalIndexedDBStoreBackend because it is open elsewhere`); + }; + _logger.logger.log(`LocalIndexedDBStoreBackend.connect: awaiting connection...`); + return reqAsEventPromise(req).then(async () => { + _logger.logger.log(`LocalIndexedDBStoreBackend.connect: connected`); + this.db = req.result; + + // add a poorly-named listener for when deleteDatabase is called + // so we can close our db connections. + this.db.onversionchange = () => { + this.db?.close(); // this does not call onclose + this.disconnected = true; + this.db = undefined; + onClose?.(); + }; + this.db.onclose = () => { + this.disconnected = true; + this.db = undefined; + onClose?.(); + }; + await this.init(); + }); + } + + /** @returns whether or not the database was newly created in this session. */ + isNewlyCreated() { + return Promise.resolve(this._isNewlyCreated); + } + + /** + * Having connected, load initial data from the database and prepare for use + * @returns Promise which resolves on success + */ + init() { + return Promise.all([this.loadAccountData(), this.loadSyncData()]).then(([accountData, syncData]) => { + _logger.logger.log(`LocalIndexedDBStoreBackend: loaded initial data`); + this.syncAccumulator.accumulate({ + next_batch: syncData.nextBatch, + rooms: syncData.roomsData, + account_data: { + events: accountData + } + }, true); + }); + } + + /** + * Returns the out-of-band membership events for this room that + * were previously loaded. + * @returns the events, potentially an empty array if OOB loading didn't yield any new members + * @returns in case the members for this room haven't been stored yet + */ + getOutOfBandMembers(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 = () => { + const cursor = request.result; + if (!cursor) { + // Unknown room + if (!membershipEvents.length && !oobWritten) { + return resolve(null); + } + return resolve(membershipEvents); + } + const record = cursor.value; + if (record.oob_written) { + oobWritten = true; + } else { + membershipEvents.push(record); + } + cursor.continue(); + }; + request.onerror = err => { + reject(err); + }; + }).then(events => { + _logger.logger.log(`LL: got ${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 membershipEvents - the membership events to store + */ + async setOutOfBandMembers(roomId, membershipEvents) { + _logger.logger.log(`LL: backend about to store ${membershipEvents.length}` + ` members for ${roomId}`); + const tx = this.db.transaction(["oob_membership_events"], "readwrite"); + const store = tx.objectStore("oob_membership_events"); + membershipEvents.forEach(e => { + store.put(e); + }); + // aside from all the events, we also write a marker object to the store + // to mark the fact that OOB members have been written for this room. + // It's possible that 0 members need to be written as all where previously know + // but we still need to know whether to return null or [] from getOutOfBandMembers + // where null means out of band members haven't been stored yet for this room + const markerObject = { + room_id: roomId, + oob_written: true, + state_key: 0 + }; + store.put(markerObject); + await txnAsPromise(tx); + _logger.logger.log(`LL: backend done storing for ${roomId}!`); + } + async clearOutOfBandMembers(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?.primaryKey)[1]); + const maxStateKeyProm = reqAsCursorPromise(roomIndex.openKeyCursor(roomRange, "prev")).then(cursor => (cursor?.primaryKey)[1]); + const [minStateKey, maxStateKey] = await Promise.all([minStateKeyProm, maxStateKeyProm]); + const writeTx = this.db.transaction(["oob_membership_events"], "readwrite"); + const writeStore = writeTx.objectStore("oob_membership_events"); + const membersKeyRange = IDBKeyRange.bound([roomId, minStateKey], [roomId, maxStateKey]); + _logger.logger.log(`LL: Deleting all users + marker in storage for room ${roomId}, with key range:`, [roomId, minStateKey], [roomId, maxStateKey]); + await reqAsPromise(writeStore.delete(membersKeyRange)); + } + + /** + * Clear the entire database. This should be used when logging out of a client + * to prevent mixing data between accounts. + * @returns Resolved when the database is cleared. + */ + clearDatabase() { + return new Promise(resolve => { + _logger.logger.log(`Removing indexeddb instance: ${this.dbName}`); + const req = this.indexedDB.deleteDatabase(this.dbName); + req.onblocked = () => { + _logger.logger.log(`can't yet delete indexeddb ${this.dbName} because it is open elsewhere`); + }; + req.onerror = () => { + // in firefox, with indexedDB disabled, this fails with a + // DOMError. We treat this as non-fatal, so that we can still + // use the app. + _logger.logger.warn(`unable to delete js-sdk store indexeddb: ${req.error}`); + resolve(); + }; + req.onsuccess = () => { + _logger.logger.log(`Removed indexeddb instance: ${this.dbName}`); + resolve(); + }; + }); + } + + /** + * @param copy - If false, the data returned is from internal + * buffers and must not be mutated. Otherwise, a copy is made before + * returning such that the data can be safely mutated. Default: true. + * + * @returns Promise which resolves with a sync response to restore the + * client state to where it was at the last save, or null if there + * is no saved sync data. + */ + getSavedSync(copy = true) { + const data = this.syncAccumulator.getJSON(); + if (!data.nextBatch) return Promise.resolve(null); + if (copy) { + // We must deep copy the stored data so that the /sync processing code doesn't + // corrupt the internal state of the sync accumulator (it adds non-clonable keys) + return Promise.resolve((0, _utils.deepCopy)(data)); + } else { + return Promise.resolve(data); + } + } + getNextBatchToken() { + return Promise.resolve(this.syncAccumulator.getNextBatchToken()); + } + setSyncData(syncData) { + return Promise.resolve().then(() => { + this.syncAccumulator.accumulate(syncData); + }); + } + + /** + * Sync users and all accumulated sync data to the database. + * If a previous sync is in flight, the new data will be added to the + * next sync and the current sync's promise will be returned. + * @param userTuples - The user tuples + * @returns Promise which resolves if the data was persisted. + */ + async syncToDatabase(userTuples) { + if (this.syncToDatabasePromise) { + _logger.logger.warn("Skipping syncToDatabase() as persist already in flight"); + this.pendingUserPresenceData.push(...userTuples); + return this.syncToDatabasePromise; + } + userTuples.unshift(...this.pendingUserPresenceData); + this.syncToDatabasePromise = this.doSyncToDatabase(userTuples); + return this.syncToDatabasePromise; + } + async doSyncToDatabase(userTuples) { + try { + const syncData = this.syncAccumulator.getJSON(true); + await Promise.all([this.persistUserPresenceEvents(userTuples), this.persistAccountData(syncData.accountData), this.persistSyncData(syncData.nextBatch, syncData.roomsData)]); + } finally { + this.syncToDatabasePromise = undefined; + } + } + + /** + * Persist rooms /sync data along with the next batch token. + * @param nextBatch - The next_batch /sync value. + * @param roomsData - The 'rooms' /sync data from a SyncAccumulator + * @returns Promise which resolves if the data was persisted. + */ + persistSyncData(nextBatch, roomsData) { + _logger.logger.log("Persisting sync data up to", nextBatch); + return (0, _utils.promiseTry)(() => { + const txn = this.db.transaction(["sync"], "readwrite"); + const store = txn.objectStore("sync"); + store.put({ + clobber: "-", + // constant key so will always clobber + nextBatch, + roomsData + }); // put == UPSERT + return txnAsPromise(txn).then(() => { + _logger.logger.log("Persisted sync data up to", nextBatch); + }); + }); + } + + /** + * Persist a list of account data events. Events with the same 'type' will + * be replaced. + * @param accountData - An array of raw user-scoped account data events + * @returns Promise which resolves if the events were persisted. + */ + persistAccountData(accountData) { + return (0, _utils.promiseTry)(() => { + const txn = this.db.transaction(["accountData"], "readwrite"); + const store = txn.objectStore("accountData"); + for (const event of accountData) { + store.put(event); // put == UPSERT + } + + return txnAsPromise(txn).then(); + }); + } + + /** + * Persist a list of [user id, presence event] they are for. + * Users with the same 'userId' will be replaced. + * Presence events should be the event in its raw form (not the Event + * object) + * @param tuples - An array of [userid, event] tuples + * @returns Promise which resolves if the users were persisted. + */ + persistUserPresenceEvents(tuples) { + return (0, _utils.promiseTry)(() => { + const txn = this.db.transaction(["users"], "readwrite"); + const store = txn.objectStore("users"); + for (const tuple of tuples) { + store.put({ + userId: tuple[0], + event: tuple[1] + }); // put == UPSERT + } + + return txnAsPromise(txn).then(); + }); + } + + /** + * Load all user presence events from the database. This is not cached. + * FIXME: It would probably be more sensible to store the events in the + * sync. + * @returns A list of presence events in their raw form. + */ + getUserPresenceEvents() { + return (0, _utils.promiseTry)(() => { + const txn = this.db.transaction(["users"], "readonly"); + const store = txn.objectStore("users"); + return selectQuery(store, undefined, cursor => { + return [cursor.value.userId, cursor.value.event]; + }); + }); + } + + /** + * Load all the account data events from the database. This is not cached. + * @returns A list of raw global account events. + */ + loadAccountData() { + _logger.logger.log(`LocalIndexedDBStoreBackend: loading account data...`); + return (0, _utils.promiseTry)(() => { + const txn = this.db.transaction(["accountData"], "readonly"); + const store = txn.objectStore("accountData"); + return selectQuery(store, undefined, cursor => { + return cursor.value; + }).then(result => { + _logger.logger.log(`LocalIndexedDBStoreBackend: loaded account data`); + return result; + }); + }); + } + + /** + * Load the sync data from the database. + * @returns An object with "roomsData" and "nextBatch" keys. + */ + loadSyncData() { + _logger.logger.log(`LocalIndexedDBStoreBackend: loading sync data...`); + return (0, _utils.promiseTry)(() => { + const txn = this.db.transaction(["sync"], "readonly"); + const store = txn.objectStore("sync"); + return selectQuery(store, undefined, cursor => { + return cursor.value; + }).then(results => { + _logger.logger.log(`LocalIndexedDBStoreBackend: loaded sync data`); + if (results.length > 1) { + _logger.logger.warn("loadSyncData: More than 1 sync row found."); + } + return results.length > 0 ? results[0] : {}; + }); + }); + } + getClientOptions() { + return Promise.resolve().then(() => { + const txn = this.db.transaction(["client_options"], "readonly"); + const store = txn.objectStore("client_options"); + return selectQuery(store, undefined, cursor => { + return cursor.value?.options; + }).then(results => results[0]); + }); + } + async storeClientOptions(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); + } + async saveToDeviceBatches(batches) { + const txn = this.db.transaction(["to_device_queue"], "readwrite"); + const store = txn.objectStore("to_device_queue"); + for (const batch of batches) { + store.add(batch); + } + await txnAsPromise(txn); + } + async getOldestToDeviceBatch() { + const txn = this.db.transaction(["to_device_queue"], "readonly"); + const store = txn.objectStore("to_device_queue"); + const cursor = await reqAsCursorPromise(store.openCursor()); + if (!cursor) return null; + const resultBatch = cursor.value; + return { + id: cursor.key, + txnId: resultBatch.txnId, + eventType: resultBatch.eventType, + batch: resultBatch.batch + }; + } + async removeToDeviceBatch(id) { + const txn = this.db.transaction(["to_device_queue"], "readwrite"); + const store = txn.objectStore("to_device_queue"); + store.delete(id); + await txnAsPromise(txn); + } + + /* + * Close the database + */ + async destroy() { + this.db?.close(); + } +} +exports.LocalIndexedDBStoreBackend = LocalIndexedDBStoreBackend;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-remote-backend.js b/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-remote-backend.js new file mode 100644 index 0000000000..378a41e8d1 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-remote-backend.js @@ -0,0 +1,200 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.RemoteIndexedDBStoreBackend = void 0; +var _logger = require("../logger"); +var _utils = require("../utils"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2017 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + 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. + */ +class RemoteIndexedDBStoreBackend { + // Callback for when the IndexedDB gets closed unexpectedly + + /** + * An IndexedDB store backend where the actual backend sits in a web + * worker. + * + * Construct a new Indexed Database store backend. This requires a call to + * `connect()` before this store can be used. + * @param workerFactory - Factory which produces a Worker + * @param dbName - Optional database name. The same name must be used + * to open the same database. + */ + constructor(workerFactory, dbName) { + this.workerFactory = workerFactory; + this.dbName = dbName; + _defineProperty(this, "worker", void 0); + _defineProperty(this, "nextSeq", 0); + // The currently in-flight requests to the actual backend + _defineProperty(this, "inFlight", {}); + // seq: promise + // Once we start connecting, we keep the promise and re-use it + // if we try to connect again + _defineProperty(this, "startPromise", void 0); + _defineProperty(this, "onWorkerMessage", ev => { + const msg = ev.data; + if (msg.command == "closed") { + this.onClose?.(); + } else if (msg.command == "cmd_success" || msg.command == "cmd_fail") { + if (msg.seq === undefined) { + _logger.logger.error("Got reply from worker with no seq"); + return; + } + const def = this.inFlight[msg.seq]; + if (def === undefined) { + _logger.logger.error("Got reply for unknown seq " + msg.seq); + return; + } + delete this.inFlight[msg.seq]; + if (msg.command == "cmd_success") { + def.resolve(msg.result); + } else { + const error = new Error(msg.error.message); + error.name = msg.error.name; + def.reject(error); + } + } else { + _logger.logger.warn("Unrecognised message from worker: ", msg); + } + }); + } + + /** + * Attempt to connect to the database. This can fail if the user does not + * grant permission. + * @returns Promise which resolves if successfully connected. + */ + connect(onClose) { + this.onClose = onClose; + return this.ensureStarted().then(() => this.doCmd("connect")); + } + + /** + * Clear the entire database. This should be used when logging out of a client + * to prevent mixing data between accounts. + * @returns Resolved when the database is cleared. + */ + clearDatabase() { + return this.ensureStarted().then(() => this.doCmd("clearDatabase")); + } + + /** @returns whether or not the database was newly created in this session. */ + isNewlyCreated() { + return this.doCmd("isNewlyCreated"); + } + + /** + * @returns Promise which resolves with a sync response to restore the + * client state to where it was at the last save, or null if there + * is no saved sync data. + */ + getSavedSync() { + return this.doCmd("getSavedSync"); + } + getNextBatchToken() { + return this.doCmd("getNextBatchToken"); + } + setSyncData(syncData) { + return this.doCmd("setSyncData", [syncData]); + } + syncToDatabase(userTuples) { + return this.doCmd("syncToDatabase", [userTuples]); + } + + /** + * Returns the out-of-band membership events for this room that + * were previously loaded. + * @returns the events, potentially an empty array if OOB loading didn't yield any new members + * @returns in case the members for this room haven't been stored yet + */ + getOutOfBandMembers(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 membershipEvents - the membership events to store + * @returns when all members have been stored + */ + setOutOfBandMembers(roomId, membershipEvents) { + return this.doCmd("setOutOfBandMembers", [roomId, membershipEvents]); + } + clearOutOfBandMembers(roomId) { + return this.doCmd("clearOutOfBandMembers", [roomId]); + } + getClientOptions() { + return this.doCmd("getClientOptions"); + } + storeClientOptions(options) { + return this.doCmd("storeClientOptions", [options]); + } + + /** + * Load all user presence events from the database. This is not cached. + * @returns A list of presence events in their raw form. + */ + getUserPresenceEvents() { + return this.doCmd("getUserPresenceEvents"); + } + async saveToDeviceBatches(batches) { + return this.doCmd("saveToDeviceBatches", [batches]); + } + async getOldestToDeviceBatch() { + return this.doCmd("getOldestToDeviceBatch"); + } + async removeToDeviceBatch(id) { + return this.doCmd("removeToDeviceBatch", [id]); + } + ensureStarted() { + if (!this.startPromise) { + this.worker = this.workerFactory(); + this.worker.onmessage = this.onWorkerMessage; + + // tell the worker the db name. + this.startPromise = this.doCmd("setupWorker", [this.dbName]).then(() => { + _logger.logger.log("IndexedDB worker is ready"); + }); + } + return this.startPromise; + } + doCmd(command, args) { + // wrap in a q so if the postMessage throws, + // the promise automatically gets rejected + return Promise.resolve().then(() => { + const seq = this.nextSeq++; + const def = (0, _utils.defer)(); + this.inFlight[seq] = def; + this.worker?.postMessage({ + command, + seq, + args + }); + return def.promise; + }); + } + /* + * Destroy the web worker + */ + async destroy() { + this.worker?.terminate(); + } +} +exports.RemoteIndexedDBStoreBackend = RemoteIndexedDBStoreBackend;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-store-worker.js b/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-store-worker.js new file mode 100644 index 0000000000..4708d58936 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-store-worker.js @@ -0,0 +1,151 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.IndexedDBStoreWorker = void 0; +var _indexeddbLocalBackend = require("./indexeddb-local-backend"); +var _logger = require("../logger"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2017 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/** + * This class lives in the webworker and drives a LocalIndexedDBStoreBackend + * controlled by messages from the main process. + * + * @example + * It should be instantiated by a web worker script provided by the application + * in a script, for example: + * ``` + * import {IndexedDBStoreWorker} from 'matrix-js-sdk/lib/indexeddb-worker.js'; + * const remoteWorker = new IndexedDBStoreWorker(postMessage); + * onmessage = remoteWorker.onMessage; + * ``` + * + * Note that it is advisable to import this class by referencing the file directly to + * avoid a dependency on the whole js-sdk. + * + */ +class IndexedDBStoreWorker { + /** + * @param postMessage - The web worker postMessage function that + * should be used to communicate back to the main script. + */ + constructor(postMessage) { + this.postMessage = postMessage; + _defineProperty(this, "backend", void 0); + _defineProperty(this, "onClose", () => { + this.postMessage.call(null, { + command: "closed" + }); + }); + /** + * Passes a message event from the main script into the class. This method + * can be directly assigned to the web worker `onmessage` variable. + * + * @param ev - The message event + */ + _defineProperty(this, "onMessage", ev => { + const msg = ev.data; + let prom; + switch (msg.command) { + case "setupWorker": + // this is the 'indexedDB' global (where global != window + // because it's a web worker and there is no window). + this.backend = new _indexeddbLocalBackend.LocalIndexedDBStoreBackend(indexedDB, msg.args[0]); + prom = Promise.resolve(); + break; + case "connect": + prom = this.backend?.connect(this.onClose); + break; + case "isNewlyCreated": + prom = this.backend?.isNewlyCreated(); + break; + case "clearDatabase": + prom = this.backend?.clearDatabase(); + break; + case "getSavedSync": + prom = this.backend?.getSavedSync(false); + break; + case "setSyncData": + prom = this.backend?.setSyncData(msg.args[0]); + break; + case "syncToDatabase": + prom = this.backend?.syncToDatabase(msg.args[0]); + break; + case "getUserPresenceEvents": + prom = this.backend?.getUserPresenceEvents(); + break; + case "getNextBatchToken": + prom = this.backend?.getNextBatchToken(); + break; + case "getOutOfBandMembers": + prom = this.backend?.getOutOfBandMembers(msg.args[0]); + break; + case "clearOutOfBandMembers": + prom = this.backend?.clearOutOfBandMembers(msg.args[0]); + break; + case "setOutOfBandMembers": + prom = this.backend?.setOutOfBandMembers(msg.args[0], msg.args[1]); + break; + case "getClientOptions": + prom = this.backend?.getClientOptions(); + break; + case "storeClientOptions": + prom = this.backend?.storeClientOptions(msg.args[0]); + break; + case "saveToDeviceBatches": + prom = this.backend?.saveToDeviceBatches(msg.args[0]); + break; + case "getOldestToDeviceBatch": + prom = this.backend?.getOldestToDeviceBatch(); + break; + case "removeToDeviceBatch": + prom = this.backend?.removeToDeviceBatch(msg.args[0]); + break; + } + if (prom === undefined) { + this.postMessage({ + command: "cmd_fail", + seq: msg.seq, + // Can't be an Error because they're not structured cloneable + error: "Unrecognised command" + }); + return; + } + prom.then(ret => { + this.postMessage.call(null, { + command: "cmd_success", + seq: msg.seq, + result: ret + }); + }, err => { + _logger.logger.error("Error running command: " + msg.command, err); + this.postMessage.call(null, { + command: "cmd_fail", + seq: msg.seq, + // Just send a string because Error objects aren't cloneable + error: { + message: err.message, + name: err.name + } + }); + }); + }); + } +} +exports.IndexedDBStoreWorker = IndexedDBStoreWorker;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb.js b/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb.js new file mode 100644 index 0000000000..3c52a70546 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb.js @@ -0,0 +1,329 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.IndexedDBStore = void 0; +var _memory = require("./memory"); +var _indexeddbLocalBackend = require("./indexeddb-local-backend"); +var _indexeddbRemoteBackend = require("./indexeddb-remote-backend"); +var _user = require("../models/user"); +var _event = require("../models/event"); +var _logger = require("../logger"); +var _typedEventEmitter = require("../models/typed-event-emitter"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2017 - 2021 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ /* eslint-disable @babel/no-invalid-this */ +/** + * This is an internal module. See {@link IndexedDBStore} for the public class. + */ + +// If this value is too small we'll be writing very often which will cause +// noticeable stop-the-world pauses. If this value is too big we'll be writing +// so infrequently that the /sync size gets bigger on reload. Writing more +// often does not affect the length of the pause since the entire /sync +// response is persisted each time. +const WRITE_DELAY_MS = 1000 * 60 * 5; // once every 5 minutes + +class IndexedDBStore extends _memory.MemoryStore { + static exists(indexedDB, dbName) { + return _indexeddbLocalBackend.LocalIndexedDBStoreBackend.exists(indexedDB, dbName); + } + + /** + * The backend instance. + * Call through to this API if you need to perform specific indexeddb actions like deleting the database. + */ + + /** + * Construct a new Indexed Database store, which extends MemoryStore. + * + * This store functions like a MemoryStore except it periodically persists + * the contents of the store to an IndexedDB backend. + * + * All data is still kept in-memory but can be loaded from disk by calling + * `startup()`. This can make startup times quicker as a complete + * sync from the server is not required. This does not reduce memory usage as all + * the data is eagerly fetched when `startup()` is called. + * ``` + * let opts = { indexedDB: window.indexedDB, localStorage: window.localStorage }; + * let store = new IndexedDBStore(opts); + * await store.startup(); // load from indexed db + * let client = sdk.createClient({ + * store: store, + * }); + * client.startClient(); + * client.on("sync", function(state, prevState, data) { + * if (state === "PREPARED") { + * console.log("Started up, now with go faster stripes!"); + * } + * }); + * ``` + * + * @param opts - Options object. + */ + constructor(opts) { + super(opts); + _defineProperty(this, "backend", void 0); + _defineProperty(this, "startedUp", false); + _defineProperty(this, "syncTs", 0); + // Records the last-modified-time of each user at the last point we saved + // the database, such that we can derive the set if users that have been + // modified since we last saved. + _defineProperty(this, "userModifiedMap", {}); + // user_id : timestamp + _defineProperty(this, "emitter", new _typedEventEmitter.TypedEventEmitter()); + _defineProperty(this, "on", this.emitter.on.bind(this.emitter)); + _defineProperty(this, "onClose", () => { + this.emitter.emit("closed"); + }); + /** + * @returns Promise which resolves with a sync response to restore the + * client state to where it was at the last save, or null if there + * is no saved sync data. + */ + _defineProperty(this, "getSavedSync", this.degradable(() => { + return this.backend.getSavedSync(); + }, "getSavedSync")); + /** @returns whether or not the database was newly created in this session. */ + _defineProperty(this, "isNewlyCreated", this.degradable(() => { + return this.backend.isNewlyCreated(); + }, "isNewlyCreated")); + /** + * @returns If there is a saved sync, the nextBatch token + * for this sync, otherwise null. + */ + _defineProperty(this, "getSavedSyncToken", this.degradable(() => { + return this.backend.getNextBatchToken(); + }, "getSavedSyncToken")); + /** + * Delete all data from this store. + * @returns Promise which resolves if the data was deleted from the database. + */ + _defineProperty(this, "deleteAllData", this.degradable(() => { + super.deleteAllData(); + return this.backend.clearDatabase().then(() => { + _logger.logger.log("Deleted indexeddb data."); + }, err => { + _logger.logger.error(`Failed to delete indexeddb data: ${err}`); + throw err; + }); + })); + _defineProperty(this, "reallySave", this.degradable(() => { + this.syncTs = Date.now(); // set now to guard against multi-writes + + // work out changed users (this doesn't handle deletions but you + // can't 'delete' users as they are just presence events). + const userTuples = []; + for (const u of this.getUsers()) { + if (this.userModifiedMap[u.userId] === u.getLastModifiedTime()) continue; + if (!u.events.presence) continue; + userTuples.push([u.userId, u.events.presence.event]); + + // note that we've saved this version of the user + this.userModifiedMap[u.userId] = u.getLastModifiedTime(); + } + return this.backend.syncToDatabase(userTuples); + })); + _defineProperty(this, "setSyncData", this.degradable(syncData => { + return this.backend.setSyncData(syncData); + }, "setSyncData")); + /** + * Returns the out-of-band membership events for this room that + * were previously loaded. + * @returns the events, potentially an empty array if OOB loading didn't yield any new members + * @returns in case the members for this room haven't been stored yet + */ + _defineProperty(this, "getOutOfBandMembers", this.degradable(roomId => { + return this.backend.getOutOfBandMembers(roomId); + }, "getOutOfBandMembers")); + /** + * Stores the out-of-band membership events for this room. Note that + * it still makes sense to store an empty array as the OOB status for the room is + * marked as fetched, and getOutOfBandMembers will return an empty array instead of null + * @param membershipEvents - the membership events to store + * @returns when all members have been stored + */ + _defineProperty(this, "setOutOfBandMembers", this.degradable((roomId, membershipEvents) => { + super.setOutOfBandMembers(roomId, membershipEvents); + return this.backend.setOutOfBandMembers(roomId, membershipEvents); + }, "setOutOfBandMembers")); + _defineProperty(this, "clearOutOfBandMembers", this.degradable(roomId => { + super.clearOutOfBandMembers(roomId); + return this.backend.clearOutOfBandMembers(roomId); + }, "clearOutOfBandMembers")); + _defineProperty(this, "getClientOptions", this.degradable(() => { + return this.backend.getClientOptions(); + }, "getClientOptions")); + _defineProperty(this, "storeClientOptions", this.degradable(options => { + super.storeClientOptions(options); + return this.backend.storeClientOptions(options); + }, "storeClientOptions")); + if (!opts.indexedDB) { + throw new Error("Missing required option: indexedDB"); + } + if (opts.workerFactory) { + this.backend = new _indexeddbRemoteBackend.RemoteIndexedDBStoreBackend(opts.workerFactory, opts.dbName); + } else { + this.backend = new _indexeddbLocalBackend.LocalIndexedDBStoreBackend(opts.indexedDB, opts.dbName); + } + } + /** + * @returns Resolved when loaded from indexed db. + */ + startup() { + if (this.startedUp) { + _logger.logger.log(`IndexedDBStore.startup: already started`); + return Promise.resolve(); + } + _logger.logger.log(`IndexedDBStore.startup: connecting to backend`); + return this.backend.connect(this.onClose).then(() => { + _logger.logger.log(`IndexedDBStore.startup: loading presence events`); + return this.backend.getUserPresenceEvents(); + }).then(userPresenceEvents => { + _logger.logger.log(`IndexedDBStore.startup: processing presence events`); + userPresenceEvents.forEach(([userId, rawEvent]) => { + const u = new _user.User(userId); + if (rawEvent) { + u.setPresenceEvent(new _event.MatrixEvent(rawEvent)); + } + this.userModifiedMap[u.userId] = u.getLastModifiedTime(); + this.storeUser(u); + }); + this.startedUp = true; + }); + } + + /* + * Close the database and destroy any associated workers + */ + destroy() { + return this.backend.destroy(); + } + /** + * Whether this store would like to save its data + * Note that obviously whether the store wants to save or + * not could change between calling this function and calling + * save(). + * + * @returns True if calling save() will actually save + * (at the time this function is called). + */ + wantsSave() { + const now = Date.now(); + return now - this.syncTs > WRITE_DELAY_MS; + } + + /** + * Possibly write data to the database. + * + * @param force - True to force a save to happen + * @returns Promise resolves after the write completes + * (or immediately if no write is performed) + */ + save(force = false) { + if (force || this.wantsSave()) { + return this.reallySave(); + } + return Promise.resolve(); + } + /** + * All member functions of `IndexedDBStore` that access the backend use this wrapper to + * watch for failures after initial store startup, including `QuotaExceededError` as + * free disk space changes, etc. + * + * When IndexedDB fails via any of these paths, we degrade this back to a `MemoryStore` + * in place so that the current operation and all future ones are in-memory only. + * + * @param func - The degradable work to do. + * @param fallback - The method name for fallback. + * @returns A wrapped member function. + */ + degradable(func, fallback) { + const fallbackFn = fallback ? super[fallback] : null; + return async (...args) => { + try { + return await func.call(this, ...args); + } catch (e) { + _logger.logger.error("IndexedDBStore failure, degrading to MemoryStore", e); + this.emitter.emit("degraded", e); + try { + // We try to delete IndexedDB after degrading since this store is only a + // cache (the app will still function correctly without the data). + // It's possible that deleting repair IndexedDB for the next app load, + // potentially by making a little more space available. + _logger.logger.log("IndexedDBStore trying to delete degraded data"); + await this.backend.clearDatabase(); + _logger.logger.log("IndexedDBStore delete after degrading succeeded"); + } catch (e) { + _logger.logger.warn("IndexedDBStore delete after degrading failed", e); + } + // Degrade the store from being an instance of `IndexedDBStore` to instead be + // an instance of `MemoryStore` so that future API calls use the memory path + // directly and skip IndexedDB entirely. This should be safe as + // `IndexedDBStore` already extends from `MemoryStore`, so we are making the + // store become its parent type in a way. The mutator methods of + // `IndexedDBStore` also maintain the state that `MemoryStore` uses (many are + // not overridden at all). + if (fallbackFn) { + return fallbackFn.call(this, ...args); + } + } + }; + } + + // XXX: ideally these would be stored in indexeddb as part of the room but, + // we don't store rooms as such and instead accumulate entire sync responses atm. + async getPendingEvents(roomId) { + if (!this.localStorage) return super.getPendingEvents(roomId); + const serialized = this.localStorage.getItem(pendingEventsKey(roomId)); + if (serialized) { + try { + return JSON.parse(serialized); + } catch (e) { + _logger.logger.error("Could not parse persisted pending events", e); + } + } + return []; + } + async setPendingEvents(roomId, events) { + if (!this.localStorage) return super.setPendingEvents(roomId, events); + if (events.length > 0) { + this.localStorage.setItem(pendingEventsKey(roomId), JSON.stringify(events)); + } else { + this.localStorage.removeItem(pendingEventsKey(roomId)); + } + } + saveToDeviceBatches(batches) { + return this.backend.saveToDeviceBatches(batches); + } + getOldestToDeviceBatch() { + return this.backend.getOldestToDeviceBatch(); + } + removeToDeviceBatch(id) { + return this.backend.removeToDeviceBatch(id); + } +} + +/** + * @param roomId - ID of the current room + * @returns Storage key to retrieve pending events + */ +exports.IndexedDBStore = IndexedDBStore; +function pendingEventsKey(roomId) { + return `mx_pending_events_${roomId}`; +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/store/local-storage-events-emitter.js b/comm/chat/protocols/matrix/lib/matrix-sdk/store/local-storage-events-emitter.js new file mode 100644 index 0000000000..bb366d32dd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/store/local-storage-events-emitter.js @@ -0,0 +1,43 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.localStorageErrorsEventsEmitter = exports.LocalStorageErrors = void 0; +var _typedEventEmitter = require("../models/typed-event-emitter"); +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +let LocalStorageErrors = /*#__PURE__*/function (LocalStorageErrors) { + LocalStorageErrors["Global"] = "Global"; + LocalStorageErrors["SetItemError"] = "setItem"; + LocalStorageErrors["GetItemError"] = "getItem"; + LocalStorageErrors["RemoveItemError"] = "removeItem"; + LocalStorageErrors["ClearError"] = "clear"; + LocalStorageErrors["QuotaExceededError"] = "QuotaExceededError"; + return LocalStorageErrors; +}({}); +exports.LocalStorageErrors = LocalStorageErrors; +/** + * Used in element-web as a temporary hack to handle all the localStorage errors on the highest level possible + * As of 15.11.2021 (DD/MM/YYYY) we're not properly handling local storage exceptions anywhere. + * This store, as an event emitter, is used to re-emit local storage exceptions so that we can handle them + * and show some kind of a "It's dead Jim" modal to the users, telling them that hey, + * maybe you should check out your disk, as it's probably dying and your session may die with it. + * See: https://github.com/vector-im/element-web/issues/18423 + */ +class LocalStorageErrorsEventsEmitter extends _typedEventEmitter.TypedEventEmitter {} +const localStorageErrorsEventsEmitter = new LocalStorageErrorsEventsEmitter(); +exports.localStorageErrorsEventsEmitter = localStorageErrorsEventsEmitter;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/store/memory.js b/comm/chat/protocols/matrix/lib/matrix-sdk/store/memory.js new file mode 100644 index 0000000000..68836ac093 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/store/memory.js @@ -0,0 +1,418 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.MemoryStore = void 0; +var _user = require("../models/user"); +var _roomState = require("../models/room-state"); +var _utils = require("../utils"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2015 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ /** + * This is an internal module. See {@link MemoryStore} for the public class. + */ +function isValidFilterId(filterId) { + const isValidStr = typeof filterId === "string" && !!filterId && filterId !== "undefined" && + // exclude these as we've serialized undefined in localStorage before + filterId !== "null"; + return isValidStr || typeof filterId === "number"; +} +class MemoryStore { + /** + * Construct a new in-memory data store for the Matrix Client. + * @param opts - Config options + */ + constructor(opts = {}) { + _defineProperty(this, "rooms", {}); + // roomId: Room + _defineProperty(this, "users", {}); + // userId: User + _defineProperty(this, "syncToken", null); + // userId: { + // filterId: Filter + // } + _defineProperty(this, "filters", new _utils.MapWithDefault(() => new Map())); + _defineProperty(this, "accountData", new Map()); + // type: content + _defineProperty(this, "localStorage", void 0); + _defineProperty(this, "oobMembers", new Map()); + // roomId: [member events] + _defineProperty(this, "pendingEvents", {}); + _defineProperty(this, "clientOptions", void 0); + _defineProperty(this, "pendingToDeviceBatches", []); + _defineProperty(this, "nextToDeviceBatchId", 0); + /** + * Called when a room member in a room being tracked by this store has been + * updated. + */ + _defineProperty(this, "onRoomMember", (event, state, member) => { + if (member.membership === "invite") { + // We do NOT add invited members because people love to typo user IDs + // which would then show up in these lists (!) + return; + } + const user = this.users[member.userId] || new _user.User(member.userId); + if (member.name) { + user.setDisplayName(member.name); + if (member.events.member) { + user.setRawDisplayName(member.events.member.getDirectionalContent().displayname); + } + } + if (member.events.member && member.events.member.getContent().avatar_url) { + user.setAvatarUrl(member.events.member.getContent().avatar_url); + } + this.users[user.userId] = user; + }); + this.localStorage = opts.localStorage; + } + + /** + * Retrieve the token to stream from. + * @returns The token or null. + */ + getSyncToken() { + return this.syncToken; + } + + /** @returns whether or not the database was newly created in this session. */ + isNewlyCreated() { + return Promise.resolve(true); + } + + /** + * Set the token to stream from. + * @param token - The token to stream from. + */ + setSyncToken(token) { + this.syncToken = token; + } + + /** + * Store the given room. + * @param room - The room to be stored. All properties must be stored. + */ + storeRoom(room) { + this.rooms[room.roomId] = room; + // add listeners for room member changes so we can keep the room member + // map up-to-date. + room.currentState.on(_roomState.RoomStateEvent.Members, this.onRoomMember); + // add existing members + room.currentState.getMembers().forEach(m => { + this.onRoomMember(null, room.currentState, m); + }); + } + /** + * Retrieve a room by its' room ID. + * @param roomId - The room ID. + * @returns The room or null. + */ + getRoom(roomId) { + return this.rooms[roomId] || null; + } + + /** + * Retrieve all known rooms. + * @returns A list of rooms, which may be empty. + */ + getRooms() { + return Object.values(this.rooms); + } + + /** + * Permanently delete a room. + */ + removeRoom(roomId) { + if (this.rooms[roomId]) { + this.rooms[roomId].currentState.removeListener(_roomState.RoomStateEvent.Members, this.onRoomMember); + } + delete this.rooms[roomId]; + } + + /** + * Retrieve a summary of all the rooms. + * @returns A summary of each room. + */ + getRoomSummaries() { + return Object.values(this.rooms).map(function (room) { + return room.summary; + }); + } + + /** + * Store a User. + * @param user - The user to store. + */ + storeUser(user) { + this.users[user.userId] = user; + } + + /** + * Retrieve a User by its' user ID. + * @param userId - The user ID. + * @returns The user or null. + */ + getUser(userId) { + return this.users[userId] || null; + } + + /** + * Retrieve all known users. + * @returns A list of users, which may be empty. + */ + getUsers() { + return Object.values(this.users); + } + + /** + * Retrieve scrollback for this room. + * @param room - The matrix room + * @param limit - The max number of old events to retrieve. + * @returns An array of objects which will be at most 'limit' + * length and at least 0. The objects are the raw event JSON. + */ + scrollback(room, limit) { + return []; + } + + /** + * Store events for a room. The events have already been added to the timeline + * @param room - The room to store events for. + * @param events - The events to store. + * @param token - The token associated with these events. + * @param toStart - True if these are paginated results. + */ + storeEvents(room, events, token, toStart) { + // no-op because they've already been added to the room instance. + } + + /** + * Store a filter. + */ + storeFilter(filter) { + if (!filter?.userId || !filter?.filterId) return; + this.filters.getOrCreate(filter.userId).set(filter.filterId, filter); + } + + /** + * Retrieve a filter. + * @returns A filter or null. + */ + getFilter(userId, filterId) { + return this.filters.get(userId)?.get(filterId) || null; + } + + /** + * Retrieve a filter ID with the given name. + * @param filterName - The filter name. + * @returns The filter ID or null. + */ + getFilterIdByName(filterName) { + if (!this.localStorage) { + return null; + } + const key = "mxjssdk_memory_filter_" + filterName; + // XXX Storage.getItem doesn't throw ... + // or are we using something different + // than window.localStorage in some cases + // that does throw? + // that would be very naughty + try { + const value = this.localStorage.getItem(key); + if (isValidFilterId(value)) { + return value; + } + } catch (e) {} + return null; + } + + /** + * Set a filter name to ID mapping. + */ + setFilterIdByName(filterName, filterId) { + if (!this.localStorage) { + return; + } + const key = "mxjssdk_memory_filter_" + filterName; + try { + if (isValidFilterId(filterId)) { + this.localStorage.setItem(key, filterId); + } else { + this.localStorage.removeItem(key); + } + } catch (e) {} + } + + /** + * Store user-scoped account data events. + * N.B. that account data only allows a single event per type, so multiple + * events with the same type will replace each other. + * @param events - The events to store. + */ + storeAccountDataEvents(events) { + events.forEach(event => { + // MSC3391: an event with content of {} should be interpreted as deleted + const isDeleted = !Object.keys(event.getContent()).length; + if (isDeleted) { + this.accountData.delete(event.getType()); + } else { + this.accountData.set(event.getType(), event); + } + }); + } + + /** + * Get account data event by event type + * @param eventType - The event type being queried + * @returns the user account_data event of given type, if any + */ + getAccountData(eventType) { + return this.accountData.get(eventType); + } + + /** + * setSyncData does nothing as there is no backing data store. + * + * @param syncData - The sync data + * @returns An immediately resolved promise. + */ + setSyncData(syncData) { + return Promise.resolve(); + } + + /** + * We never want to save becase we have nothing to save to. + * + * @returns If the store wants to save + */ + wantsSave() { + return false; + } + + /** + * Save does nothing as there is no backing data store. + * @param force - True to force a save (but the memory + * store still can't save anything) + */ + save(force) { + return Promise.resolve(); + } + + /** + * Startup does nothing as this store doesn't require starting up. + * @returns An immediately resolved promise. + */ + startup() { + return Promise.resolve(); + } + + /** + * @returns Promise which resolves with a sync response to restore the + * client state to where it was at the last save, or null if there + * is no saved sync data. + */ + getSavedSync() { + return Promise.resolve(null); + } + + /** + * @returns If there is a saved sync, the nextBatch token + * for this sync, otherwise null. + */ + getSavedSyncToken() { + return Promise.resolve(null); + } + + /** + * Delete all data from this store. + * @returns An immediately resolved promise. + */ + deleteAllData() { + this.rooms = { + // roomId: Room + }; + this.users = { + // userId: User + }; + this.syncToken = null; + this.filters = new _utils.MapWithDefault(() => new Map()); + this.accountData = new Map(); // type : content + return Promise.resolve(); + } + + /** + * Returns the out-of-band membership events for this room that + * were previously loaded. + * @returns the events, potentially an empty array if OOB loading didn't yield any new members + * @returns in case the members for this room haven't been stored yet + */ + getOutOfBandMembers(roomId) { + return Promise.resolve(this.oobMembers.get(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 membershipEvents - the membership events to store + * @returns when all members have been stored + */ + setOutOfBandMembers(roomId, membershipEvents) { + this.oobMembers.set(roomId, membershipEvents); + return Promise.resolve(); + } + clearOutOfBandMembers(roomId) { + this.oobMembers.delete(roomId); + return Promise.resolve(); + } + getClientOptions() { + return Promise.resolve(this.clientOptions); + } + storeClientOptions(options) { + this.clientOptions = Object.assign({}, options); + return Promise.resolve(); + } + async getPendingEvents(roomId) { + return this.pendingEvents[roomId] ?? []; + } + async setPendingEvents(roomId, events) { + this.pendingEvents[roomId] = events; + } + saveToDeviceBatches(batches) { + for (const batch of batches) { + this.pendingToDeviceBatches.push({ + id: this.nextToDeviceBatchId++, + eventType: batch.eventType, + txnId: batch.txnId, + batch: batch.batch + }); + } + return Promise.resolve(); + } + async getOldestToDeviceBatch() { + if (this.pendingToDeviceBatches.length === 0) return null; + return this.pendingToDeviceBatches[0]; + } + removeToDeviceBatch(id) { + this.pendingToDeviceBatches = this.pendingToDeviceBatches.filter(batch => batch.id !== id); + return Promise.resolve(); + } + async destroy() { + // Nothing to do + } +} +exports.MemoryStore = MemoryStore;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/store/stub.js b/comm/chat/protocols/matrix/lib/matrix-sdk/store/stub.js new file mode 100644 index 0000000000..9bf1df937d --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/store/stub.js @@ -0,0 +1,262 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.StubStore = void 0; +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } +/* +Copyright 2015 - 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * This is an internal module. + */ + +/** + * Construct a stub store. This does no-ops on most store methods. + */ +class StubStore { + constructor() { + _defineProperty(this, "accountData", new Map()); + // stub + _defineProperty(this, "fromToken", null); + } + /** @returns whether or not the database was newly created in this session. */ + isNewlyCreated() { + return Promise.resolve(true); + } + + /** + * Get the sync token. + */ + getSyncToken() { + return this.fromToken; + } + + /** + * Set the sync token. + */ + setSyncToken(token) { + this.fromToken = token; + } + + /** + * No-op. + */ + storeRoom(room) {} + + /** + * No-op. + */ + getRoom(roomId) { + return null; + } + + /** + * No-op. + * @returns An empty array. + */ + getRooms() { + return []; + } + + /** + * Permanently delete a room. + */ + removeRoom(roomId) { + return; + } + + /** + * No-op. + * @returns An empty array. + */ + getRoomSummaries() { + return []; + } + + /** + * No-op. + */ + storeUser(user) {} + + /** + * No-op. + */ + getUser(userId) { + return null; + } + + /** + * No-op. + */ + getUsers() { + return []; + } + + /** + * No-op. + */ + scrollback(room, limit) { + return []; + } + + /** + * Store events for a room. + * @param room - The room to store events for. + * @param events - The events to store. + * @param token - The token associated with these events. + * @param toStart - True if these are paginated results. + */ + storeEvents(room, events, token, toStart) {} + + /** + * Store a filter. + */ + storeFilter(filter) {} + + /** + * Retrieve a filter. + * @returns A filter or null. + */ + getFilter(userId, filterId) { + return null; + } + + /** + * Retrieve a filter ID with the given name. + * @param filterName - The filter name. + * @returns The filter ID or null. + */ + getFilterIdByName(filterName) { + return null; + } + + /** + * Set a filter name to ID mapping. + */ + setFilterIdByName(filterName, filterId) {} + + /** + * Store user-scoped account data events + * @param events - The events to store. + */ + storeAccountDataEvents(events) {} + + /** + * Get account data event by event type + * @param eventType - The event type being queried + */ + getAccountData(eventType) { + return undefined; + } + + /** + * setSyncData does nothing as there is no backing data store. + * + * @param syncData - The sync data + * @returns An immediately resolved promise. + */ + setSyncData(syncData) { + return Promise.resolve(); + } + + /** + * We never want to save because we have nothing to save to. + * + * @returns If the store wants to save + */ + wantsSave() { + return false; + } + + /** + * Save does nothing as there is no backing data store. + */ + save() { + return Promise.resolve(); + } + + /** + * Startup does nothing. + * @returns An immediately resolved promise. + */ + startup() { + return Promise.resolve(); + } + + /** + * @returns Promise which resolves with a sync response to restore the + * client state to where it was at the last save, or null if there + * is no saved sync data. + */ + getSavedSync() { + return Promise.resolve(null); + } + + /** + * @returns If there is a saved sync, the nextBatch token + * for this sync, otherwise null. + */ + getSavedSyncToken() { + return Promise.resolve(null); + } + + /** + * Delete all data from this store. Does nothing since this store + * doesn't store anything. + * @returns An immediately resolved promise. + */ + deleteAllData() { + return Promise.resolve(); + } + getOutOfBandMembers() { + return Promise.resolve(null); + } + setOutOfBandMembers(roomId, membershipEvents) { + return Promise.resolve(); + } + clearOutOfBandMembers() { + return Promise.resolve(); + } + getClientOptions() { + return Promise.resolve(undefined); + } + storeClientOptions(options) { + return Promise.resolve(); + } + async getPendingEvents(roomId) { + return []; + } + setPendingEvents(roomId, events) { + return Promise.resolve(); + } + async saveToDeviceBatches(batch) { + return Promise.resolve(); + } + getOldestToDeviceBatch() { + return Promise.resolve(null); + } + async removeToDeviceBatch(id) { + return Promise.resolve(); + } + async destroy() { + // Nothing to do + } +} +exports.StubStore = StubStore;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/sync-accumulator.js b/comm/chat/protocols/matrix/lib/matrix-sdk/sync-accumulator.js new file mode 100644 index 0000000000..8fb0d93909 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/sync-accumulator.js @@ -0,0 +1,474 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.SyncAccumulator = exports.Category = void 0; +var _logger = require("./logger"); +var _utils = require("./utils"); +var _sync = require("./@types/sync"); +var _receiptAccumulator = require("./receipt-accumulator"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2017 - 2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ /** + * This is an internal module. See {@link SyncAccumulator} for the public class. + */ +/* eslint-disable camelcase */ +/* eslint-enable camelcase */ +let Category = /*#__PURE__*/function (Category) { + Category["Invite"] = "invite"; + Category["Leave"] = "leave"; + Category["Join"] = "join"; + return Category; +}({}); +exports.Category = Category; +function isTaggedEvent(event) { + return "_localTs" in event && event["_localTs"] !== undefined; +} + +/** + * The purpose of this class is to accumulate /sync responses such that a + * complete "initial" JSON response can be returned which accurately represents + * the sum total of the /sync responses accumulated to date. It only handles + * room data: that is, everything under the "rooms" top-level key. + * + * This class is used when persisting room data so a complete /sync response can + * be loaded from disk and incremental syncs can be performed on the server, + * rather than asking the server to do an initial sync on startup. + */ +class SyncAccumulator { + constructor(opts = {}) { + this.opts = opts; + _defineProperty(this, "accountData", {}); + // $event_type: Object + _defineProperty(this, "inviteRooms", {}); + // $roomId: { ... sync 'invite' json data ... } + _defineProperty(this, "joinRooms", {}); + // the /sync token which corresponds to the last time rooms were + // accumulated. We remember this so that any caller can obtain a + // coherent /sync response and know at what point they should be + // streaming from without losing events. + _defineProperty(this, "nextBatch", null); + this.opts.maxTimelineEntries = this.opts.maxTimelineEntries || 50; + } + accumulate(syncResponse, fromDatabase = false) { + this.accumulateRooms(syncResponse, fromDatabase); + this.accumulateAccountData(syncResponse); + this.nextBatch = syncResponse.next_batch; + } + accumulateAccountData(syncResponse) { + if (!syncResponse.account_data || !syncResponse.account_data.events) { + return; + } + // Clobbers based on event type. + syncResponse.account_data.events.forEach(e => { + this.accountData[e.type] = e; + }); + } + + /** + * Accumulate incremental /sync room data. + * @param syncResponse - the complete /sync JSON + * @param fromDatabase - True if the sync response is one saved to the database + */ + accumulateRooms(syncResponse, fromDatabase = false) { + if (!syncResponse.rooms) { + return; + } + if (syncResponse.rooms.invite) { + Object.keys(syncResponse.rooms.invite).forEach(roomId => { + this.accumulateRoom(roomId, Category.Invite, syncResponse.rooms.invite[roomId], fromDatabase); + }); + } + if (syncResponse.rooms.join) { + Object.keys(syncResponse.rooms.join).forEach(roomId => { + this.accumulateRoom(roomId, Category.Join, syncResponse.rooms.join[roomId], fromDatabase); + }); + } + if (syncResponse.rooms.leave) { + Object.keys(syncResponse.rooms.leave).forEach(roomId => { + this.accumulateRoom(roomId, Category.Leave, syncResponse.rooms.leave[roomId], fromDatabase); + }); + } + } + accumulateRoom(roomId, category, data, fromDatabase = false) { + // Valid /sync state transitions + // +--------+ <======+ 1: Accept an invite + // +== | INVITE | | (5) 2: Leave a room + // | +--------+ =====+ | 3: Join a public room previously + // |(1) (4) | | left (handle as if new room) + // V (2) V | 4: Reject an invite + // +------+ ========> +--------+ 5: Invite to a room previously + // | JOIN | (3) | LEAVE* | left (handle as if new room) + // +------+ <======== +--------+ + // + // * equivalent to "no state" + switch (category) { + case Category.Invite: + // (5) + this.accumulateInviteState(roomId, data); + break; + case Category.Join: + if (this.inviteRooms[roomId]) { + // (1) + // was previously invite, now join. We expect /sync to give + // the entire state and timeline on 'join', so delete previous + // invite state + delete this.inviteRooms[roomId]; + } + // (3) + this.accumulateJoinState(roomId, data, fromDatabase); + break; + case Category.Leave: + if (this.inviteRooms[roomId]) { + // (4) + delete this.inviteRooms[roomId]; + } else { + // (2) + delete this.joinRooms[roomId]; + } + break; + default: + _logger.logger.error("Unknown cateogory: ", category); + } + } + accumulateInviteState(roomId, data) { + if (!data.invite_state || !data.invite_state.events) { + // no new data + return; + } + if (!this.inviteRooms[roomId]) { + this.inviteRooms[roomId] = { + invite_state: data.invite_state + }; + return; + } + // accumulate extra keys for invite->invite transitions + // clobber based on event type / state key + // We expect invite_state to be small, so just loop over the events + const currentData = this.inviteRooms[roomId]; + data.invite_state.events.forEach(e => { + let hasAdded = false; + for (let i = 0; i < currentData.invite_state.events.length; i++) { + const current = currentData.invite_state.events[i]; + if (current.type === e.type && current.state_key == e.state_key) { + currentData.invite_state.events[i] = e; // update + hasAdded = true; + } + } + if (!hasAdded) { + currentData.invite_state.events.push(e); + } + }); + } + + // Accumulate timeline and state events in a room. + accumulateJoinState(roomId, data, fromDatabase = false) { + // We expect this function to be called a lot (every /sync) so we want + // this to be fast. /sync stores events in an array but we often want + // to clobber based on type/state_key. Rather than convert arrays to + // maps all the time, just keep private maps which contain + // the actual current accumulated sync state, and array-ify it when + // getJSON() is called. + + // State resolution: + // The 'state' key is the delta from the previous sync (or start of time + // if no token was supplied), to the START of the timeline. To obtain + // the current state, we need to "roll forward" state by reading the + // timeline. We want to store the current state so we can drop events + // out the end of the timeline based on opts.maxTimelineEntries. + // + // 'state' 'timeline' current state + // |-------x<======================>x + // T I M E + // + // When getJSON() is called, we 'roll back' the current state by the + // number of entries in the timeline to work out what 'state' should be. + + // Back-pagination: + // On an initial /sync, the server provides a back-pagination token for + // the start of the timeline. When /sync deltas come down, they also + // include back-pagination tokens for the start of the timeline. This + // means not all events in the timeline have back-pagination tokens, as + // it is only the ones at the START of the timeline which have them. + // In order for us to have a valid timeline (and back-pagination token + // to match), we need to make sure that when we remove old timeline + // events, that we roll forward to an event which has a back-pagination + // token. This means we can't keep a strict sliding-window based on + // opts.maxTimelineEntries, and we may have a few less. We should never + // have more though, provided that the /sync limit is less than or equal + // to opts.maxTimelineEntries. + + if (!this.joinRooms[roomId]) { + // Create truly empty objects so event types of 'hasOwnProperty' and co + // don't cause this code to break. + this.joinRooms[roomId] = { + _currentState: Object.create(null), + _timeline: [], + _accountData: Object.create(null), + _unreadNotifications: {}, + _unreadThreadNotifications: {}, + _summary: {}, + _receipts: new _receiptAccumulator.ReceiptAccumulator() + }; + } + const currentData = this.joinRooms[roomId]; + if (data.account_data && data.account_data.events) { + // clobber based on type + data.account_data.events.forEach(e => { + currentData._accountData[e.type] = e; + }); + } + + // these probably clobber, spec is unclear. + if (data.unread_notifications) { + currentData._unreadNotifications = data.unread_notifications; + } + currentData._unreadThreadNotifications = data[_sync.UNREAD_THREAD_NOTIFICATIONS.stable] ?? data[_sync.UNREAD_THREAD_NOTIFICATIONS.unstable] ?? undefined; + 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]; + } + + // We purposefully do not persist m.typing events. + // Technically you could refresh a browser before the timer on a + // typing event is up, so it'll look like you aren't typing when + // you really still are. However, the alternative is worse. If + // we do persist typing events, it will look like people are + // typing forever until someone really does start typing (which + // will prompt Synapse to send down an actual m.typing event to + // clobber the one we persisted). + + // Persist the receipts + currentData._receipts.consumeEphemeralEvents(data.ephemeral?.events); + + // if we got a limited sync, we need to remove all timeline entries or else + // we will have gaps in the timeline. + if (data.timeline && data.timeline.limited) { + currentData._timeline = []; + } + + // Work out the current state. The deltas need to be applied in the order: + // - existing state which didn't come down /sync. + // - State events under the 'state' key. + // - State events in the 'timeline'. + data.state?.events?.forEach(e => { + setState(currentData._currentState, e); + }); + data.timeline?.events?.forEach((e, index) => { + // this nops if 'e' isn't a state event + setState(currentData._currentState, e); + // append the event to the timeline. The back-pagination token + // corresponds to the first event in the timeline + let transformedEvent; + if (!fromDatabase) { + transformedEvent = Object.assign({}, e); + if (transformedEvent.unsigned !== undefined) { + transformedEvent.unsigned = Object.assign({}, transformedEvent.unsigned); + } + const age = e.unsigned ? e.unsigned.age : e.age; + if (age !== undefined) transformedEvent._localTs = Date.now() - age; + } else { + transformedEvent = e; + } + currentData._timeline.push({ + event: transformedEvent, + token: index === 0 ? data.timeline.prev_batch ?? null : null + }); + }); + + // attempt to prune the timeline by jumping between events which have + // pagination tokens. + if (currentData._timeline.length > this.opts.maxTimelineEntries) { + const startIndex = currentData._timeline.length - this.opts.maxTimelineEntries; + for (let i = startIndex; i < currentData._timeline.length; i++) { + if (currentData._timeline[i].token) { + // keep all events after this, including this one + currentData._timeline = currentData._timeline.slice(i, currentData._timeline.length); + break; + } + } + } + } + + /** + * Return everything under the 'rooms' key from a /sync response which + * represents all room data that should be stored. This should be paired + * with the sync token which represents the most recent /sync response + * provided to accumulate(). + * @param forDatabase - True to generate a sync to be saved to storage + * @returns An object with a "nextBatch", "roomsData" and "accountData" + * keys. + * The "nextBatch" key is a string which represents at what point in the + * /sync stream the accumulator reached. This token should be used when + * restarting a /sync stream at startup. Failure to do so can lead to missing + * events. The "roomsData" key is an Object which represents the entire + * /sync response from the 'rooms' key onwards. The "accountData" key is + * a list of raw events which represent global account data. + */ + getJSON(forDatabase = false) { + const data = { + join: {}, + invite: {}, + // always empty. This is set by /sync when a room was previously + // in 'invite' or 'join'. On fresh startup, the client won't know + // about any previous room being in 'invite' or 'join' so we can + // just omit mentioning it at all, even if it has previously come + // down /sync. + // The notable exception is when a client is kicked or banned: + // we may want to hold onto that room so the client can clearly see + // why their room has disappeared. We don't persist it though because + // it is unclear *when* we can safely remove the room from the DB. + // Instead, we assume that if you're loading from the DB, you've + // refreshed the page, which means you've seen the kick/ban already. + leave: {} + }; + Object.keys(this.inviteRooms).forEach(roomId => { + data.invite[roomId] = this.inviteRooms[roomId]; + }); + Object.keys(this.joinRooms).forEach(roomId => { + const roomData = this.joinRooms[roomId]; + const roomJson = { + ephemeral: { + events: [] + }, + account_data: { + events: [] + }, + state: { + events: [] + }, + timeline: { + events: [], + prev_batch: null + }, + unread_notifications: roomData._unreadNotifications, + unread_thread_notifications: roomData._unreadThreadNotifications, + summary: roomData._summary + }; + // Add account data + Object.keys(roomData._accountData).forEach(evType => { + roomJson.account_data.events.push(roomData._accountData[evType]); + }); + const receiptEvent = roomData._receipts.buildAccumulatedReceiptEvent(roomId); + + // add only if we have some receipt data + if (receiptEvent) { + roomJson.ephemeral.events.push(receiptEvent); + } + + // Add timeline data + roomData._timeline.forEach(msgData => { + if (!roomJson.timeline.prev_batch) { + // the first event we add to the timeline MUST match up to + // the prev_batch token. + if (!msgData.token) { + return; // this shouldn't happen as we prune constantly. + } + + roomJson.timeline.prev_batch = msgData.token; + } + let transformedEvent; + if (!forDatabase && isTaggedEvent(msgData.event)) { + // This means we have to copy each event, so we can fix it up to + // set a correct 'age' parameter whilst keeping the local timestamp + // on our stored event. If this turns out to be a bottleneck, it could + // be optimised either by doing this in the main process after the data + // has been structured-cloned to go between the worker & main process, + // or special-casing data from saved syncs to read the local timestamp + // directly rather than turning it into age to then immediately be + // transformed back again into a local timestamp. + transformedEvent = Object.assign({}, msgData.event); + if (transformedEvent.unsigned !== undefined) { + transformedEvent.unsigned = Object.assign({}, transformedEvent.unsigned); + } + delete transformedEvent._localTs; + transformedEvent.unsigned = transformedEvent.unsigned || {}; + transformedEvent.unsigned.age = Date.now() - msgData.event._localTs; + } else { + transformedEvent = msgData.event; + } + roomJson.timeline.events.push(transformedEvent); + }); + + // Add state data: roll back current state to the start of timeline, + // by "reverse clobbering" from the end of the timeline to the start. + // Convert maps back into arrays. + const rollBackState = Object.create(null); + for (let i = roomJson.timeline.events.length - 1; i >= 0; i--) { + const timelineEvent = roomJson.timeline.events[i]; + if (timelineEvent.state_key === null || timelineEvent.state_key === undefined) { + continue; // not a state event + } + // since we're going back in time, we need to use the previous + // state value else we'll break causality. We don't have the + // complete previous state event, so we need to create one. + const prevStateEvent = (0, _utils.deepCopy)(timelineEvent); + if (prevStateEvent.unsigned) { + if (prevStateEvent.unsigned.prev_content) { + prevStateEvent.content = prevStateEvent.unsigned.prev_content; + } + if (prevStateEvent.unsigned.prev_sender) { + prevStateEvent.sender = prevStateEvent.unsigned.prev_sender; + } + } + setState(rollBackState, prevStateEvent); + } + Object.keys(roomData._currentState).forEach(evType => { + Object.keys(roomData._currentState[evType]).forEach(stateKey => { + let ev = roomData._currentState[evType][stateKey]; + if (rollBackState[evType] && rollBackState[evType][stateKey]) { + // use the reverse clobbered event instead. + ev = rollBackState[evType][stateKey]; + } + roomJson.state.events.push(ev); + }); + }); + data.join[roomId] = roomJson; + }); + + // Add account data + const accData = []; + Object.keys(this.accountData).forEach(evType => { + accData.push(this.accountData[evType]); + }); + return { + nextBatch: this.nextBatch, + roomsData: data, + accountData: accData + }; + } + getNextBatchToken() { + return this.nextBatch; + } +} +exports.SyncAccumulator = SyncAccumulator; +function setState(eventMap, event) { + if (event.state_key === null || event.state_key === undefined || !event.type) { + return; + } + if (!eventMap[event.type]) { + eventMap[event.type] = Object.create(null); + } + eventMap[event.type][event.state_key] = event; +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/sync.js b/comm/chat/protocols/matrix/lib/matrix-sdk/sync.js new file mode 100644 index 0000000000..0e68952eeb --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/sync.js @@ -0,0 +1,1594 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.SyncState = exports.SyncApi = void 0; +exports._createAndReEmitRoom = _createAndReEmitRoom; +exports.defaultClientOpts = defaultClientOpts; +exports.defaultSyncApiOpts = defaultSyncApiOpts; +var _user = require("./models/user"); +var _room = require("./models/room"); +var _utils = require("./utils"); +var _filter = require("./filter"); +var _eventTimeline = require("./models/event-timeline"); +var _logger = require("./logger"); +var _errors = require("./errors"); +var _client = require("./client"); +var _httpApi = require("./http-api"); +var _event = require("./@types/event"); +var _roomState = require("./models/room-state"); +var _roomMember = require("./models/room-member"); +var _beacon = require("./models/beacon"); +var _sync = require("./@types/sync"); +var _feature = require("./feature"); +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2015 - 2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ /* + * TODO: + * This class mainly serves to take all the syncing logic out of client.js and + * into a separate file. It's all very fluid, and this class gut wrenches a lot + * of MatrixClient props (e.g. http). Given we want to support WebSockets as + * an alternative syncing API, we may want to have a proper syncing interface + * for HTTP and WS at some point. + */ +const DEBUG = true; + +// /sync requests allow you to set a timeout= but the request may continue +// beyond that and wedge forever, so we need to track how long we are willing +// to keep open the connection. This constant is *ADDED* to the timeout= value +// to determine the max time we're willing to wait. +const BUFFER_PERIOD_MS = 80 * 1000; + +// Number of consecutive failed syncs that will lead to a syncState of ERROR as opposed +// to RECONNECTING. This is needed to inform the client of server issues when the +// keepAlive is successful but the server /sync fails. +const FAILED_SYNC_ERROR_THRESHOLD = 3; +let SyncState = /*#__PURE__*/function (SyncState) { + SyncState["Error"] = "ERROR"; + SyncState["Prepared"] = "PREPARED"; + SyncState["Stopped"] = "STOPPED"; + SyncState["Syncing"] = "SYNCING"; + SyncState["Catchup"] = "CATCHUP"; + SyncState["Reconnecting"] = "RECONNECTING"; + return SyncState; +}({}); // Room versions where "insertion", "batch", and "marker" events are controlled +// by power-levels. MSC2716 is supported in existing room versions but they +// should only have special meaning when the room creator sends them. +exports.SyncState = SyncState; +const MSC2716_ROOM_VERSIONS = ["org.matrix.msc2716v3"]; +function getFilterName(userId, suffix) { + // scope this on the user ID because people may login on many accounts + // and they all need to be stored! + return `FILTER_SYNC_${userId}` + (suffix ? "_" + suffix : ""); +} + +/* istanbul ignore next */ +function debuglog(...params) { + if (!DEBUG) return; + _logger.logger.log(...params); +} + +/** + * Options passed into the constructor of SyncApi by MatrixClient + */ +var SetPresence = /*#__PURE__*/function (SetPresence) { + SetPresence["Offline"] = "offline"; + SetPresence["Online"] = "online"; + SetPresence["Unavailable"] = "unavailable"; + return SetPresence; +}(SetPresence || {}); +/** add default settings to an IStoredClientOpts */ +function defaultClientOpts(opts) { + return _objectSpread({ + initialSyncLimit: 8, + resolveInvitesToProfiles: false, + pollTimeout: 30 * 1000, + pendingEventOrdering: _client.PendingEventOrdering.Chronological, + threadSupport: false + }, opts); +} +function defaultSyncApiOpts(syncOpts) { + return _objectSpread({ + canResetEntireTimeline: _roomId => false + }, syncOpts); +} +class SyncApi { + // flag set if the store needs to be cleared before we can start + /** + * Construct an entity which is able to sync with a homeserver. + * @param client - The matrix client instance to use. + * @param opts - client config options + * @param syncOpts - sync-specific options passed by the client + * @internal + */ + constructor(client, opts, syncOpts) { + this.client = client; + _defineProperty(this, "opts", void 0); + _defineProperty(this, "syncOpts", void 0); + _defineProperty(this, "_peekRoom", null); + _defineProperty(this, "currentSyncRequest", void 0); + _defineProperty(this, "abortController", void 0); + _defineProperty(this, "syncState", null); + _defineProperty(this, "syncStateData", void 0); + // additional data (eg. error object for failed sync) + _defineProperty(this, "catchingUp", false); + _defineProperty(this, "running", false); + _defineProperty(this, "keepAliveTimer", void 0); + _defineProperty(this, "connectionReturnedDefer", void 0); + _defineProperty(this, "notifEvents", []); + // accumulator of sync events in the current sync response + _defineProperty(this, "failedSyncCount", 0); + // Number of consecutive failed /sync requests + _defineProperty(this, "storeIsInvalid", false); + _defineProperty(this, "getPushRules", async () => { + try { + debuglog("Getting push rules..."); + const result = await this.client.getPushRules(); + debuglog("Got push rules"); + this.client.pushRules = result; + } catch (err) { + _logger.logger.error("Getting push rules failed", err); + if (this.shouldAbortSync(err)) return; + // wait for saved sync to complete before doing anything else, + // otherwise the sync state will end up being incorrect + debuglog("Waiting for saved sync before retrying push rules..."); + await this.recoverFromSyncStartupError(this.savedSyncPromise, err); + return this.getPushRules(); // try again + } + }); + _defineProperty(this, "buildDefaultFilter", () => { + const filter = new _filter.Filter(this.client.credentials.userId); + if (this.client.canSupport.get(_feature.Feature.ThreadUnreadNotifications) !== _feature.ServerSupport.Unsupported) { + filter.setUnreadThreadNotifications(true); + } + return filter; + }); + _defineProperty(this, "checkLazyLoadStatus", async () => { + debuglog("Checking lazy load status..."); + if (this.opts.lazyLoadMembers && this.client.isGuest()) { + this.opts.lazyLoadMembers = false; + } + if (this.opts.lazyLoadMembers) { + debuglog("Checking server lazy load support..."); + const supported = await this.client.doesServerSupportLazyLoading(); + if (supported) { + debuglog("Enabling lazy load on sync filter..."); + if (!this.opts.filter) { + this.opts.filter = this.buildDefaultFilter(); + } + this.opts.filter.setLazyLoadMembers(true); + } else { + debuglog("LL: lazy loading requested but not supported " + "by server, so disabling"); + this.opts.lazyLoadMembers = false; + } + } + // need to vape the store when enabling LL and wasn't enabled before + debuglog("Checking whether lazy loading has changed in store..."); + const shouldClear = await this.wasLazyLoadingToggled(this.opts.lazyLoadMembers); + if (shouldClear) { + this.storeIsInvalid = true; + const error = new _errors.InvalidStoreError(_errors.InvalidStoreState.ToggledLazyLoading, !!this.opts.lazyLoadMembers); + this.updateSyncState(SyncState.Error, { + error + }); + // bail out of the sync loop now: the app needs to respond to this error. + // we leave the state as 'ERROR' which isn't great since this normally means + // we're retrying. The client must be stopped before clearing the stores anyway + // so the app should stop the client, clear the store and start it again. + _logger.logger.warn("InvalidStoreError: store is not usable: stopping sync."); + return; + } + if (this.opts.lazyLoadMembers) { + this.syncOpts.crypto?.enableLazyLoading(); + } + try { + debuglog("Storing client options..."); + await this.client.storeClientOptions(); + debuglog("Stored client options"); + } catch (err) { + _logger.logger.error("Storing client options failed", err); + throw err; + } + }); + _defineProperty(this, "getFilter", async () => { + debuglog("Getting filter..."); + let filter; + if (this.opts.filter) { + filter = this.opts.filter; + } else { + filter = this.buildDefaultFilter(); + } + let filterId; + try { + filterId = await this.client.getOrCreateFilter(getFilterName(this.client.credentials.userId), filter); + } catch (err) { + _logger.logger.error("Getting filter failed", err); + if (this.shouldAbortSync(err)) return {}; + // wait for saved sync to complete before doing anything else, + // otherwise the sync state will end up being incorrect + debuglog("Waiting for saved sync before retrying filter..."); + await this.recoverFromSyncStartupError(this.savedSyncPromise, err); + return this.getFilter(); // try again + } + + return { + filter, + filterId + }; + }); + _defineProperty(this, "savedSyncPromise", void 0); + /** + * Event handler for the 'online' event + * This event is generally unreliable and precise behaviour + * varies between browsers, so we poll for connectivity too, + * but this might help us reconnect a little faster. + */ + _defineProperty(this, "onOnline", () => { + debuglog("Browser thinks we are back online"); + this.startKeepAlives(0); + }); + this.opts = defaultClientOpts(opts); + this.syncOpts = defaultSyncApiOpts(syncOpts); + if (client.getNotifTimelineSet()) { + client.reEmitter.reEmit(client.getNotifTimelineSet(), [_room.RoomEvent.Timeline, _room.RoomEvent.TimelineReset]); + } + } + createRoom(roomId) { + const room = _createAndReEmitRoom(this.client, roomId, this.opts); + room.on(_roomState.RoomStateEvent.Marker, (markerEvent, markerFoundOptions) => { + this.onMarkerStateEvent(room, markerEvent, markerFoundOptions); + }); + return room; + } + + /** When we see the marker state change in the room, we know there is some + * new historical messages imported by MSC2716 `/batch_send` somewhere in + * the room and we need to throw away the timeline to make sure the + * historical messages are shown when we paginate `/messages` again. + * @param room - The room where the marker event was sent + * @param markerEvent - The new marker event + * @param setStateOptions - When `timelineWasEmpty` is set + * as `true`, the given marker event will be ignored + */ + onMarkerStateEvent(room, markerEvent, { + timelineWasEmpty + } = {}) { + // We don't need to refresh the timeline if it was empty before the + // marker arrived. This could be happen in a variety of cases: + // 1. From the initial sync + // 2. If it's from the first state we're seeing after joining the room + // 3. Or whether it's coming from `syncFromCache` + if (timelineWasEmpty) { + _logger.logger.debug(`MarkerState: Ignoring markerEventId=${markerEvent.getId()} in roomId=${room.roomId} ` + `because the timeline was empty before the marker arrived which means there is nothing to refresh.`); + return; + } + const isValidMsc2716Event = + // Check whether the room version directly supports MSC2716, in + // which case, "marker" events are already auth'ed by + // power_levels + MSC2716_ROOM_VERSIONS.includes(room.getVersion()) || + // MSC2716 is also supported in all existing room versions but + // special meaning should only be given to "insertion", "batch", + // and "marker" events when they come from the room creator + markerEvent.getSender() === room.getCreator(); + + // It would be nice if we could also specifically tell whether the + // historical messages actually affected the locally cached client + // timeline or not. The problem is we can't see the prev_events of + // the base insertion event that the marker was pointing to because + // prev_events aren't available in the client API's. In most cases, + // the history won't be in people's locally cached timelines in the + // client, so we don't need to bother everyone about refreshing + // their timeline. This works for a v1 though and there are use + // cases like initially bootstrapping your bridged room where people + // are likely to encounter the historical messages affecting their + // current timeline (think someone signing up for Beeper and + // importing their Whatsapp history). + if (isValidMsc2716Event) { + // Saw new marker event, let's let the clients know they should + // refresh the timeline. + _logger.logger.debug(`MarkerState: Timeline needs to be refreshed because ` + `a new markerEventId=${markerEvent.getId()} was sent in roomId=${room.roomId}`); + room.setTimelineNeedsRefresh(true); + room.emit(_room.RoomEvent.HistoryImportedWithinTimeline, markerEvent, room); + } else { + _logger.logger.debug(`MarkerState: Ignoring markerEventId=${markerEvent.getId()} in roomId=${room.roomId} because ` + `MSC2716 is not supported in the room version or for any room version, the marker wasn't sent ` + `by the room creator.`); + } + } + + /** + * Sync rooms the user has left. + * @returns Resolved when they've been added to the store. + */ + async syncLeftRooms() { + const client = this.client; + + // grab a filter with limit=1 and include_leave=true + const filter = new _filter.Filter(this.client.credentials.userId); + filter.setTimelineLimit(1); + filter.setIncludeLeaveRooms(true); + const localTimeoutMs = this.opts.pollTimeout + BUFFER_PERIOD_MS; + const filterId = await client.getOrCreateFilter(getFilterName(client.credentials.userId, "LEFT_ROOMS"), filter); + const qps = { + timeout: 0, + // don't want to block since this is a single isolated req + filter: filterId + }; + const data = await client.http.authedRequest(_httpApi.Method.Get, "/sync", qps, undefined, { + localTimeoutMs + }); + let leaveRooms = []; + if (data.rooms?.leave) { + leaveRooms = this.mapSyncResponseToRoomArray(data.rooms.leave); + } + const rooms = await Promise.all(leaveRooms.map(async leaveObj => { + const room = leaveObj.room; + if (!leaveObj.isBrandNewRoom) { + // the intention behind syncLeftRooms is to add in rooms which were + // *omitted* from the initial /sync. Rooms the user were joined to + // but then left whilst the app is running will appear in this list + // and we do not want to bother with them since they will have the + // current state already (and may get dupe messages if we add + // yet more timeline events!), so skip them. + // NB: When we persist rooms to localStorage this will be more + // complicated... + return; + } + leaveObj.timeline = leaveObj.timeline || { + prev_batch: null, + events: [] + }; + const events = this.mapSyncEventsFormat(leaveObj.timeline, room); + const stateEvents = this.mapSyncEventsFormat(leaveObj.state, room); + + // set the back-pagination token. Do this *before* adding any + // events so that clients can start back-paginating. + room.getLiveTimeline().setPaginationToken(leaveObj.timeline.prev_batch, _eventTimeline.EventTimeline.BACKWARDS); + await this.injectRoomEvents(room, stateEvents, events); + room.recalculate(); + client.store.storeRoom(room); + client.emit(_client.ClientEvent.Room, room); + this.processEventsForNotifs(room, events); + return room; + })); + return rooms.filter(Boolean); + } + + /** + * Peek into a room. This will result in the room in question being synced so it + * is accessible via getRooms(). Live updates for the room will be provided. + * @param roomId - The room ID to peek into. + * @returns A promise which resolves once the room has been added to the + * store. + */ + peek(roomId) { + if (this._peekRoom?.roomId === roomId) { + return Promise.resolve(this._peekRoom); + } + const client = this.client; + this._peekRoom = this.createRoom(roomId); + return this.client.roomInitialSync(roomId, 20).then(response => { + // make sure things are init'd + response.messages = response.messages || { + chunk: [] + }; + response.messages.chunk = response.messages.chunk || []; + response.state = response.state || []; + + // FIXME: Mostly duplicated from injectRoomEvents but not entirely + // because "state" in this API is at the BEGINNING of the chunk + const oldStateEvents = (0, _utils.deepCopy)(response.state).map(client.getEventMapper()); + const stateEvents = response.state.map(client.getEventMapper()); + const messages = response.messages.chunk.map(client.getEventMapper()); + + // XXX: copypasted from /sync until we kill off this minging v1 API stuff) + // handle presence events (User objects) + if (Array.isArray(response.presence)) { + response.presence.map(client.getEventMapper()).forEach(function (presenceEvent) { + let user = client.store.getUser(presenceEvent.getContent().user_id); + if (user) { + user.setPresenceEvent(presenceEvent); + } else { + user = createNewUser(client, presenceEvent.getContent().user_id); + user.setPresenceEvent(presenceEvent); + client.store.storeUser(user); + } + client.emit(_client.ClientEvent.Event, presenceEvent); + }); + } + + // set the pagination token before adding the events in case people + // fire off pagination requests in response to the Room.timeline + // events. + if (response.messages.start) { + this._peekRoom.oldState.paginationToken = response.messages.start; + } + + // set the state of the room to as it was after the timeline executes + this._peekRoom.oldState.setStateEvents(oldStateEvents); + this._peekRoom.currentState.setStateEvents(stateEvents); + this.resolveInvites(this._peekRoom); + this._peekRoom.recalculate(); + + // roll backwards to diverge old state. addEventsToTimeline + // will overwrite the pagination token, so make sure it overwrites + // it with the right thing. + this._peekRoom.addEventsToTimeline(messages.reverse(), true, this._peekRoom.getLiveTimeline(), response.messages.start); + client.store.storeRoom(this._peekRoom); + client.emit(_client.ClientEvent.Room, this._peekRoom); + this.peekPoll(this._peekRoom); + return this._peekRoom; + }); + } + + /** + * Stop polling for updates in the peeked room. NOPs if there is no room being + * peeked. + */ + stopPeeking() { + this._peekRoom = null; + } + + /** + * Do a peek room poll. + * @param token - from= token + */ + peekPoll(peekRoom, token) { + if (this._peekRoom !== peekRoom) { + debuglog("Stopped peeking in room %s", peekRoom.roomId); + return; + } + + // FIXME: gut wrenching; hard-coded timeout values + this.client.http.authedRequest(_httpApi.Method.Get, "/events", { + room_id: peekRoom.roomId, + timeout: String(30 * 1000), + from: token + }, undefined, { + localTimeoutMs: 50 * 1000, + abortSignal: this.abortController?.signal + }).then(async res => { + if (this._peekRoom !== peekRoom) { + debuglog("Stopped peeking in room %s", peekRoom.roomId); + return; + } + // We have a problem that we get presence both from /events and /sync + // however, /sync only returns presence for users in rooms + // you're actually joined to. + // in order to be sure to get presence for all of the users in the + // peeked room, we handle presence explicitly here. This may result + // in duplicate presence events firing for some users, which is a + // performance drain, but such is life. + // XXX: copypasted from /sync until we can kill this minging v1 stuff. + + res.chunk.filter(function (e) { + return e.type === "m.presence"; + }).map(this.client.getEventMapper()).forEach(presenceEvent => { + let user = this.client.store.getUser(presenceEvent.getContent().user_id); + if (user) { + user.setPresenceEvent(presenceEvent); + } else { + user = createNewUser(this.client, presenceEvent.getContent().user_id); + user.setPresenceEvent(presenceEvent); + this.client.store.storeUser(user); + } + this.client.emit(_client.ClientEvent.Event, presenceEvent); + }); + + // strip out events which aren't for the given room_id (e.g presence) + // and also ephemeral events (which we're assuming is anything without + // and event ID because the /events API doesn't separate them). + const events = res.chunk.filter(function (e) { + return e.room_id === peekRoom.roomId && e.event_id; + }).map(this.client.getEventMapper()); + await peekRoom.addLiveEvents(events); + this.peekPoll(peekRoom, res.end); + }, err => { + _logger.logger.error("[%s] Peek poll failed: %s", peekRoom.roomId, err); + setTimeout(() => { + this.peekPoll(peekRoom, token); + }, 30 * 1000); + }); + } + + /** + * Returns the current state of this sync object + * @see MatrixClient#event:"sync" + */ + getSyncState() { + 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. + */ + getSyncStateData() { + return this.syncStateData ?? null; + } + async recoverFromSyncStartupError(savedSyncPromise, error) { + // Wait for the saved sync to complete - we send the pushrules and filter requests + // before the saved sync has finished so they can run in parallel, but only process + // the results after the saved sync is done. Equivalently, we wait for it to finish + // before reporting failures from these functions. + await savedSyncPromise; + const keepaliveProm = this.startKeepAlives(); + this.updateSyncState(SyncState.Error, { + error + }); + await keepaliveProm; + } + + /** + * Is the lazy loading option different than in previous session? + * @param lazyLoadMembers - current options for lazy loading + * @returns whether or not the option has changed compared to the previous session */ + async wasLazyLoadingToggled(lazyLoadMembers = false) { + // 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; + } + shouldAbortSync(error) { + if (error.errcode === "M_UNKNOWN_TOKEN") { + // The logout already happened, we just need to stop. + _logger.logger.warn("Token no longer valid - assuming logout"); + this.stop(); + this.updateSyncState(SyncState.Error, { + error + }); + return true; + } + return false; + } + /** + * Main entry point + */ + async sync() { + this.running = true; + this.abortController = new AbortController(); + global.window?.addEventListener?.("online", this.onOnline, false); + if (this.client.isGuest()) { + // no push rules for guests, no access to POST filter for guests. + return this.doSync({}); + } + + // Pull the saved sync token out first, before the worker starts sending + // all the sync data which could take a while. This will let us send our + // first incremental sync request before we've processed our saved data. + debuglog("Getting saved sync token..."); + const savedSyncTokenPromise = this.client.store.getSavedSyncToken().then(tok => { + debuglog("Got saved sync token"); + return tok; + }); + this.savedSyncPromise = this.client.store.getSavedSync().then(savedSync => { + debuglog(`Got reply from saved sync, exists? ${!!savedSync}`); + if (savedSync) { + return this.syncFromCache(savedSync); + } + }).catch(err => { + _logger.logger.error("Getting saved sync failed", err); + }); + + // We need to do one-off checks before we can begin the /sync loop. + // These are: + // 1) We need to get push rules so we can check if events should bing as we get + // them from /sync. + // 2) We need to get/create a filter which we can use for /sync. + // 3) We need to check the lazy loading option matches what was used in the + // stored sync. If it doesn't, we can't use the stored sync. + + // Now start the first incremental sync request: this can also + // take a while so if we set it going now, we can wait for it + // to finish while we process our saved sync data. + await this.getPushRules(); + await this.checkLazyLoadStatus(); + const { + filterId, + filter + } = await this.getFilter(); + if (!filter) return; // bail, getFilter failed + + // reset the notifications timeline to prepare it to paginate from + // the current point in time. + // The right solution would be to tie /sync pagination tokens into + // /notifications API somehow. + this.client.resetNotifTimelineSet(); + if (!this.currentSyncRequest) { + let firstSyncFilter = filterId; + const savedSyncToken = await savedSyncTokenPromise; + if (savedSyncToken) { + debuglog("Sending first sync request..."); + } else { + debuglog("Sending initial sync request..."); + const initialFilter = this.buildDefaultFilter(); + initialFilter.setDefinition(filter.getDefinition()); + initialFilter.setTimelineLimit(this.opts.initialSyncLimit); + // Use an inline filter, no point uploading it for a single usage + firstSyncFilter = JSON.stringify(initialFilter.getDefinition()); + } + + // Send this first sync request here so we can then wait for the saved + // sync data to finish processing before we process the results of this one. + this.currentSyncRequest = this.doSyncRequest({ + filter: firstSyncFilter + }, savedSyncToken); + } + + // Now wait for the saved sync to finish... + debuglog("Waiting for saved sync before starting sync processing..."); + await this.savedSyncPromise; + // process the first sync request and continue syncing with the normal filterId + return this.doSync({ + filter: filterId + }); + } + + /** + * Stops the sync object from syncing. + */ + stop() { + debuglog("SyncApi.stop"); + // It is necessary to check for the existance of + // global.window AND global.window.removeEventListener. + // Some platforms (e.g. React Native) register global.window, + // but do not have global.window.removeEventListener. + global.window?.removeEventListener?.("online", this.onOnline, false); + this.running = false; + this.abortController?.abort(); + if (this.keepAliveTimer) { + clearTimeout(this.keepAliveTimer); + this.keepAliveTimer = undefined; + } + } + + /** + * Retry a backed off syncing request immediately. This should only be used when + * the user <b>explicitly</b> attempts to retry their lost connection. + * @returns True if this resulted in a request being retried. + */ + retryImmediately() { + if (!this.connectionReturnedDefer) { + return false; + } + this.startKeepAlives(0); + return true; + } + /** + * Process a single set of cached sync data. + * @param savedSync - a saved sync that was persisted by a store. This + * should have been acquired via client.store.getSavedSync(). + */ + async syncFromCache(savedSync) { + debuglog("sync(): not doing HTTP hit, instead returning stored /sync data"); + const nextSyncToken = savedSync.nextBatch; + + // Set sync token for future incremental syncing + this.client.store.setSyncToken(nextSyncToken); + + // No previous sync, set old token to null + const syncEventData = { + nextSyncToken, + catchingUp: false, + fromCache: true + }; + const data = { + next_batch: nextSyncToken, + rooms: savedSync.roomsData, + account_data: { + events: savedSync.accountData + } + }; + try { + await this.processSyncResponse(syncEventData, data); + } catch (e) { + _logger.logger.error("Error processing cached sync", e); + } + + // Don't emit a prepared if we've bailed because the store is invalid: + // in this case the client will not be usable until stopped & restarted + // so this would be useless and misleading. + if (!this.storeIsInvalid) { + this.updateSyncState(SyncState.Prepared, syncEventData); + } + } + + /** + * Invoke me to do /sync calls + */ + async doSync(syncOptions) { + while (this.running) { + const syncToken = this.client.store.getSyncToken(); + let data; + try { + if (!this.currentSyncRequest) { + this.currentSyncRequest = this.doSyncRequest(syncOptions, syncToken); + } + data = await this.currentSyncRequest; + } catch (e) { + const abort = await this.onSyncError(e); + if (abort) return; + continue; + } finally { + this.currentSyncRequest = undefined; + } + + // set the sync token NOW *before* processing the events. We do this so + // if something barfs on an event we can skip it rather than constantly + // polling with the same token. + this.client.store.setSyncToken(data.next_batch); + + // Reset after a successful sync + this.failedSyncCount = 0; + const syncEventData = { + oldSyncToken: syncToken ?? undefined, + nextSyncToken: data.next_batch, + catchingUp: this.catchingUp + }; + if (this.syncOpts.crypto) { + // tell the crypto module we're about to process a sync + // response + await this.syncOpts.crypto.onSyncWillProcess(syncEventData); + } + try { + await this.processSyncResponse(syncEventData, data); + } catch (e) { + // log the exception with stack if we have it, else fall back + // to the plain description + _logger.logger.error("Caught /sync error", e); + + // Emit the exception for client handling + this.client.emit(_client.ClientEvent.SyncUnexpectedError, e); + } + + // Persist after processing as `unsigned` may get mutated + // with an `org.matrix.msc4023.thread_id` + await this.client.store.setSyncData(data); + + // update this as it may have changed + syncEventData.catchingUp = this.catchingUp; + + // emit synced events + if (!syncOptions.hasSyncedBefore) { + this.updateSyncState(SyncState.Prepared, syncEventData); + syncOptions.hasSyncedBefore = true; + } + + // tell the crypto module to do its processing. It may block (to do a + // /keys/changes request). + if (this.syncOpts.cryptoCallbacks) { + await this.syncOpts.cryptoCallbacks.onSyncCompleted(syncEventData); + } + + // keep emitting SYNCING -> SYNCING for clients who want to do bulk updates + this.updateSyncState(SyncState.Syncing, syncEventData); + if (this.client.store.wantsSave()) { + // We always save the device list (if it's dirty) before saving the sync data: + // this means we know the saved device list data is at least as fresh as the + // stored sync data which means we don't have to worry that we may have missed + // device changes. We can also skip the delay since we're not calling this very + // frequently (and we don't really want to delay the sync for it). + if (this.syncOpts.crypto) { + await this.syncOpts.crypto.saveDeviceList(0); + } + + // tell databases that everything is now in a consistent state and can be saved. + await this.client.store.save(); + } + } + if (!this.running) { + debuglog("Sync no longer running: exiting."); + if (this.connectionReturnedDefer) { + this.connectionReturnedDefer.reject(); + this.connectionReturnedDefer = undefined; + } + this.updateSyncState(SyncState.Stopped); + } + } + doSyncRequest(syncOptions, syncToken) { + const qps = this.getSyncParams(syncOptions, syncToken); + return this.client.http.authedRequest(_httpApi.Method.Get, "/sync", qps, undefined, { + localTimeoutMs: qps.timeout + BUFFER_PERIOD_MS, + abortSignal: this.abortController?.signal + }); + } + getSyncParams(syncOptions, syncToken) { + let timeout = this.opts.pollTimeout; + if (this.getSyncState() !== SyncState.Syncing || this.catchingUp) { + // unless we are happily syncing already, we want the server to return + // as quickly as possible, even if there are no events queued. This + // serves two purposes: + // + // * When the connection dies, we want to know asap when it comes back, + // so that we can hide the error from the user. (We don't want to + // have to wait for an event or a timeout). + // + // * We want to know if the server has any to_device messages queued up + // for us. We do that by calling it with a zero timeout until it + // doesn't give us any more to_device messages. + this.catchingUp = true; + timeout = 0; + } + let filter = syncOptions.filter; + if (this.client.isGuest() && !filter) { + filter = this.getGuestFilter(); + } + const qps = { + filter, + timeout + }; + if (this.opts.disablePresence) { + qps.set_presence = SetPresence.Offline; + } + if (syncToken) { + qps.since = syncToken; + } else { + // use a cachebuster for initialsyncs, to make sure that + // we don't get a stale sync + // (https://github.com/vector-im/vector-web/issues/1354) + qps._cacheBuster = Date.now(); + } + if ([SyncState.Reconnecting, SyncState.Error].includes(this.getSyncState())) { + // we think the connection is dead. If it comes back up, we won't know + // about it till /sync returns. If the timeout= is high, this could + // be a long time. Set it to 0 when doing retries so we don't have to wait + // for an event or a timeout before emiting the SYNCING event. + qps.timeout = 0; + } + return qps; + } + async onSyncError(err) { + if (!this.running) { + debuglog("Sync no longer running: exiting"); + if (this.connectionReturnedDefer) { + this.connectionReturnedDefer.reject(); + this.connectionReturnedDefer = undefined; + } + this.updateSyncState(SyncState.Stopped); + return true; // abort + } + + _logger.logger.error("/sync error %s", err); + if (this.shouldAbortSync(err)) { + return true; // abort + } + + this.failedSyncCount++; + _logger.logger.log("Number of consecutive failed sync requests:", this.failedSyncCount); + debuglog("Starting keep-alive"); + // Note that we do *not* mark the sync connection as + // lost yet: we only do this if a keepalive poke + // fails, since long lived HTTP connections will + // go away sometimes and we shouldn't treat this as + // erroneous. We set the state to 'reconnecting' + // instead, so that clients can observe this state + // if they wish. + const keepAlivePromise = this.startKeepAlives(); + this.currentSyncRequest = undefined; + // Transition from RECONNECTING to ERROR after a given number of failed syncs + this.updateSyncState(this.failedSyncCount >= FAILED_SYNC_ERROR_THRESHOLD ? SyncState.Error : SyncState.Reconnecting, { + error: err + }); + const connDidFail = await keepAlivePromise; + + // Only emit CATCHUP if we detected a connectivity error: if we didn't, + // it's quite likely the sync will fail again for the same reason and we + // want to stay in ERROR rather than keep flip-flopping between ERROR + // and CATCHUP. + if (connDidFail && this.getSyncState() === SyncState.Error) { + this.updateSyncState(SyncState.Catchup, { + catchingUp: true + }); + } + return false; + } + + /** + * Process data returned from a sync response and propagate it + * into the model objects + * + * @param syncEventData - Object containing sync tokens associated with this sync + * @param data - The response from /sync + */ + async processSyncResponse(syncEventData, data) { + const client = this.client; + + // data looks like: + // { + // next_batch: $token, + // presence: { events: [] }, + // account_data: { events: [] }, + // device_lists: { changed: ["@user:server", ... ]}, + // to_device: { events: [] }, + // device_one_time_keys_count: { signed_curve25519: 42 }, + // rooms: { + // invite: { + // $roomid: { + // invite_state: { events: [] } + // } + // }, + // join: { + // $roomid: { + // state: { events: [] }, + // timeline: { events: [], prev_batch: $token, limited: true }, + // ephemeral: { events: [] }, + // summary: { + // m.heroes: [ $user_id ], + // m.joined_member_count: $count, + // m.invited_member_count: $count + // }, + // account_data: { events: [] }, + // unread_notifications: { + // highlight_count: 0, + // notification_count: 0, + // } + // } + // }, + // leave: { + // $roomid: { + // state: { events: [] }, + // timeline: { events: [], prev_batch: $token } + // } + // } + // } + // } + + // TODO-arch: + // - Each event we pass through needs to be emitted via 'event', can we + // do this in one place? + // - The isBrandNewRoom boilerplate is boilerplatey. + + // handle presence events (User objects) + if (Array.isArray(data.presence?.events)) { + data.presence.events.filter(_utils.noUnsafeEventProps).map(client.getEventMapper()).forEach(function (presenceEvent) { + let user = client.store.getUser(presenceEvent.getSender()); + if (user) { + user.setPresenceEvent(presenceEvent); + } else { + user = createNewUser(client, presenceEvent.getSender()); + user.setPresenceEvent(presenceEvent); + client.store.storeUser(user); + } + client.emit(_client.ClientEvent.Event, presenceEvent); + }); + } + + // handle non-room account_data + if (Array.isArray(data.account_data?.events)) { + const events = data.account_data.events.filter(_utils.noUnsafeEventProps).map(client.getEventMapper()); + const prevEventsMap = events.reduce((m, c) => { + m[c.getType()] = client.store.getAccountData(c.getType()); + return m; + }, {}); + client.store.storeAccountDataEvents(events); + events.forEach(function (accountDataEvent) { + // Honour push rules that come down the sync stream but also + // honour push rules that were previously cached. Base rules + // will be updated when we receive push rules via getPushRules + // (see sync) before syncing over the network. + if (accountDataEvent.getType() === _event.EventType.PushRules) { + const rules = accountDataEvent.getContent(); + client.setPushRules(rules); + } + const prevEvent = prevEventsMap[accountDataEvent.getType()]; + client.emit(_client.ClientEvent.AccountData, accountDataEvent, prevEvent); + return accountDataEvent; + }); + } + + // handle to-device events + if (data.to_device && Array.isArray(data.to_device.events) && data.to_device.events.length > 0) { + let toDeviceMessages = data.to_device.events.filter(_utils.noUnsafeEventProps); + if (this.syncOpts.cryptoCallbacks) { + toDeviceMessages = await this.syncOpts.cryptoCallbacks.preprocessToDeviceMessages(toDeviceMessages); + } + const cancelledKeyVerificationTxns = []; + toDeviceMessages.map(client.getEventMapper({ + toDevice: true + })).map(toDeviceEvent => { + // map is a cheap inline forEach + // We want to flag m.key.verification.start events as cancelled + // if there's an accompanying m.key.verification.cancel event, so + // we pull out the transaction IDs from the cancellation events + // so we can flag the verification events as cancelled in the loop + // below. + if (toDeviceEvent.getType() === "m.key.verification.cancel") { + const txnId = toDeviceEvent.getContent()["transaction_id"]; + if (txnId) { + cancelledKeyVerificationTxns.push(txnId); + } + } + + // as mentioned above, .map is a cheap inline forEach, so return + // the unmodified event. + return toDeviceEvent; + }).forEach(function (toDeviceEvent) { + const content = toDeviceEvent.getContent(); + if (toDeviceEvent.getType() == "m.room.message" && content.msgtype == "m.bad.encrypted") { + // the mapper already logged a warning. + _logger.logger.log("Ignoring undecryptable to-device event from " + toDeviceEvent.getSender()); + return; + } + if (toDeviceEvent.getType() === "m.key.verification.start" || toDeviceEvent.getType() === "m.key.verification.request") { + const txnId = content["transaction_id"]; + if (cancelledKeyVerificationTxns.includes(txnId)) { + toDeviceEvent.flagCancelled(); + } + } + client.emit(_client.ClientEvent.ToDeviceEvent, toDeviceEvent); + }); + } else { + // no more to-device events: we can stop polling with a short timeout. + this.catchingUp = false; + } + + // the returned json structure is a bit crap, so make it into a + // nicer form (array) after applying sanity to make sure we don't fail + // on missing keys (on the off chance) + let inviteRooms = []; + let joinRooms = []; + let leaveRooms = []; + if (data.rooms) { + if (data.rooms.invite) { + inviteRooms = this.mapSyncResponseToRoomArray(data.rooms.invite); + } + if (data.rooms.join) { + joinRooms = this.mapSyncResponseToRoomArray(data.rooms.join); + } + if (data.rooms.leave) { + leaveRooms = this.mapSyncResponseToRoomArray(data.rooms.leave); + } + } + this.notifEvents = []; + + // Handle invites + await (0, _utils.promiseMapSeries)(inviteRooms, async inviteObj => { + const room = inviteObj.room; + const stateEvents = this.mapSyncEventsFormat(inviteObj.invite_state, room); + await this.injectRoomEvents(room, stateEvents); + const inviter = room.currentState.getStateEvents(_event.EventType.RoomMember, client.getUserId())?.getSender(); + const crypto = client.crypto; + if (crypto) { + const parkedHistory = await crypto.cryptoStore.takeParkedSharedHistory(room.roomId); + for (const parked of parkedHistory) { + if (parked.senderId === inviter) { + await crypto.olmDevice.addInboundGroupSession(room.roomId, parked.senderKey, parked.forwardingCurve25519KeyChain, parked.sessionId, parked.sessionKey, parked.keysClaimed, true, { + sharedHistory: true, + untrusted: true + }); + } + } + } + if (inviteObj.isBrandNewRoom) { + room.recalculate(); + client.store.storeRoom(room); + client.emit(_client.ClientEvent.Room, room); + } else { + // Update room state for invite->reject->invite cycles + room.recalculate(); + } + stateEvents.forEach(function (e) { + client.emit(_client.ClientEvent.Event, e); + }); + }); + + // Handle joins + await (0, _utils.promiseMapSeries)(joinRooms, async joinObj => { + const room = joinObj.room; + const stateEvents = this.mapSyncEventsFormat(joinObj.state, room); + // Prevent events from being decrypted ahead of time + // this helps large account to speed up faster + // room::decryptCriticalEvent is in charge of decrypting all the events + // required for a client to function properly + const events = this.mapSyncEventsFormat(joinObj.timeline, room, false); + const ephemeralEvents = this.mapSyncEventsFormat(joinObj.ephemeral); + const accountDataEvents = this.mapSyncEventsFormat(joinObj.account_data); + const encrypted = client.isRoomEncrypted(room.roomId); + // We store the server-provided value first so it's correct when any of the events fire. + if (joinObj.unread_notifications) { + /** + * We track unread notifications ourselves in encrypted rooms, so don't + * bother setting it here. We trust our calculations better than the + * server's for this case, and therefore will assume that our non-zero + * count is accurate. + * + * @see import("./client").fixNotificationCountOnDecryption + */ + if (!encrypted || joinObj.unread_notifications.notification_count === 0) { + // In an encrypted room, if the room has notifications enabled then it's typical for + // the server to flag all new messages as notifying. However, some push rules calculate + // events as ignored based on their event contents (e.g. ignoring msgtype=m.notice messages) + // so we want to calculate this figure on the client in all cases. + room.setUnreadNotificationCount(_room.NotificationCountType.Total, joinObj.unread_notifications.notification_count ?? 0); + } + if (!encrypted || room.getUnreadNotificationCount(_room.NotificationCountType.Highlight) <= 0) { + // If the locally stored highlight count is zero, use the server provided value. + room.setUnreadNotificationCount(_room.NotificationCountType.Highlight, joinObj.unread_notifications.highlight_count ?? 0); + } + } + const unreadThreadNotifications = joinObj[_sync.UNREAD_THREAD_NOTIFICATIONS.name] ?? joinObj[_sync.UNREAD_THREAD_NOTIFICATIONS.altName]; + if (unreadThreadNotifications) { + // Only partially reset unread notification + // We want to keep the client-generated count. Particularly important + // for encrypted room that refresh their notification count on event + // decryption + room.resetThreadUnreadNotificationCount(Object.keys(unreadThreadNotifications)); + for (const [threadId, unreadNotification] of Object.entries(unreadThreadNotifications)) { + if (!encrypted || unreadNotification.notification_count === 0) { + room.setThreadUnreadNotificationCount(threadId, _room.NotificationCountType.Total, unreadNotification.notification_count ?? 0); + } + const hasNoNotifications = room.getThreadUnreadNotificationCount(threadId, _room.NotificationCountType.Highlight) <= 0; + if (!encrypted || encrypted && hasNoNotifications) { + room.setThreadUnreadNotificationCount(threadId, _room.NotificationCountType.Highlight, unreadNotification.highlight_count ?? 0); + } + } + } else { + room.resetThreadUnreadNotificationCount(); + } + joinObj.timeline = joinObj.timeline || {}; + if (joinObj.isBrandNewRoom) { + // set the back-pagination token. Do this *before* adding any + // events so that clients can start back-paginating. + if (joinObj.timeline.prev_batch !== null) { + room.getLiveTimeline().setPaginationToken(joinObj.timeline.prev_batch, _eventTimeline.EventTimeline.BACKWARDS); + } + } else if (joinObj.timeline.limited) { + let limited = true; + + // we've got a limited sync, so we *probably* have a gap in the + // timeline, so should reset. But we might have been peeking or + // paginating and already have some of the events, in which + // case we just want to append any subsequent events to the end + // of the existing timeline. + // + // This is particularly important in the case that we already have + // *all* of the events in the timeline - in that case, if we reset + // the timeline, we'll end up with an entirely empty timeline, + // which we'll try to paginate but not get any new events (which + // will stop us linking the empty timeline into the chain). + // + for (let i = events.length - 1; i >= 0; i--) { + const eventId = events[i].getId(); + if (room.getTimelineForEvent(eventId)) { + debuglog(`Already have event ${eventId} in limited sync - not resetting`); + limited = false; + + // we might still be missing some of the events before i; + // we don't want to be adding them to the end of the + // timeline because that would put them out of order. + events.splice(0, i); + + // XXX: there's a problem here if the skipped part of the + // timeline modifies the state set in stateEvents, because + // we'll end up using the state from stateEvents rather + // than the later state from timelineEvents. We probably + // need to wind stateEvents forward over the events we're + // skipping. + + break; + } + } + if (limited) { + room.resetLiveTimeline(joinObj.timeline.prev_batch, this.syncOpts.canResetEntireTimeline(room.roomId) ? null : syncEventData.oldSyncToken ?? null); + + // We have to assume any gap in any timeline is + // reason to stop incrementally tracking notifications and + // reset the timeline. + client.resetNotifTimelineSet(); + } + } + + // process any crypto events *before* emitting the RoomStateEvent events. This + // avoids a race condition if the application tries to send a message after the + // state event is processed, but before crypto is enabled, which then causes the + // crypto layer to complain. + if (this.syncOpts.cryptoCallbacks) { + for (const e of stateEvents.concat(events)) { + if (e.isState() && e.getType() === _event.EventType.RoomEncryption && e.getStateKey() === "") { + await this.syncOpts.cryptoCallbacks.onCryptoEvent(room, e); + } + } + } + try { + await this.injectRoomEvents(room, stateEvents, events, syncEventData.fromCache); + } catch (e) { + _logger.logger.error(`Failed to process events on room ${room.roomId}:`, e); + } + + // set summary after processing events, + // because it will trigger a name calculation + // which needs the room state to be up to date + if (joinObj.summary) { + room.setSummary(joinObj.summary); + } + + // we deliberately don't add ephemeral events to the timeline + room.addEphemeralEvents(ephemeralEvents); + + // we deliberately don't add accountData to the timeline + room.addAccountData(accountDataEvents); + room.recalculate(); + if (joinObj.isBrandNewRoom) { + client.store.storeRoom(room); + client.emit(_client.ClientEvent.Room, room); + } + this.processEventsForNotifs(room, events); + const emitEvent = e => client.emit(_client.ClientEvent.Event, e); + stateEvents.forEach(emitEvent); + events.forEach(emitEvent); + ephemeralEvents.forEach(emitEvent); + accountDataEvents.forEach(emitEvent); + + // Decrypt only the last message in all rooms to make sure we can generate a preview + // And decrypt all events after the recorded read receipt to ensure an accurate + // notification count + room.decryptCriticalEvents(); + }); + + // Handle leaves (e.g. kicked rooms) + await (0, _utils.promiseMapSeries)(leaveRooms, async leaveObj => { + const room = leaveObj.room; + const stateEvents = this.mapSyncEventsFormat(leaveObj.state, room); + const events = this.mapSyncEventsFormat(leaveObj.timeline, room); + const accountDataEvents = this.mapSyncEventsFormat(leaveObj.account_data); + await this.injectRoomEvents(room, stateEvents, events); + room.addAccountData(accountDataEvents); + room.recalculate(); + if (leaveObj.isBrandNewRoom) { + client.store.storeRoom(room); + client.emit(_client.ClientEvent.Room, room); + } + this.processEventsForNotifs(room, events); + stateEvents.forEach(function (e) { + client.emit(_client.ClientEvent.Event, e); + }); + events.forEach(function (e) { + client.emit(_client.ClientEvent.Event, e); + }); + accountDataEvents.forEach(function (e) { + client.emit(_client.ClientEvent.Event, e); + }); + }); + + // update the notification timeline, if appropriate. + // we only do this for live events, as otherwise we can't order them sanely + // in the timeline relative to ones paginated in by /notifications. + // XXX: we could fix this by making EventTimeline support chronological + // ordering... but it doesn't, right now. + if (syncEventData.oldSyncToken && this.notifEvents.length) { + this.notifEvents.sort(function (a, b) { + return a.getTs() - b.getTs(); + }); + this.notifEvents.forEach(function (event) { + client.getNotifTimelineSet()?.addLiveEvent(event); + }); + } + + // Handle device list updates + if (data.device_lists) { + if (this.syncOpts.cryptoCallbacks) { + await this.syncOpts.cryptoCallbacks.processDeviceLists(data.device_lists); + } else { + // FIXME if we *don't* have a crypto module, we still need to + // invalidate the device lists. But that would require a + // substantial bit of rework :/. + } + } + + // Handle one_time_keys_count and unused fallback keys + await this.syncOpts.cryptoCallbacks?.processKeyCounts(data.device_one_time_keys_count, data.device_unused_fallback_key_types ?? data["org.matrix.msc2732.device_unused_fallback_key_types"]); + } + + /** + * Starts polling the connectivity check endpoint + * @param delay - How long to delay until the first poll. + * defaults to a short, randomised interval (to prevent + * tight-looping if /versions succeeds but /sync etc. fail). + * @returns which resolves once the connection returns + */ + startKeepAlives(delay) { + if (delay === undefined) { + delay = 2000 + Math.floor(Math.random() * 5000); + } + if (this.keepAliveTimer !== null) { + clearTimeout(this.keepAliveTimer); + } + if (delay > 0) { + this.keepAliveTimer = setTimeout(this.pokeKeepAlive.bind(this), delay); + } else { + this.pokeKeepAlive(); + } + if (!this.connectionReturnedDefer) { + this.connectionReturnedDefer = (0, _utils.defer)(); + } + return this.connectionReturnedDefer.promise; + } + + /** + * Make a dummy call to /_matrix/client/versions, to see if the HS is + * reachable. + * + * On failure, schedules a call back to itself. On success, resolves + * this.connectionReturnedDefer. + * + * @param connDidFail - True if a connectivity failure has been detected. Optional. + */ + pokeKeepAlive(connDidFail = false) { + const success = () => { + clearTimeout(this.keepAliveTimer); + if (this.connectionReturnedDefer) { + this.connectionReturnedDefer.resolve(connDidFail); + this.connectionReturnedDefer = undefined; + } + }; + this.client.http.request(_httpApi.Method.Get, "/_matrix/client/versions", undefined, + // queryParams + undefined, + // data + { + prefix: "", + localTimeoutMs: 15 * 1000, + abortSignal: this.abortController?.signal + }).then(() => { + success(); + }, err => { + if (err.httpStatus == 400 || err.httpStatus == 404) { + // treat this as a success because the server probably just doesn't + // support /versions: point is, we're getting a response. + // We wait a short time though, just in case somehow the server + // is in a mode where it 400s /versions responses and sync etc. + // responses fail, this will mean we don't hammer in a loop. + this.keepAliveTimer = setTimeout(success, 2000); + } else { + connDidFail = true; + this.keepAliveTimer = setTimeout(this.pokeKeepAlive.bind(this, connDidFail), 5000 + Math.floor(Math.random() * 5000)); + // A keepalive has failed, so we emit the + // error state (whether or not this is the + // first failure). + // Note we do this after setting the timer: + // this lets the unit tests advance the mock + // clock when they get the error. + this.updateSyncState(SyncState.Error, { + error: err + }); + } + }); + } + mapSyncResponseToRoomArray(obj) { + // Maps { roomid: {stuff}, roomid: {stuff} } + // to + // [{stuff+Room+isBrandNewRoom}, {stuff+Room+isBrandNewRoom}] + const client = this.client; + return Object.keys(obj).filter(k => !(0, _utils.unsafeProp)(k)).map(roomId => { + let room = client.store.getRoom(roomId); + let isBrandNewRoom = false; + if (!room) { + room = this.createRoom(roomId); + isBrandNewRoom = true; + } + return _objectSpread(_objectSpread({}, obj[roomId]), {}, { + room, + isBrandNewRoom + }); + }); + } + mapSyncEventsFormat(obj, room, decrypt = true) { + if (!obj || !Array.isArray(obj.events)) { + return []; + } + const mapper = this.client.getEventMapper({ + decrypt + }); + return obj.events.filter(_utils.noUnsafeEventProps).map(function (e) { + if (room) { + e.room_id = room.roomId; + } + return mapper(e); + }); + } + + /** + */ + resolveInvites(room) { + if (!room || !this.opts.resolveInvitesToProfiles) { + return; + } + const client = this.client; + // For each invited room member we want to give them a displayname/avatar url + // if they have one (the m.room.member invites don't contain this). + room.getMembersWithMembership("invite").forEach(function (member) { + if (member.requestedProfileInfo) return; + member.requestedProfileInfo = true; + // try to get a cached copy first. + const user = client.getUser(member.userId); + let promise; + if (user) { + promise = Promise.resolve({ + avatar_url: user.avatarUrl, + displayname: user.displayName + }); + } else { + promise = client.getProfileInfo(member.userId); + } + promise.then(function (info) { + // slightly naughty by doctoring the invite event but this means all + // the code paths remain the same between invite/join display name stuff + // which is a worthy trade-off for some minor pollution. + const inviteEvent = member.events.member; + if (inviteEvent?.getContent().membership !== "invite") { + // between resolving and now they have since joined, so don't clobber + return; + } + inviteEvent.getContent().avatar_url = info.avatar_url; + inviteEvent.getContent().displayname = info.displayname; + // fire listeners + member.setMembershipEvent(inviteEvent, room.currentState); + }, function (err) { + // OH WELL. + }); + }); + } + + /** + * Injects events into a room's model. + * @param stateEventList - A list of state events. This is the state + * at the *START* of the timeline list if it is supplied. + * @param timelineEventList - A list of timeline events, including threaded. Lower index + * is earlier in time. Higher index is later. + * @param fromCache - whether the sync response came from cache + */ + async injectRoomEvents(room, stateEventList, timelineEventList, fromCache = false) { + // If there are no events in the timeline yet, initialise it with + // the given state events + const liveTimeline = room.getLiveTimeline(); + const timelineWasEmpty = liveTimeline.getEvents().length == 0; + if (timelineWasEmpty) { + // Passing these events into initialiseState will freeze them, so we need + // to compute and cache the push actions for them now, otherwise sync dies + // with an attempt to assign to read only property. + // XXX: This is pretty horrible and is assuming all sorts of behaviour from + // these functions that it shouldn't be. We should probably either store the + // push actions cache elsewhere so we can freeze MatrixEvents, or otherwise + // find some solution where MatrixEvents are immutable but allow for a cache + // field. + for (const ev of stateEventList) { + this.client.getPushActionsForEvent(ev); + } + liveTimeline.initialiseState(stateEventList, { + timelineWasEmpty + }); + } + this.resolveInvites(room); + + // recalculate the room name at this point as adding events to the timeline + // may make notifications appear which should have the right name. + // XXX: This looks suspect: we'll end up recalculating the room once here + // and then again after adding events (processSyncResponse calls it after + // calling us) even if no state events were added. It also means that if + // one of the room events in timelineEventList is something that needs + // a recalculation (like m.room.name) we won't recalculate until we've + // finished adding all the events, which will cause the notification to have + // the old room name rather than the new one. + room.recalculate(); + + // If the timeline wasn't empty, we process the state events here: they're + // defined as updates to the state before the start of the timeline, so this + // starts to roll the state forward. + // XXX: That's what we *should* do, but this can happen if we were previously + // peeking in a room, in which case we obviously do *not* want to add the + // state events here onto the end of the timeline. Historically, the js-sdk + // has just set these new state events on the old and new state. This seems + // very wrong because there could be events in the timeline that diverge the + // state, in which case this is going to leave things out of sync. However, + // for now I think it;s best to behave the same as the code has done previously. + if (!timelineWasEmpty) { + // XXX: As above, don't do this... + //room.addLiveEvents(stateEventList || []); + // Do this instead... + room.oldState.setStateEvents(stateEventList || []); + room.currentState.setStateEvents(stateEventList || []); + } + + // Execute the timeline events. This will continue to diverge the current state + // if the timeline has any state events in it. + // This also needs to be done before running push rules on the events as they need + // to be decorated with sender etc. + await room.addLiveEvents(timelineEventList || [], { + fromCache, + timelineWasEmpty + }); + this.client.processBeaconEvents(room, timelineEventList); + } + + /** + * Takes a list of timelineEvents and adds and adds to notifEvents + * as appropriate. + * This must be called after the room the events belong to has been stored. + * + * @param timelineEventList - A list of timeline events. Lower index + * is earlier in time. Higher index is later. + */ + processEventsForNotifs(room, timelineEventList) { + // gather our notifications into this.notifEvents + if (this.client.getNotifTimelineSet()) { + for (const event of timelineEventList) { + const pushActions = this.client.getPushActionsForEvent(event); + if (pushActions?.notify && pushActions.tweaks?.highlight) { + this.notifEvents.push(event); + } + } + } + } + getGuestFilter() { + // Dev note: This used to be conditional to return a filter of 20 events maximum, but + // the condition never went to the other branch. This is now hardcoded. + return "{}"; + } + + /** + * Sets the sync state and emits an event to say so + * @param newState - The new state string + * @param data - Object of additional data to emit in the event + */ + updateSyncState(newState, data) { + const old = this.syncState; + this.syncState = newState; + this.syncStateData = data; + this.client.emit(_client.ClientEvent.Sync, this.syncState, old, data); + } +} +exports.SyncApi = SyncApi; +function createNewUser(client, userId) { + const user = new _user.User(userId); + client.reEmitter.reEmit(user, [_user.UserEvent.AvatarUrl, _user.UserEvent.DisplayName, _user.UserEvent.Presence, _user.UserEvent.CurrentlyActive, _user.UserEvent.LastPresenceTs]); + return user; +} + +// /!\ This function is not intended for public use! It's only exported from +// here in order to share some common logic with sliding-sync-sdk.ts. +function _createAndReEmitRoom(client, roomId, opts) { + const { + timelineSupport + } = client; + const room = new _room.Room(roomId, client, client.getUserId(), { + lazyLoadMembers: opts.lazyLoadMembers, + pendingEventOrdering: opts.pendingEventOrdering, + timelineSupport + }); + client.reEmitter.reEmit(room, [_room.RoomEvent.Name, _room.RoomEvent.Redaction, _room.RoomEvent.RedactionCancelled, _room.RoomEvent.Receipt, _room.RoomEvent.Tags, _room.RoomEvent.LocalEchoUpdated, _room.RoomEvent.AccountData, _room.RoomEvent.MyMembership, _room.RoomEvent.Timeline, _room.RoomEvent.TimelineReset, _roomState.RoomStateEvent.Events, _roomState.RoomStateEvent.Members, _roomState.RoomStateEvent.NewMember, _roomState.RoomStateEvent.Update, _beacon.BeaconEvent.New, _beacon.BeaconEvent.Update, _beacon.BeaconEvent.Destroy, _beacon.BeaconEvent.LivenessChange]); + + // We need to add a listener for RoomState.members in order to hook them + // correctly. + room.on(_roomState.RoomStateEvent.NewMember, (event, state, member) => { + member.user = client.getUser(member.userId) ?? undefined; + client.reEmitter.reEmit(member, [_roomMember.RoomMemberEvent.Name, _roomMember.RoomMemberEvent.Typing, _roomMember.RoomMemberEvent.PowerLevel, _roomMember.RoomMemberEvent.Membership]); + }); + return room; +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/timeline-window.js b/comm/chat/protocols/matrix/lib/matrix-sdk/timeline-window.js new file mode 100644 index 0000000000..2c7365bdff --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/timeline-window.js @@ -0,0 +1,462 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.TimelineWindow = exports.TimelineIndex = void 0; +var _eventTimeline = require("./models/event-timeline"); +var _logger = require("./logger"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2016 - 2021 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/** + * @internal + */ +const DEBUG = false; + +/** + * @internal + */ +/* istanbul ignore next */ +const debuglog = DEBUG ? _logger.logger.log.bind(_logger.logger) : function () {}; + +/** + * the number of times we ask the server for more events before giving up + * + * @internal + */ +const DEFAULT_PAGINATE_LOOP_LIMIT = 5; +class TimelineWindow { + /** + * Construct a TimelineWindow. + * + * <p>This abstracts the separate timelines in a Matrix {@link Room} into a single iterable thing. + * It keeps track of the start and endpoints of the window, which can be advanced with the help + * of pagination requests. + * + * <p>Before the window is useful, it must be initialised by calling {@link TimelineWindow#load}. + * + * <p>Note that the window will not automatically extend itself when new events + * are received from /sync; you should arrange to call {@link TimelineWindow#paginate} + * on {@link RoomEvent.Timeline} events. + * + * @param client - MatrixClient to be used for context/pagination + * requests. + * + * @param timelineSet - The timelineSet to track + * + * @param opts - Configuration options for this window + */ + constructor(client, timelineSet, opts = {}) { + this.client = client; + this.timelineSet = timelineSet; + _defineProperty(this, "windowLimit", void 0); + // these will be TimelineIndex objects; they delineate the 'start' and + // 'end' of the window. + // + // start.index is inclusive; end.index is exclusive. + _defineProperty(this, "start", void 0); + _defineProperty(this, "end", void 0); + _defineProperty(this, "eventCount", 0); + this.windowLimit = opts.windowLimit || 1000; + } + + /** + * Initialise the window to point at a given event, or the live timeline + * + * @param initialEventId - If given, the window will contain the + * given event + * @param initialWindowSize - Size of the initial window + */ + load(initialEventId, initialWindowSize = 20) { + // given an EventTimeline, find the event we were looking for, and initialise our + // fields so that the event in question is in the middle of the window. + const initFields = timeline => { + if (!timeline) { + throw new Error("No timeline given to initFields"); + } + let eventIndex; + const events = timeline.getEvents(); + if (!initialEventId) { + // we were looking for the live timeline: initialise to the end + eventIndex = events.length; + } else { + eventIndex = events.findIndex(e => e.getId() === initialEventId); + if (eventIndex < 0) { + throw new Error("getEventTimeline result didn't include requested event"); + } + } + const endIndex = Math.min(events.length, eventIndex + Math.ceil(initialWindowSize / 2)); + const startIndex = Math.max(0, endIndex - initialWindowSize); + this.start = new TimelineIndex(timeline, startIndex - timeline.getBaseIndex()); + this.end = new TimelineIndex(timeline, endIndex - timeline.getBaseIndex()); + this.eventCount = endIndex - startIndex; + }; + + // We avoid delaying the resolution of the promise by a reactor tick if we already have the data we need, + // which is important to keep room-switching feeling snappy. + if (this.timelineSet.getTimelineForEvent(initialEventId)) { + initFields(this.timelineSet.getTimelineForEvent(initialEventId)); + return Promise.resolve(); + } else if (initialEventId) { + return this.client.getEventTimeline(this.timelineSet, initialEventId).then(initFields); + } else { + initFields(this.timelineSet.getLiveTimeline()); + return Promise.resolve(); + } + } + + /** + * Get the TimelineIndex of the window in the given direction. + * + * @param direction - EventTimeline.BACKWARDS to get the TimelineIndex + * at the start of the window; EventTimeline.FORWARDS to get the TimelineIndex at + * the end. + * + * @returns The requested timeline index if one exists, null + * otherwise. + */ + getTimelineIndex(direction) { + if (direction == _eventTimeline.EventTimeline.BACKWARDS) { + return this.start ?? null; + } else if (direction == _eventTimeline.EventTimeline.FORWARDS) { + return this.end ?? null; + } else { + throw new Error("Invalid direction '" + direction + "'"); + } + } + + /** + * Try to extend the window using events that are already in the underlying + * TimelineIndex. + * + * @param direction - EventTimeline.BACKWARDS to try extending it + * backwards; EventTimeline.FORWARDS to try extending it forwards. + * @param size - number of events to try to extend by. + * + * @returns true if the window was extended, false otherwise. + */ + extend(direction, size) { + const tl = this.getTimelineIndex(direction); + if (!tl) { + debuglog("TimelineWindow: no timeline yet"); + return false; + } + const count = direction == _eventTimeline.EventTimeline.BACKWARDS ? tl.retreat(size) : tl.advance(size); + if (count) { + this.eventCount += count; + debuglog("TimelineWindow: increased cap by " + count + " (now " + this.eventCount + ")"); + // remove some events from the other end, if necessary + const excess = this.eventCount - this.windowLimit; + if (excess > 0) { + this.unpaginate(excess, direction != _eventTimeline.EventTimeline.BACKWARDS); + } + return true; + } + return false; + } + + /** + * Check if this window can be extended + * + * <p>This returns true if we either have more events, or if we have a + * pagination token which means we can paginate in that direction. It does not + * necessarily mean that there are more events available in that direction at + * this time. + * + * @param direction - EventTimeline.BACKWARDS to check if we can + * paginate backwards; EventTimeline.FORWARDS to check if we can go forwards + * + * @returns true if we can paginate in the given direction + */ + canPaginate(direction) { + const tl = this.getTimelineIndex(direction); + if (!tl) { + debuglog("TimelineWindow: no timeline yet"); + return false; + } + if (direction == _eventTimeline.EventTimeline.BACKWARDS) { + if (tl.index > tl.minIndex()) { + return true; + } + } else { + if (tl.index < tl.maxIndex()) { + return true; + } + } + const hasNeighbouringTimeline = tl.timeline.getNeighbouringTimeline(direction); + const paginationToken = tl.timeline.getPaginationToken(direction); + return Boolean(hasNeighbouringTimeline) || Boolean(paginationToken); + } + + /** + * Attempt to extend the window + * + * @param direction - EventTimeline.BACKWARDS to extend the window + * backwards (towards older events); EventTimeline.FORWARDS to go forwards. + * + * @param size - number of events to try to extend by. If fewer than this + * number are immediately available, then we return immediately rather than + * making an API call. + * + * @param makeRequest - whether we should make API calls to + * fetch further events if we don't have any at all. (This has no effect if + * the room already knows about additional events in the relevant direction, + * even if there are fewer than 'size' of them, as we will just return those + * we already know about.) + * + * @param requestLimit - limit for the number of API requests we + * should make. + * + * @returns Promise which resolves to a boolean which is true if more events + * were successfully retrieved. + */ + async paginate(direction, size, makeRequest = true, requestLimit = DEFAULT_PAGINATE_LOOP_LIMIT) { + // Either wind back the message cap (if there are enough events in the + // timeline to do so), or fire off a pagination request. + const tl = this.getTimelineIndex(direction); + if (!tl) { + debuglog("TimelineWindow: no timeline yet"); + return false; + } + if (tl.pendingPaginate) { + return tl.pendingPaginate; + } + + // try moving the cap + if (this.extend(direction, size)) { + return true; + } + if (!makeRequest || requestLimit === 0) { + // todo: should we return something different to indicate that there + // might be more events out there, but we haven't found them yet? + return false; + } + + // try making a pagination request + const token = tl.timeline.getPaginationToken(direction); + if (!token) { + debuglog("TimelineWindow: no token"); + return false; + } + debuglog("TimelineWindow: starting request"); + const prom = this.client.paginateEventTimeline(tl.timeline, { + backwards: direction == _eventTimeline.EventTimeline.BACKWARDS, + limit: size + }).finally(function () { + tl.pendingPaginate = undefined; + }).then(r => { + debuglog("TimelineWindow: request completed with result " + r); + if (!r) { + return this.paginate(direction, size, false, 0); + } + + // recurse to advance the index into the results. + // + // If we don't get any new events, we want to make sure we keep asking + // the server for events for as long as we have a valid pagination + // token. In particular, we want to know if we've actually hit the + // start of the timeline, or if we just happened to know about all of + // the events thanks to https://matrix.org/jira/browse/SYN-645. + // + // On the other hand, we necessarily want to wait forever for the + // server to make its mind up about whether there are other events, + // because it gives a bad user experience + // (https://github.com/vector-im/vector-web/issues/1204). + return this.paginate(direction, size, true, requestLimit - 1); + }); + tl.pendingPaginate = prom; + return prom; + } + + /** + * Remove `delta` events from the start or end of the timeline. + * + * @param delta - number of events to remove from the timeline + * @param startOfTimeline - if events should be removed from the start + * of the timeline. + */ + unpaginate(delta, startOfTimeline) { + const tl = startOfTimeline ? this.start : this.end; + if (!tl) { + throw new Error(`Attempting to unpaginate startOfTimeline=${startOfTimeline} but don't have this direction`); + } + + // sanity-check the delta + if (delta > this.eventCount || delta < 0) { + throw new Error(`Attemting to unpaginate ${delta} events, but only have ${this.eventCount} in the timeline`); + } + while (delta > 0) { + const count = startOfTimeline ? tl.advance(delta) : tl.retreat(delta); + if (count <= 0) { + // sadness. This shouldn't be possible. + throw new Error("Unable to unpaginate any further, but still have " + this.eventCount + " events"); + } + delta -= count; + this.eventCount -= count; + debuglog("TimelineWindow.unpaginate: dropped " + count + " (now " + this.eventCount + ")"); + } + } + + /** + * Get a list of the events currently in the window + * + * @returns the events in the window + */ + getEvents() { + if (!this.start) { + // not yet loaded + return []; + } + const result = []; + + // iterate through each timeline between this.start and this.end + // (inclusive). + let timeline = this.start.timeline; + // eslint-disable-next-line no-constant-condition + while (timeline) { + const events = timeline.getEvents(); + + // For the first timeline in the chain, we want to start at + // this.start.index. For the last timeline in the chain, we want to + // stop before this.end.index. Otherwise, we want to copy all of the + // events in the timeline. + // + // (Note that both this.start.index and this.end.index are relative + // to their respective timelines' BaseIndex). + // + let startIndex = 0; + let endIndex = events.length; + if (timeline === this.start.timeline) { + startIndex = this.start.index + timeline.getBaseIndex(); + } + if (timeline === this.end?.timeline) { + endIndex = this.end.index + timeline.getBaseIndex(); + } + for (let i = startIndex; i < endIndex; i++) { + result.push(events[i]); + } + + // if we're not done, iterate to the next timeline. + if (timeline === this.end?.timeline) { + break; + } else { + timeline = timeline.getNeighbouringTimeline(_eventTimeline.EventTimeline.FORWARDS); + } + } + return result; + } +} + +/** + * A thing which contains a timeline reference, and an index into it. + * @internal + */ +exports.TimelineWindow = TimelineWindow; +class TimelineIndex { + // index: the indexes are relative to BaseIndex, so could well be negative. + constructor(timeline, index) { + this.timeline = timeline; + this.index = index; + _defineProperty(this, "pendingPaginate", void 0); + } + + /** + * @returns the minimum possible value for the index in the current + * timeline + */ + minIndex() { + return this.timeline.getBaseIndex() * -1; + } + + /** + * @returns the maximum possible value for the index in the current + * timeline (exclusive - ie, it actually returns one more than the index + * of the last element). + */ + maxIndex() { + return this.timeline.getEvents().length - this.timeline.getBaseIndex(); + } + + /** + * Try move the index forward, or into the neighbouring timeline + * + * @param delta - number of events to advance by + * @returns number of events successfully advanced by + */ + advance(delta) { + if (!delta) { + return 0; + } + + // first try moving the index in the current timeline. See if there is room + // to do so. + let cappedDelta; + if (delta < 0) { + // we want to wind the index backwards. + // + // (this.minIndex() - this.index) is a negative number whose magnitude + // is the amount of room we have to wind back the index in the current + // timeline. We cap delta to this quantity. + cappedDelta = Math.max(delta, this.minIndex() - this.index); + if (cappedDelta < 0) { + this.index += cappedDelta; + return cappedDelta; + } + } else { + // we want to wind the index forwards. + // + // (this.maxIndex() - this.index) is a (positive) number whose magnitude + // is the amount of room we have to wind forward the index in the current + // timeline. We cap delta to this quantity. + cappedDelta = Math.min(delta, this.maxIndex() - this.index); + if (cappedDelta > 0) { + this.index += cappedDelta; + return cappedDelta; + } + } + + // the index is already at the start/end of the current timeline. + // + // next see if there is a neighbouring timeline to switch to. + const neighbour = this.timeline.getNeighbouringTimeline(delta < 0 ? _eventTimeline.EventTimeline.BACKWARDS : _eventTimeline.EventTimeline.FORWARDS); + if (neighbour) { + this.timeline = neighbour; + if (delta < 0) { + this.index = this.maxIndex(); + } else { + this.index = this.minIndex(); + } + debuglog("paginate: switched to new neighbour"); + + // recurse, using the next timeline + return this.advance(delta); + } + return 0; + } + + /** + * Try move the index backwards, or into the neighbouring timeline + * + * @param delta - number of events to retreat by + * @returns number of events successfully retreated by + */ + retreat(delta) { + return this.advance(delta * -1) * -1; + } +} +exports.TimelineIndex = TimelineIndex;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/utils.js b/comm/chat/protocols/matrix/lib/matrix-sdk/utils.js new file mode 100644 index 0000000000..6c7ead2ce7 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/utils.js @@ -0,0 +1,754 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.MapWithDefault = exports.DEFAULT_ALPHABET = void 0; +exports.alphabetPad = alphabetPad; +exports.averageBetweenStrings = averageBetweenStrings; +exports.baseToString = baseToString; +exports.checkObjectHasKeys = checkObjectHasKeys; +exports.chunkPromises = chunkPromises; +exports.compare = compare; +exports.decodeParams = decodeParams; +exports.deepCompare = deepCompare; +exports.deepCopy = deepCopy; +exports.deepSortedObjectEntries = deepSortedObjectEntries; +exports.defer = defer; +exports.encodeParams = encodeParams; +exports.encodeUri = encodeUri; +exports.ensureNoTrailingSlash = ensureNoTrailingSlash; +exports.escapeRegExp = escapeRegExp; +exports.globToRegexp = globToRegexp; +exports.immediate = immediate; +exports.internaliseString = internaliseString; +exports.isFunction = isFunction; +exports.isNullOrUndefined = isNullOrUndefined; +exports.isNumber = isNumber; +exports.isSupportedReceiptType = isSupportedReceiptType; +exports.lexicographicCompare = lexicographicCompare; +exports.mapsEqual = mapsEqual; +exports.nextString = nextString; +exports.noUnsafeEventProps = noUnsafeEventProps; +exports.normalize = normalize; +exports.prevString = prevString; +exports.promiseMapSeries = promiseMapSeries; +exports.promiseTry = promiseTry; +exports.recursiveMapToObject = recursiveMapToObject; +exports.recursivelyAssign = recursivelyAssign; +exports.removeDirectionOverrideChars = removeDirectionOverrideChars; +exports.removeElement = removeElement; +exports.removeHiddenChars = removeHiddenChars; +exports.replaceParam = replaceParam; +exports.safeSet = safeSet; +exports.simpleRetryOperation = simpleRetryOperation; +exports.sleep = sleep; +exports.sortEventsByLatestContentTimestamp = sortEventsByLatestContentTimestamp; +exports.stringToBase = stringToBase; +exports.unsafeProp = unsafeProp; +var _unhomoglyph = _interopRequireDefault(require("unhomoglyph")); +var _pRetry = _interopRequireDefault(require("p-retry")); +var _location = require("./@types/location"); +var _read_receipts = require("./@types/read_receipts"); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2015, 2016, 2019, 2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ /** + * This is an internal module. + */ +const interns = new Map(); + +/** + * Internalises a string, reusing a known pointer or storing the pointer + * if needed for future strings. + * @param str - The string to internalise. + * @returns The internalised string. + */ +function internaliseString(str) { + // Unwrap strings before entering the map, if we somehow got a wrapped + // string as our input. This should only happen from tests. + if (str instanceof String) { + str = str.toString(); + } + + // Check the map to see if we can store the value + if (!interns.has(str)) { + interns.set(str, str); + } + + // Return any cached string reference + return interns.get(str); +} + +/** + * Encode a dictionary of query parameters. + * Omits any undefined/null values. + * @param params - A dict of key/values to encode e.g. + * `{"foo": "bar", "baz": "taz"}` + * @returns The encoded string e.g. foo=bar&baz=taz + */ +function encodeParams(params, urlSearchParams) { + const searchParams = urlSearchParams ?? new URLSearchParams(); + for (const [key, val] of Object.entries(params)) { + if (val !== undefined && val !== null) { + if (Array.isArray(val)) { + val.forEach(v => { + searchParams.append(key, String(v)); + }); + } else { + searchParams.append(key, String(val)); + } + } + } + return searchParams; +} +/** + * Replace a stable parameter with the unstable naming for params + */ +function replaceParam(stable, unstable, dict) { + const result = _objectSpread(_objectSpread({}, dict), {}, { + [unstable]: dict[stable] + }); + delete result[stable]; + return result; +} + +/** + * Decode a query string in `application/x-www-form-urlencoded` format. + * @param query - A query string to decode e.g. + * foo=bar&via=server1&server2 + * @returns The decoded object, if any keys occurred multiple times + * then the value will be an array of strings, else it will be an array. + * This behaviour matches Node's qs.parse but is built on URLSearchParams + * for native web compatibility + */ +function decodeParams(query) { + const o = {}; + const params = new URLSearchParams(query); + for (const key of params.keys()) { + const val = params.getAll(key); + o[key] = val.length === 1 ? val[0] : val; + } + return o; +} + +/** + * Encodes a URI according to a set of template variables. Variables will be + * passed through encodeURIComponent. + * @param pathTemplate - The path with template variables e.g. '/foo/$bar'. + * @param variables - The key/value pairs to replace the template + * variables with. E.g. `{ "$bar": "baz" }`. + * @returns The result of replacing all template variables e.g. '/foo/baz'. + */ +function encodeUri(pathTemplate, variables) { + for (const key in variables) { + if (!variables.hasOwnProperty(key)) { + continue; + } + const value = variables[key]; + if (value === undefined || value === null) { + continue; + } + pathTemplate = pathTemplate.replace(key, encodeURIComponent(value)); + } + return pathTemplate; +} + +/** + * The removeElement() method removes the first element in the array that + * satisfies (returns true) the provided testing function. + * @param array - The array. + * @param fn - Function to execute on each value in the array, with the + * function signature `fn(element, index, array)`. Return true to + * remove this element and break. + * @param reverse - True to search in reverse order. + * @returns True if an element was removed. + */ +function removeElement(array, fn, reverse) { + let i; + if (reverse) { + for (i = array.length - 1; i >= 0; i--) { + if (fn(array[i], i, array)) { + array.splice(i, 1); + return true; + } + } + } else { + for (i = 0; i < array.length; i++) { + if (fn(array[i], i, array)) { + array.splice(i, 1); + return true; + } + } + } + return false; +} + +/** + * Checks if the given thing is a function. + * @param value - The thing to check. + * @returns True if it is a function. + */ +function isFunction(value) { + return Object.prototype.toString.call(value) === "[object Function]"; +} + +/** + * Checks that the given object has the specified keys. + * @param obj - The object to check. + * @param keys - The list of keys that 'obj' must have. + * @throws If the object is missing keys. + */ +// note using 'keys' here would shadow the 'keys' function defined above +function checkObjectHasKeys(obj, keys) { + for (const key of keys) { + if (!obj.hasOwnProperty(key)) { + throw new Error("Missing required key: " + key); + } + } +} + +/** + * Deep copy the given object. The object MUST NOT have circular references and + * MUST NOT have functions. + * @param obj - The object to deep copy. + * @returns A copy of the object without any references to the original. + */ +function deepCopy(obj) { + return JSON.parse(JSON.stringify(obj)); +} + +/** + * Compare two objects for equality. The objects MUST NOT have circular references. + * + * @param x - The first object to compare. + * @param y - The second object to compare. + * + * @returns true if the two objects are equal + */ +function deepCompare(x, y) { + // Inspired by + // http://stackoverflow.com/questions/1068834/object-comparison-in-javascript#1144249 + + // Compare primitives and functions. + // Also check if both arguments link to the same object. + if (x === y) { + return true; + } + if (typeof x !== typeof y) { + return false; + } + + // special-case NaN (since NaN !== NaN) + if (typeof x === "number" && isNaN(x) && isNaN(y)) { + return true; + } + + // special-case null (since typeof null == 'object', but null.constructor + // throws) + if (x === null || y === null) { + return x === y; + } + + // everything else is either an unequal primitive, or an object + if (!(x instanceof Object)) { + return false; + } + + // check they are the same type of object + if (x.constructor !== y.constructor || x.prototype !== y.prototype) { + return false; + } + + // special-casing for some special types of object + if (x instanceof RegExp || x instanceof Date) { + return x.toString() === y.toString(); + } + + // the object algorithm works for Array, but it's sub-optimal. + if (Array.isArray(x)) { + if (x.length !== y.length) { + return false; + } + for (let i = 0; i < x.length; i++) { + if (!deepCompare(x[i], y[i])) { + return false; + } + } + } else { + // check that all of y's direct keys are in x + for (const p in y) { + if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) { + return false; + } + } + + // finally, compare each of x's keys with y + for (const p in x) { + if (y.hasOwnProperty(p) !== x.hasOwnProperty(p) || !deepCompare(x[p], y[p])) { + return false; + } + } + } + return true; +} + +// Dev note: This returns an array of tuples, but jsdoc doesn't like that. https://github.com/jsdoc/jsdoc/issues/1703 +/** + * Creates an array of object properties/values (entries) then + * sorts the result by key, recursively. The input object must + * ensure it does not have loops. If the input is not an object + * then it will be returned as-is. + * @param obj - The object to get entries of + * @returns The entries, sorted by key. + */ +function deepSortedObjectEntries(obj) { + if (typeof obj !== "object") return obj; + + // Apparently these are object types... + if (obj === null || obj === undefined || Array.isArray(obj)) return obj; + const pairs = []; + for (const [k, v] of Object.entries(obj)) { + pairs.push([k, deepSortedObjectEntries(v)]); + } + + // lexicographicCompare is faster than localeCompare, so let's use that. + pairs.sort((a, b) => lexicographicCompare(a[0], b[0])); + return pairs; +} + +/** + * Returns whether the given value is a finite number without type-coercion + * + * @param value - the value to test + * @returns whether or not value is a finite number without type-coercion + */ +function isNumber(value) { + return typeof value === "number" && isFinite(value); +} + +/** + * Removes zero width chars, diacritics and whitespace from the string + * Also applies an unhomoglyph on the string, to prevent similar looking chars + * @param str - the string to remove hidden characters from + * @returns a string with the hidden characters removed + */ +function removeHiddenChars(str) { + if (typeof str === "string") { + return (0, _unhomoglyph.default)(str.normalize("NFD").replace(removeHiddenCharsRegex, "")); + } + return ""; +} + +/** + * Removes the direction override characters from a string + * @returns string with chars removed + */ +function removeDirectionOverrideChars(str) { + if (typeof str === "string") { + return str.replace(/[\u202d-\u202e]/g, ""); + } + return ""; +} +function normalize(str) { + // Note: we have to match the filter with the removeHiddenChars() because the + // function strips spaces and other characters (M becomes RN for example, in lowercase). + return removeHiddenChars(str.toLowerCase()) + // Strip all punctuation + .replace(/[\\'!"#$%&()*+,\-./:;<=>?@[\]^_`{|}~\u2000-\u206f\u2e00-\u2e7f]/g, "") + // We also doubly convert to lowercase to work around oddities of the library. + .toLowerCase(); +} + +// Regex matching bunch of unicode control characters and otherwise misleading/invisible characters. +// Includes: +// various width spaces U+2000 - U+200D +// LTR and RTL marks U+200E and U+200F +// LTR/RTL and other directional formatting marks U+202A - U+202F +// Arabic Letter RTL mark U+061C +// Combining characters U+0300 - U+036F +// Zero width no-break space (BOM) U+FEFF +// Blank/invisible characters (U2800, U2062-U2063) +// eslint-disable-next-line no-misleading-character-class +const removeHiddenCharsRegex = /[\u2000-\u200F\u202A-\u202F\u0300-\u036F\uFEFF\u061C\u2800\u2062-\u2063\s]/g; +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Converts Matrix glob-style string to a regular expression + * https://spec.matrix.org/v1.7/appendices/#glob-style-matching + * @param glob - Matrix glob-style string + * @returns regular expression + */ +function globToRegexp(glob) { + return escapeRegExp(glob).replace(/\\\*/g, ".*").replace(/\?/g, "."); +} +function ensureNoTrailingSlash(url) { + if (url?.endsWith("/")) { + return url.slice(0, -1); + } else { + return url; + } +} + +/** + * Returns a promise which resolves with a given value after the given number of ms + */ +function sleep(ms, value) { + return new Promise(resolve => { + setTimeout(resolve, ms, value); + }); +} + +/** + * Promise/async version of {@link setImmediate}. + */ +function immediate() { + return new Promise(setImmediate); +} +function isNullOrUndefined(val) { + return val === null || val === undefined; +} +// Returns a Deferred +function defer() { + let resolve; + let reject; + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + return { + resolve, + reject, + promise + }; +} +async function promiseMapSeries(promises, fn // if async we don't care about the type as we only await resolution +) { + for (const o of promises) { + await fn(await o); + } +} +function promiseTry(fn) { + return Promise.resolve(fn()); +} + +// Creates and awaits all promises, running no more than `chunkSize` at the same time +async function chunkPromises(fns, chunkSize) { + const results = []; + for (let i = 0; i < fns.length; i += chunkSize) { + results.push(...(await Promise.all(fns.slice(i, i + chunkSize).map(fn => fn())))); + } + return results; +} + +/** + * Retries the function until it succeeds or is interrupted. The given function must return + * a promise which throws/rejects on error, otherwise the retry will assume the request + * succeeded. The promise chain returned will contain the successful promise. The given function + * should always return a new promise. + * @param promiseFn - The function to call to get a fresh promise instance. Takes an + * attempt count as an argument, for logging/debugging purposes. + * @returns The promise for the retried operation. + */ +function simpleRetryOperation(promiseFn) { + return (0, _pRetry.default)(attempt => { + return promiseFn(attempt); + }, { + forever: true, + factor: 2, + minTimeout: 3000, + // ms + maxTimeout: 15000 // ms + }); +} + +// String averaging inspired by https://stackoverflow.com/a/2510816 +// Dev note: We make the alphabet a string because it's easier to write syntactically +// than arrays. Thankfully, strings implement the useful parts of the Array interface +// anyhow. + +/** + * The default alphabet used by string averaging in this SDK. This matches + * all usefully printable ASCII characters (0x20-0x7E, inclusive). + */ +const DEFAULT_ALPHABET = (() => { + let str = ""; + for (let c = 0x20; c <= 0x7e; c++) { + str += String.fromCharCode(c); + } + return str; +})(); + +/** + * Pads a string using the given alphabet as a base. The returned string will be + * padded at the end with the first character in the alphabet. + * + * This is intended for use with string averaging. + * @param s - The string to pad. + * @param n - The length to pad to. + * @param alphabet - The alphabet to use as a single string. + * @returns The padded string. + */ +exports.DEFAULT_ALPHABET = DEFAULT_ALPHABET; +function alphabetPad(s, n, alphabet = DEFAULT_ALPHABET) { + return s.padEnd(n, alphabet[0]); +} + +/** + * Converts a baseN number to a string, where N is the alphabet's length. + * + * This is intended for use with string averaging. + * @param n - The baseN number. + * @param alphabet - The alphabet to use as a single string. + * @returns The baseN number encoded as a string from the alphabet. + */ +function baseToString(n, alphabet = DEFAULT_ALPHABET) { + // Developer note: the stringToBase() function offsets the character set by 1 so that repeated + // characters (ie: "aaaaaa" in a..z) don't come out as zero. We have to reverse this here as + // otherwise we'll be wrong in our conversion. Undoing a +1 before an exponent isn't very fun + // though, so we rely on a lengthy amount of `x - 1` and integer division rules to reach a + // sane state. This also means we have to do rollover detection: see below. + + const len = BigInt(alphabet.length); + if (n <= len) { + return alphabet[Number(n) - 1] ?? ""; + } + let d = n / len; + let r = Number(n % len) - 1; + + // Rollover detection: if the remainder is negative, it means that the string needs + // to roll over by 1 character downwards (ie: in a..z, the previous to "aaa" would be + // "zz"). + if (r < 0) { + d -= BigInt(Math.abs(r)); // abs() is just to be clear what we're doing. Could also `+= r`. + r = Number(len) - 1; + } + return baseToString(d, alphabet) + alphabet[r]; +} + +/** + * Converts a string to a baseN number, where N is the alphabet's length. + * + * This is intended for use with string averaging. + * @param s - The string to convert to a number. + * @param alphabet - The alphabet to use as a single string. + * @returns The baseN number. + */ +function stringToBase(s, alphabet = DEFAULT_ALPHABET) { + const len = BigInt(alphabet.length); + + // In our conversion to baseN we do a couple performance optimizations to avoid using + // excess CPU and such. To create baseN numbers, the input string needs to be reversed + // so the exponents stack up appropriately, as the last character in the unreversed + // string has less impact than the first character (in "abc" the A is a lot more important + // for lexicographic sorts). We also do a trick with the character codes to optimize the + // alphabet lookup, avoiding an index scan of `alphabet.indexOf(reversedStr[i])` - we know + // that the alphabet and (theoretically) the input string are constrained on character sets + // and thus can do simple subtraction to end up with the same result. + + // Developer caution: we carefully cast to BigInt here to avoid losing precision. We cannot + // rely on Math.pow() (for example) to be capable of handling our insane numbers. + + let result = BigInt(0); + for (let i = s.length - 1, j = BigInt(0); i >= 0; i--, j++) { + const charIndex = s.charCodeAt(i) - alphabet.charCodeAt(0); + + // We add 1 to the char index to offset the whole numbering scheme. We unpack this in + // the baseToString() function. + result += BigInt(1 + charIndex) * len ** j; + } + return result; +} + +/** + * Averages two strings, returning the midpoint between them. This is accomplished by + * converting both to baseN numbers (where N is the alphabet's length) then averaging + * those before re-encoding as a string. + * @param a - The first string. + * @param b - The second string. + * @param alphabet - The alphabet to use as a single string. + * @returns The midpoint between the strings, as a string. + */ +function averageBetweenStrings(a, b, alphabet = DEFAULT_ALPHABET) { + const padN = Math.max(a.length, b.length); + const baseA = stringToBase(alphabetPad(a, padN, alphabet), alphabet); + const baseB = stringToBase(alphabetPad(b, padN, alphabet), alphabet); + const avg = (baseA + baseB) / BigInt(2); + + // Detect integer division conflicts. This happens when two numbers are divided too close so + // we lose a .5 precision. We need to add a padding character in these cases. + if (avg === baseA || avg == baseB) { + return baseToString(avg, alphabet) + alphabet[0]; + } + return baseToString(avg, alphabet); +} + +/** + * Finds the next string using the alphabet provided. This is done by converting the + * string to a baseN number, where N is the alphabet's length, then adding 1 before + * converting back to a string. + * @param s - The string to start at. + * @param alphabet - The alphabet to use as a single string. + * @returns The string which follows the input string. + */ +function nextString(s, alphabet = DEFAULT_ALPHABET) { + return baseToString(stringToBase(s, alphabet) + BigInt(1), alphabet); +} + +/** + * Finds the previous string using the alphabet provided. This is done by converting the + * string to a baseN number, where N is the alphabet's length, then subtracting 1 before + * converting back to a string. + * @param s - The string to start at. + * @param alphabet - The alphabet to use as a single string. + * @returns The string which precedes the input string. + */ +function prevString(s, alphabet = DEFAULT_ALPHABET) { + return baseToString(stringToBase(s, alphabet) - BigInt(1), alphabet); +} + +/** + * Compares strings lexicographically as a sort-safe function. + * @param a - The first (reference) string. + * @param b - The second (compare) string. + * @returns Negative if the reference string is before the compare string; + * positive if the reference string is after; and zero if equal. + */ +function lexicographicCompare(a, b) { + // Dev note: this exists because I'm sad that you can use math operators on strings, so I've + // hidden the operation in this function. + if (a < b) { + return -1; + } else if (a > b) { + return 1; + } else { + return 0; + } +} +const collator = new Intl.Collator(); +/** + * Performant language-sensitive string comparison + * @param a - the first string to compare + * @param b - the second string to compare + */ +function compare(a, b) { + return collator.compare(a, b); +} + +/** + * This function is similar to Object.assign() but it assigns recursively and + * allows you to ignore nullish values from the source + * + * @returns the target object + */ +function recursivelyAssign(target, source, ignoreNullish = false) { + for (const [sourceKey, sourceValue] of Object.entries(source)) { + if (target[sourceKey] instanceof Object && sourceValue) { + recursivelyAssign(target[sourceKey], sourceValue); + continue; + } + if (sourceValue !== null && sourceValue !== undefined || !ignoreNullish) { + safeSet(target, sourceKey, sourceValue); + continue; + } + } + return target; +} +function getContentTimestampWithFallback(event) { + return _location.M_TIMESTAMP.findIn(event.getContent()) ?? -1; +} + +/** + * Sort events by their content m.ts property + * Latest timestamp first + */ +function sortEventsByLatestContentTimestamp(left, right) { + return getContentTimestampWithFallback(right) - getContentTimestampWithFallback(left); +} +function isSupportedReceiptType(receiptType) { + return [_read_receipts.ReceiptType.Read, _read_receipts.ReceiptType.ReadPrivate].includes(receiptType); +} + +/** + * Determines whether two maps are equal. + * @param eq - The equivalence relation to compare values by. Defaults to strict equality. + */ +function mapsEqual(x, y, eq = (v1, v2) => v1 === v2) { + if (x.size !== y.size) return false; + for (const [k, v1] of x) { + const v2 = y.get(k); + if (v2 === undefined || !eq(v1, v2)) return false; + } + return true; +} +function processMapToObjectValue(value) { + if (value instanceof Map) { + // Value is a Map. Recursively map it to an object. + return recursiveMapToObject(value); + } else if (Array.isArray(value)) { + // Value is an Array. Recursively map the value (e.g. to cover Array of Arrays). + return value.map(v => processMapToObjectValue(v)); + } else { + return value; + } +} + +/** + * Recursively converts Maps to plain objects. + * Also supports sub-lists of Maps. + */ +function recursiveMapToObject(map) { + const targetMap = new Map(); + for (const [key, value] of map) { + targetMap.set(key, processMapToObjectValue(value)); + } + return Object.fromEntries(targetMap.entries()); +} +function unsafeProp(prop) { + return prop === "__proto__" || prop === "prototype" || prop === "constructor"; +} +function safeSet(obj, prop, value) { + if (unsafeProp(prop)) { + throw new Error("Trying to modify prototype or constructor"); + } + obj[prop] = value; +} +function noUnsafeEventProps(event) { + return !(unsafeProp(event.room_id) || unsafeProp(event.sender) || unsafeProp(event.user_id) || unsafeProp(event.event_id)); +} +class MapWithDefault extends Map { + constructor(createDefault) { + super(); + this.createDefault = createDefault; + } + + /** + * Returns the value if the key already exists. + * If not, it creates a new value under that key using the ctor callback and returns it. + */ + getOrCreate(key) { + if (!this.has(key)) { + this.set(key, this.createDefault()); + } + return this.get(key); + } +} +exports.MapWithDefault = MapWithDefault;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/audioContext.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/audioContext.js new file mode 100644 index 0000000000..4cecf68ad3 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/audioContext.js @@ -0,0 +1,52 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.releaseContext = exports.acquireContext = void 0; +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +let audioContext = null; +let refCount = 0; + +/** + * Acquires a reference to the shared AudioContext. + * It's highly recommended to reuse this AudioContext rather than creating your + * own, because multiple AudioContexts can be problematic in some browsers. + * Make sure to call releaseContext when you're done using it. + * @returns The shared AudioContext + */ +const acquireContext = () => { + if (audioContext === null) audioContext = new AudioContext(); + refCount++; + return audioContext; +}; + +/** + * Signals that one of the references to the shared AudioContext has been + * released, allowing the context and associated hardware resources to be + * cleaned up if nothing else is using it. + */ +exports.acquireContext = acquireContext; +const releaseContext = () => { + refCount--; + if (refCount === 0) { + audioContext?.close(); + audioContext = null; + } +}; +exports.releaseContext = releaseContext;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/call.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/call.js new file mode 100644 index 0000000000..862d7ce1f8 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/call.js @@ -0,0 +1,2364 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.MatrixCall = exports.CallType = exports.CallState = exports.CallParty = exports.CallEvent = exports.CallErrorCode = exports.CallError = exports.CallDirection = void 0; +exports.createNewMatrixCall = createNewMatrixCall; +exports.genCallID = genCallID; +exports.setTracksEnabled = setTracksEnabled; +exports.supportsMatrixCall = supportsMatrixCall; +var _uuid = require("uuid"); +var _sdpTransform = require("sdp-transform"); +var _logger = require("../logger"); +var _utils = require("../utils"); +var _event = require("../@types/event"); +var _randomstring = require("../randomstring"); +var _callEventTypes = require("./callEventTypes"); +var _callFeed = require("./callFeed"); +var _typedEventEmitter = require("../models/typed-event-emitter"); +var _deviceinfo = require("../crypto/deviceinfo"); +var _groupCall = require("./groupCall"); +var _httpApi = require("../http-api"); +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2015, 2016 OpenMarket Ltd + Copyright 2017 New Vector Ltd + Copyright 2019, 2020 The Matrix.org Foundation C.I.C. + Copyright 2021 - 2022 Šimon Brandner <simon.bra.ag@gmail.com> + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ /** + * This is an internal module. See {@link createNewMatrixCall} for the public API. + */ +var MediaType = /*#__PURE__*/function (MediaType) { + MediaType["AUDIO"] = "audio"; + MediaType["VIDEO"] = "video"; + return MediaType; +}(MediaType || {}); +var CodecName = /*#__PURE__*/function (CodecName) { + CodecName["OPUS"] = "opus"; + return CodecName; +}(CodecName || {}); // add more as needed +// Used internally to specify modifications to codec parameters in SDP +let CallState = /*#__PURE__*/function (CallState) { + CallState["Fledgling"] = "fledgling"; + CallState["InviteSent"] = "invite_sent"; + CallState["WaitLocalMedia"] = "wait_local_media"; + CallState["CreateOffer"] = "create_offer"; + CallState["CreateAnswer"] = "create_answer"; + CallState["Connecting"] = "connecting"; + CallState["Connected"] = "connected"; + CallState["Ringing"] = "ringing"; + CallState["Ended"] = "ended"; + return CallState; +}({}); +exports.CallState = CallState; +let CallType = /*#__PURE__*/function (CallType) { + CallType["Voice"] = "voice"; + CallType["Video"] = "video"; + return CallType; +}({}); +exports.CallType = CallType; +let CallDirection = /*#__PURE__*/function (CallDirection) { + CallDirection["Inbound"] = "inbound"; + CallDirection["Outbound"] = "outbound"; + return CallDirection; +}({}); +exports.CallDirection = CallDirection; +let CallParty = /*#__PURE__*/function (CallParty) { + CallParty["Local"] = "local"; + CallParty["Remote"] = "remote"; + return CallParty; +}({}); +exports.CallParty = CallParty; +let CallEvent = /*#__PURE__*/function (CallEvent) { + CallEvent["Hangup"] = "hangup"; + CallEvent["State"] = "state"; + CallEvent["Error"] = "error"; + CallEvent["Replaced"] = "replaced"; + CallEvent["LocalHoldUnhold"] = "local_hold_unhold"; + CallEvent["RemoteHoldUnhold"] = "remote_hold_unhold"; + CallEvent["HoldUnhold"] = "hold_unhold"; + CallEvent["FeedsChanged"] = "feeds_changed"; + CallEvent["AssertedIdentityChanged"] = "asserted_identity_changed"; + CallEvent["LengthChanged"] = "length_changed"; + CallEvent["DataChannel"] = "datachannel"; + CallEvent["SendVoipEvent"] = "send_voip_event"; + CallEvent["PeerConnectionCreated"] = "peer_connection_created"; + return CallEvent; +}({}); +exports.CallEvent = CallEvent; +let CallErrorCode = /*#__PURE__*/function (CallErrorCode) { + CallErrorCode["UserHangup"] = "user_hangup"; + CallErrorCode["LocalOfferFailed"] = "local_offer_failed"; + CallErrorCode["NoUserMedia"] = "no_user_media"; + CallErrorCode["UnknownDevices"] = "unknown_devices"; + CallErrorCode["SendInvite"] = "send_invite"; + CallErrorCode["CreateAnswer"] = "create_answer"; + CallErrorCode["CreateOffer"] = "create_offer"; + CallErrorCode["SendAnswer"] = "send_answer"; + CallErrorCode["SetRemoteDescription"] = "set_remote_description"; + CallErrorCode["SetLocalDescription"] = "set_local_description"; + CallErrorCode["AnsweredElsewhere"] = "answered_elsewhere"; + CallErrorCode["IceFailed"] = "ice_failed"; + CallErrorCode["InviteTimeout"] = "invite_timeout"; + CallErrorCode["Replaced"] = "replaced"; + CallErrorCode["SignallingFailed"] = "signalling_timeout"; + CallErrorCode["UserBusy"] = "user_busy"; + CallErrorCode["Transferred"] = "transferred"; + CallErrorCode["NewSession"] = "new_session"; + return CallErrorCode; +}({}); +/** + * The version field that we set in m.call.* events + */ +exports.CallErrorCode = CallErrorCode; +const VOIP_PROTO_VERSION = "1"; + +/** The fallback ICE server to use for STUN or TURN protocols. */ +const FALLBACK_ICE_SERVER = "stun:turn.matrix.org"; + +/** The length of time a call can be ringing for. */ +const CALL_TIMEOUT_MS = 60 * 1000; // ms +/** The time after which we increment callLength */ +const CALL_LENGTH_INTERVAL = 1000; // ms +/** The time after which we end the call, if ICE got disconnected */ +const ICE_DISCONNECTED_TIMEOUT = 30 * 1000; // ms +/** The time after which we try a ICE restart, if ICE got disconnected */ +const ICE_RECONNECTING_TIMEOUT = 2 * 1000; // ms +class CallError extends Error { + constructor(code, msg, err) { + // Still don't think there's any way to have proper nested errors + super(msg + ": " + err); + _defineProperty(this, "code", void 0); + this.code = code; + } +} +exports.CallError = CallError; +function genCallID() { + return Date.now().toString() + (0, _randomstring.randomString)(16); +} +function getCodecParamMods(isPtt) { + const mods = [{ + mediaType: "audio", + codec: "opus", + enableDtx: true, + maxAverageBitrate: isPtt ? 12000 : undefined + }]; + return mods; +} + +/** + * These now all have the call object as an argument. Why? Well, to know which call a given event is + * about you have three options: + * 1. Use a closure as the callback that remembers what call it's listening to. This can be + * a pain because you need to pass the listener function again when you remove the listener, + * which might be somewhere else. + * 2. Use not-very-well-known fact that EventEmitter sets 'this' to the emitter object in the + * callback. This doesn't really play well with modern Typescript and eslint and doesn't work + * with our pattern of re-emitting events. + * 3. Pass the object in question as an argument to the callback. + * + * Now that we have group calls which have to deal with multiple call objects, this will + * become more important, and I think methods 1 and 2 are just going to cause issues. + */ + +// The key of the transceiver map (purpose + media type, separated by ':') + +// generates keys for the map of transceivers +// kind is unfortunately a string rather than MediaType as this is the type of +// track.kind +function getTransceiverKey(purpose, kind) { + return purpose + ":" + kind; +} +class MatrixCall extends _typedEventEmitter.TypedEventEmitter { + /** + * Construct a new Matrix Call. + * @param opts - Config options. + */ + constructor(opts) { + super(); + _defineProperty(this, "roomId", void 0); + _defineProperty(this, "callId", void 0); + _defineProperty(this, "invitee", void 0); + _defineProperty(this, "hangupParty", void 0); + _defineProperty(this, "hangupReason", void 0); + _defineProperty(this, "direction", void 0); + _defineProperty(this, "ourPartyId", void 0); + _defineProperty(this, "peerConn", void 0); + _defineProperty(this, "toDeviceSeq", 0); + // whether this call should have push-to-talk semantics + // This should be set by the consumer on incoming & outgoing calls. + _defineProperty(this, "isPtt", false); + _defineProperty(this, "_state", CallState.Fledgling); + _defineProperty(this, "client", void 0); + _defineProperty(this, "forceTURN", void 0); + _defineProperty(this, "turnServers", void 0); + // A queue for candidates waiting to go out. + // We try to amalgamate candidates into a single candidate message where + // possible + _defineProperty(this, "candidateSendQueue", []); + _defineProperty(this, "candidateSendTries", 0); + _defineProperty(this, "candidatesEnded", false); + _defineProperty(this, "feeds", []); + // our transceivers for each purpose and type of media + _defineProperty(this, "transceivers", new Map()); + _defineProperty(this, "inviteOrAnswerSent", false); + _defineProperty(this, "waitForLocalAVStream", false); + _defineProperty(this, "successor", void 0); + _defineProperty(this, "opponentMember", void 0); + _defineProperty(this, "opponentVersion", void 0); + // The party ID of the other side: undefined if we haven't chosen a partner + // yet, null if we have but they didn't send a party ID. + _defineProperty(this, "opponentPartyId", void 0); + _defineProperty(this, "opponentCaps", void 0); + _defineProperty(this, "iceDisconnectedTimeout", void 0); + _defineProperty(this, "iceReconnectionTimeOut", void 0); + _defineProperty(this, "inviteTimeout", void 0); + _defineProperty(this, "removeTrackListeners", new Map()); + // The logic of when & if a call is on hold is nontrivial and explained in is*OnHold + // This flag represents whether we want the other party to be on hold + _defineProperty(this, "remoteOnHold", false); + // the stats for the call at the point it ended. We can't get these after we + // tear the call down, so we just grab a snapshot before we stop the call. + // The typescript definitions have this type as 'any' :( + _defineProperty(this, "callStatsAtEnd", void 0); + // Perfect negotiation state: https://www.w3.org/TR/webrtc/#perfect-negotiation-example + _defineProperty(this, "makingOffer", false); + _defineProperty(this, "ignoreOffer", false); + _defineProperty(this, "isSettingRemoteAnswerPending", false); + _defineProperty(this, "responsePromiseChain", void 0); + // If candidates arrive before we've picked an opponent (which, in particular, + // will happen if the opponent sends candidates eagerly before the user answers + // the call) we buffer them up here so we can then add the ones from the party we pick + _defineProperty(this, "remoteCandidateBuffer", new Map()); + _defineProperty(this, "remoteAssertedIdentity", void 0); + _defineProperty(this, "remoteSDPStreamMetadata", void 0); + _defineProperty(this, "callLengthInterval", void 0); + _defineProperty(this, "callStartTime", void 0); + _defineProperty(this, "opponentDeviceId", void 0); + _defineProperty(this, "opponentDeviceInfo", void 0); + _defineProperty(this, "opponentSessionId", void 0); + _defineProperty(this, "groupCallId", void 0); + // Used to keep the timer for the delay before actually stopping our + // video track after muting (see setLocalVideoMuted) + _defineProperty(this, "stopVideoTrackTimer", void 0); + // Used to allow connection without Video and Audio. To establish a webrtc connection without media a Data channel is + // needed At the moment this property is true if we allow MatrixClient with isVoipWithNoMediaAllowed = true + _defineProperty(this, "isOnlyDataChannelAllowed", void 0); + _defineProperty(this, "stats", void 0); + /** + * Internal + */ + _defineProperty(this, "gotLocalIceCandidate", event => { + if (event.candidate) { + if (this.candidatesEnded) { + _logger.logger.warn(`Call ${this.callId} gotLocalIceCandidate() got candidate after candidates have ended!`); + } + _logger.logger.debug(`Call ${this.callId} got local ICE ${event.candidate.sdpMid} ${event.candidate.candidate}`); + if (this.callHasEnded()) return; + + // As with the offer, note we need to make a copy of this object, not + // pass the original: that broke in Chrome ~m43. + if (event.candidate.candidate === "") { + this.queueCandidate(null); + } else { + this.queueCandidate(event.candidate); + } + } + }); + _defineProperty(this, "onIceGatheringStateChange", event => { + _logger.logger.debug(`Call ${this.callId} onIceGatheringStateChange() ice gathering state changed to ${this.peerConn.iceGatheringState}`); + if (this.peerConn?.iceGatheringState === "complete") { + this.queueCandidate(null); // We should leave it to WebRTC to announce the end + _logger.logger.debug(`Call ${this.callId} onIceGatheringStateChange() ice gathering state complete, set candidates have ended`); + } + }); + _defineProperty(this, "getLocalOfferFailed", err => { + _logger.logger.error(`Call ${this.callId} getLocalOfferFailed() running`, err); + this.emit(CallEvent.Error, new CallError(CallErrorCode.LocalOfferFailed, "Failed to get local offer!", err), this); + this.terminate(CallParty.Local, CallErrorCode.LocalOfferFailed, false); + }); + _defineProperty(this, "getUserMediaFailed", err => { + if (this.successor) { + this.successor.getUserMediaFailed(err); + return; + } + _logger.logger.warn(`Call ${this.callId} getUserMediaFailed() failed to get user media - ending call`, err); + this.emit(CallEvent.Error, new CallError(CallErrorCode.NoUserMedia, "Couldn't start capturing media! Is your microphone set up and " + "does this app have permission?", err), this); + this.terminate(CallParty.Local, CallErrorCode.NoUserMedia, false); + }); + _defineProperty(this, "onIceConnectionStateChanged", () => { + if (this.callHasEnded()) { + return; // because ICE can still complete as we're ending the call + } + + _logger.logger.debug(`Call ${this.callId} onIceConnectionStateChanged() running (state=${this.peerConn?.iceConnectionState}, conn=${this.peerConn?.connectionState})`); + + // ideally we'd consider the call to be connected when we get media but + // chrome doesn't implement any of the 'onstarted' events yet + if (["connected", "completed"].includes(this.peerConn?.iceConnectionState ?? "")) { + clearTimeout(this.iceDisconnectedTimeout); + this.iceDisconnectedTimeout = undefined; + if (this.iceReconnectionTimeOut) { + clearTimeout(this.iceReconnectionTimeOut); + } + this.state = CallState.Connected; + if (!this.callLengthInterval && !this.callStartTime) { + this.callStartTime = Date.now(); + this.callLengthInterval = setInterval(() => { + this.emit(CallEvent.LengthChanged, Math.round((Date.now() - this.callStartTime) / 1000), this); + }, CALL_LENGTH_INTERVAL); + } + } else if (this.peerConn?.iceConnectionState == "failed") { + this.candidatesEnded = false; + // Firefox for Android does not yet have support for restartIce() + // (the types say it's always defined though, so we have to cast + // to prevent typescript from warning). + if (this.peerConn?.restartIce) { + this.candidatesEnded = false; + _logger.logger.debug(`Call ${this.callId} onIceConnectionStateChanged() ice restart (state=${this.peerConn?.iceConnectionState})`); + this.peerConn.restartIce(); + } else { + _logger.logger.info(`Call ${this.callId} onIceConnectionStateChanged() hanging up call (ICE failed and no ICE restart method)`); + this.hangup(CallErrorCode.IceFailed, false); + } + } else if (this.peerConn?.iceConnectionState == "disconnected") { + this.candidatesEnded = false; + this.iceReconnectionTimeOut = setTimeout(() => { + _logger.logger.info(`Call ${this.callId} onIceConnectionStateChanged() ICE restarting because of ICE disconnected, (state=${this.peerConn?.iceConnectionState}, conn=${this.peerConn?.connectionState})`); + if (this.peerConn?.restartIce) { + this.candidatesEnded = false; + this.peerConn.restartIce(); + } + this.iceReconnectionTimeOut = undefined; + }, ICE_RECONNECTING_TIMEOUT); + this.iceDisconnectedTimeout = setTimeout(() => { + _logger.logger.info(`Call ${this.callId} onIceConnectionStateChanged() hanging up call (ICE disconnected for too long)`); + this.hangup(CallErrorCode.IceFailed, false); + }, ICE_DISCONNECTED_TIMEOUT); + this.state = CallState.Connecting; + } + + // In PTT mode, override feed status to muted when we lose connection to + // the peer, since we don't want to block the line if they're not saying anything. + // Experimenting in Chrome, this happens after 5 or 6 seconds, which is probably + // fast enough. + if (this.isPtt && ["failed", "disconnected"].includes(this.peerConn.iceConnectionState)) { + for (const feed of this.getRemoteFeeds()) { + feed.setAudioVideoMuted(true, true); + } + } + }); + _defineProperty(this, "onSignallingStateChanged", () => { + _logger.logger.debug(`Call ${this.callId} onSignallingStateChanged() running (state=${this.peerConn?.signalingState})`); + }); + _defineProperty(this, "onTrack", ev => { + if (ev.streams.length === 0) { + _logger.logger.warn(`Call ${this.callId} onTrack() called with streamless track streamless (kind=${ev.track.kind})`); + return; + } + const stream = ev.streams[0]; + this.pushRemoteFeed(stream); + if (!this.removeTrackListeners.has(stream)) { + const onRemoveTrack = () => { + if (stream.getTracks().length === 0) { + _logger.logger.info(`Call ${this.callId} onTrack() removing track (streamId=${stream.id})`); + this.deleteFeedByStream(stream); + stream.removeEventListener("removetrack", onRemoveTrack); + this.removeTrackListeners.delete(stream); + } + }; + stream.addEventListener("removetrack", onRemoveTrack); + this.removeTrackListeners.set(stream, onRemoveTrack); + } + }); + _defineProperty(this, "onDataChannel", ev => { + this.emit(CallEvent.DataChannel, ev.channel, this); + }); + _defineProperty(this, "onNegotiationNeeded", async () => { + _logger.logger.info(`Call ${this.callId} onNegotiationNeeded() negotiation is needed!`); + if (this.state !== CallState.CreateOffer && this.opponentVersion === 0) { + _logger.logger.info(`Call ${this.callId} onNegotiationNeeded() opponent does not support renegotiation: ignoring negotiationneeded event`); + return; + } + this.queueGotLocalOffer(); + }); + _defineProperty(this, "onHangupReceived", msg => { + _logger.logger.debug(`Call ${this.callId} onHangupReceived() running`); + + // party ID must match (our chosen partner hanging up the call) or be undefined (we haven't chosen + // a partner yet but we're treating the hangup as a reject as per VoIP v0) + if (this.partyIdMatches(msg) || this.state === CallState.Ringing) { + // default reason is user_hangup + this.terminate(CallParty.Remote, msg.reason || CallErrorCode.UserHangup, true); + } else { + _logger.logger.info(`Call ${this.callId} onHangupReceived() ignoring message from party ID ${msg.party_id}: our partner is ${this.opponentPartyId}`); + } + }); + _defineProperty(this, "onRejectReceived", msg => { + _logger.logger.debug(`Call ${this.callId} onRejectReceived() running`); + + // No need to check party_id for reject because if we'd received either + // an answer or reject, we wouldn't be in state InviteSent + + const shouldTerminate = + // reject events also end the call if it's ringing: it's another of + // our devices rejecting the call. + [CallState.InviteSent, CallState.Ringing].includes(this.state) || + // also if we're in the init state and it's an inbound call, since + // this means we just haven't entered the ringing state yet + this.state === CallState.Fledgling && this.direction === CallDirection.Inbound; + if (shouldTerminate) { + this.terminate(CallParty.Remote, msg.reason || CallErrorCode.UserHangup, true); + } else { + _logger.logger.debug(`Call ${this.callId} onRejectReceived() called in wrong state (state=${this.state})`); + } + }); + _defineProperty(this, "onAnsweredElsewhere", msg => { + _logger.logger.debug(`Call ${this.callId} onAnsweredElsewhere() running`); + this.terminate(CallParty.Remote, CallErrorCode.AnsweredElsewhere, true); + }); + this.roomId = opts.roomId; + this.invitee = opts.invitee; + this.client = opts.client; + if (!this.client.deviceId) throw new Error("Client must have a device ID to start calls"); + this.forceTURN = opts.forceTURN ?? false; + this.ourPartyId = this.client.deviceId; + this.opponentDeviceId = opts.opponentDeviceId; + this.opponentSessionId = opts.opponentSessionId; + this.groupCallId = opts.groupCallId; + // Array of Objects with urls, username, credential keys + this.turnServers = opts.turnServers || []; + if (this.turnServers.length === 0 && this.client.isFallbackICEServerAllowed()) { + this.turnServers.push({ + urls: [FALLBACK_ICE_SERVER] + }); + } + for (const server of this.turnServers) { + (0, _utils.checkObjectHasKeys)(server, ["urls"]); + } + this.callId = genCallID(); + // If the Client provides calls without audio and video we need a datachannel for a webrtc connection + this.isOnlyDataChannelAllowed = this.client.isVoipWithNoMediaAllowed; + } + + /** + * Place a voice call to this room. + * @throws If you have not specified a listener for 'error' events. + */ + async placeVoiceCall() { + await this.placeCall(true, false); + } + + /** + * Place a video call to this room. + * @throws If you have not specified a listener for 'error' events. + */ + async placeVideoCall() { + await this.placeCall(true, true); + } + + /** + * Create a datachannel using this call's peer connection. + * @param label - A human readable label for this datachannel + * @param options - An object providing configuration options for the data channel. + */ + createDataChannel(label, options) { + const dataChannel = this.peerConn.createDataChannel(label, options); + this.emit(CallEvent.DataChannel, dataChannel, this); + return dataChannel; + } + getOpponentMember() { + return this.opponentMember; + } + getOpponentDeviceId() { + return this.opponentDeviceId; + } + getOpponentSessionId() { + return this.opponentSessionId; + } + opponentCanBeTransferred() { + return Boolean(this.opponentCaps && this.opponentCaps["m.call.transferee"]); + } + opponentSupportsDTMF() { + return Boolean(this.opponentCaps && this.opponentCaps["m.call.dtmf"]); + } + getRemoteAssertedIdentity() { + return this.remoteAssertedIdentity; + } + get state() { + return this._state; + } + set state(state) { + const oldState = this._state; + this._state = state; + this.emit(CallEvent.State, state, oldState, this); + } + get type() { + // we may want to look for a video receiver here rather than a track to match the + // sender behaviour, although in practice they should be the same thing + return this.hasUserMediaVideoSender || this.hasRemoteUserMediaVideoTrack ? CallType.Video : CallType.Voice; + } + get hasLocalUserMediaVideoTrack() { + return !!this.localUsermediaStream?.getVideoTracks().length; + } + get hasRemoteUserMediaVideoTrack() { + return this.getRemoteFeeds().some(feed => { + return feed.purpose === _callEventTypes.SDPStreamMetadataPurpose.Usermedia && feed.stream?.getVideoTracks().length; + }); + } + get hasLocalUserMediaAudioTrack() { + return !!this.localUsermediaStream?.getAudioTracks().length; + } + get hasRemoteUserMediaAudioTrack() { + return this.getRemoteFeeds().some(feed => { + return feed.purpose === _callEventTypes.SDPStreamMetadataPurpose.Usermedia && !!feed.stream?.getAudioTracks().length; + }); + } + get hasUserMediaAudioSender() { + return Boolean(this.transceivers.get(getTransceiverKey(_callEventTypes.SDPStreamMetadataPurpose.Usermedia, "audio"))?.sender); + } + get hasUserMediaVideoSender() { + return Boolean(this.transceivers.get(getTransceiverKey(_callEventTypes.SDPStreamMetadataPurpose.Usermedia, "video"))?.sender); + } + get localUsermediaFeed() { + return this.getLocalFeeds().find(feed => feed.purpose === _callEventTypes.SDPStreamMetadataPurpose.Usermedia); + } + get localScreensharingFeed() { + return this.getLocalFeeds().find(feed => feed.purpose === _callEventTypes.SDPStreamMetadataPurpose.Screenshare); + } + get localUsermediaStream() { + return this.localUsermediaFeed?.stream; + } + get localScreensharingStream() { + return this.localScreensharingFeed?.stream; + } + get remoteUsermediaFeed() { + return this.getRemoteFeeds().find(feed => feed.purpose === _callEventTypes.SDPStreamMetadataPurpose.Usermedia); + } + get remoteScreensharingFeed() { + return this.getRemoteFeeds().find(feed => feed.purpose === _callEventTypes.SDPStreamMetadataPurpose.Screenshare); + } + get remoteUsermediaStream() { + return this.remoteUsermediaFeed?.stream; + } + get remoteScreensharingStream() { + return this.remoteScreensharingFeed?.stream; + } + getFeedByStreamId(streamId) { + return this.getFeeds().find(feed => feed.stream.id === streamId); + } + + /** + * Returns an array of all CallFeeds + * @returns CallFeeds + */ + getFeeds() { + return this.feeds; + } + + /** + * Returns an array of all local CallFeeds + * @returns local CallFeeds + */ + getLocalFeeds() { + return this.feeds.filter(feed => feed.isLocal()); + } + + /** + * Returns an array of all remote CallFeeds + * @returns remote CallFeeds + */ + getRemoteFeeds() { + return this.feeds.filter(feed => !feed.isLocal()); + } + async initOpponentCrypto() { + if (!this.opponentDeviceId) return; + if (!this.client.getUseE2eForGroupCall()) return; + // It's possible to want E2EE and yet not have the means to manage E2EE + // ourselves (for example if the client is a RoomWidgetClient) + if (!this.client.isCryptoEnabled()) { + // All we know is the device ID + this.opponentDeviceInfo = new _deviceinfo.DeviceInfo(this.opponentDeviceId); + return; + } + // if we've got to this point, we do want to init crypto, so throw if we can't + if (!this.client.crypto) throw new Error("Crypto is not initialised."); + const userId = this.invitee || this.getOpponentMember()?.userId; + if (!userId) throw new Error("Couldn't find opponent user ID to init crypto"); + const deviceInfoMap = await this.client.crypto.deviceList.downloadKeys([userId], false); + this.opponentDeviceInfo = deviceInfoMap.get(userId)?.get(this.opponentDeviceId); + if (this.opponentDeviceInfo === undefined) { + throw new _groupCall.GroupCallUnknownDeviceError(userId); + } + } + + /** + * Generates and returns localSDPStreamMetadata + * @returns localSDPStreamMetadata + */ + getLocalSDPStreamMetadata(updateStreamIds = false) { + const metadata = {}; + for (const localFeed of this.getLocalFeeds()) { + if (updateStreamIds) { + localFeed.sdpMetadataStreamId = localFeed.stream.id; + } + metadata[localFeed.sdpMetadataStreamId] = { + purpose: localFeed.purpose, + audio_muted: localFeed.isAudioMuted(), + video_muted: localFeed.isVideoMuted() + }; + } + return metadata; + } + + /** + * Returns true if there are no incoming feeds, + * otherwise returns false + * @returns no incoming feeds + */ + noIncomingFeeds() { + return !this.feeds.some(feed => !feed.isLocal()); + } + pushRemoteFeed(stream) { + // Fallback to old behavior if the other side doesn't support SDPStreamMetadata + if (!this.opponentSupportsSDPStreamMetadata()) { + this.pushRemoteFeedWithoutMetadata(stream); + return; + } + const userId = this.getOpponentMember().userId; + const purpose = this.remoteSDPStreamMetadata[stream.id].purpose; + const audioMuted = this.remoteSDPStreamMetadata[stream.id].audio_muted; + const videoMuted = this.remoteSDPStreamMetadata[stream.id].video_muted; + if (!purpose) { + _logger.logger.warn(`Call ${this.callId} pushRemoteFeed() ignoring stream because we didn't get any metadata about it (streamId=${stream.id})`); + return; + } + if (this.getFeedByStreamId(stream.id)) { + _logger.logger.warn(`Call ${this.callId} pushRemoteFeed() ignoring stream because we already have a feed for it (streamId=${stream.id})`); + return; + } + this.feeds.push(new _callFeed.CallFeed({ + client: this.client, + call: this, + roomId: this.roomId, + userId, + deviceId: this.getOpponentDeviceId(), + stream, + purpose, + audioMuted, + videoMuted + })); + this.emit(CallEvent.FeedsChanged, this.feeds, this); + _logger.logger.info(`Call ${this.callId} pushRemoteFeed() pushed stream (streamId=${stream.id}, active=${stream.active}, purpose=${purpose})`); + } + + /** + * This method is used ONLY if the other client doesn't support sending SDPStreamMetadata + */ + pushRemoteFeedWithoutMetadata(stream) { + const userId = this.getOpponentMember().userId; + // We can guess the purpose here since the other client can only send one stream + const purpose = _callEventTypes.SDPStreamMetadataPurpose.Usermedia; + const oldRemoteStream = this.feeds.find(feed => !feed.isLocal())?.stream; + + // Note that we check by ID and always set the remote stream: Chrome appears + // to make new stream objects when transceiver directionality is changed and the 'active' + // status of streams change - Dave + // If we already have a stream, check this stream has the same id + if (oldRemoteStream && stream.id !== oldRemoteStream.id) { + _logger.logger.warn(`Call ${this.callId} pushRemoteFeedWithoutMetadata() ignoring new stream because we already have stream (streamId=${stream.id})`); + return; + } + if (this.getFeedByStreamId(stream.id)) { + _logger.logger.warn(`Call ${this.callId} pushRemoteFeedWithoutMetadata() ignoring stream because we already have a feed for it (streamId=${stream.id})`); + return; + } + this.feeds.push(new _callFeed.CallFeed({ + client: this.client, + call: this, + roomId: this.roomId, + audioMuted: false, + videoMuted: false, + userId, + deviceId: this.getOpponentDeviceId(), + stream, + purpose + })); + this.emit(CallEvent.FeedsChanged, this.feeds, this); + _logger.logger.info(`Call ${this.callId} pushRemoteFeedWithoutMetadata() pushed stream (streamId=${stream.id}, active=${stream.active})`); + } + pushNewLocalFeed(stream, purpose, addToPeerConnection = true) { + const userId = this.client.getUserId(); + + // Tracks don't always start off enabled, eg. chrome will give a disabled + // audio track if you ask for user media audio and already had one that + // you'd set to disabled (presumably because it clones them internally). + setTracksEnabled(stream.getAudioTracks(), true); + setTracksEnabled(stream.getVideoTracks(), true); + if (this.getFeedByStreamId(stream.id)) { + _logger.logger.warn(`Call ${this.callId} pushNewLocalFeed() ignoring stream because we already have a feed for it (streamId=${stream.id})`); + return; + } + this.pushLocalFeed(new _callFeed.CallFeed({ + client: this.client, + roomId: this.roomId, + audioMuted: false, + videoMuted: false, + userId, + deviceId: this.getOpponentDeviceId(), + stream, + purpose + }), addToPeerConnection); + } + + /** + * Pushes supplied feed to the call + * @param callFeed - to push + * @param addToPeerConnection - whether to add the tracks to the peer connection + */ + pushLocalFeed(callFeed, addToPeerConnection = true) { + if (this.feeds.some(feed => callFeed.stream.id === feed.stream.id)) { + _logger.logger.info(`Call ${this.callId} pushLocalFeed() ignoring duplicate local stream (streamId=${callFeed.stream.id})`); + return; + } + this.feeds.push(callFeed); + if (addToPeerConnection) { + for (const track of callFeed.stream.getTracks()) { + _logger.logger.info(`Call ${this.callId} pushLocalFeed() adding track to peer connection (id=${track.id}, kind=${track.kind}, streamId=${callFeed.stream.id}, streamPurpose=${callFeed.purpose}, enabled=${track.enabled})`); + const tKey = getTransceiverKey(callFeed.purpose, track.kind); + if (this.transceivers.has(tKey)) { + // we already have a sender, so we re-use it. We try to re-use transceivers as much + // as possible because they can't be removed once added, so otherwise they just + // accumulate which makes the SDP very large very quickly: in fact it only takes + // about 6 video tracks to exceed the maximum size of an Olm-encrypted + // Matrix event. + const transceiver = this.transceivers.get(tKey); + transceiver.sender.replaceTrack(track); + // set the direction to indicate we're going to start sending again + // (this will trigger the re-negotiation) + transceiver.direction = transceiver.direction === "inactive" ? "sendonly" : "sendrecv"; + } else { + // create a new one. We need to use addTrack rather addTransceiver for this because firefox + // doesn't yet implement RTCRTPSender.setStreams() + // (https://bugzilla.mozilla.org/show_bug.cgi?id=1510802) so we'd have no way to group the + // two tracks together into a stream. + const newSender = this.peerConn.addTrack(track, callFeed.stream); + + // now go & fish for the new transceiver + const newTransceiver = this.peerConn.getTransceivers().find(t => t.sender === newSender); + if (newTransceiver) { + this.transceivers.set(tKey, newTransceiver); + } else { + _logger.logger.warn(`Call ${this.callId} pushLocalFeed() didn't find a matching transceiver after adding track!`); + } + } + } + } + _logger.logger.info(`Call ${this.callId} pushLocalFeed() pushed stream (id=${callFeed.stream.id}, active=${callFeed.stream.active}, purpose=${callFeed.purpose})`); + this.emit(CallEvent.FeedsChanged, this.feeds, this); + } + + /** + * Removes local call feed from the call and its tracks from the peer + * connection + * @param callFeed - to remove + */ + removeLocalFeed(callFeed) { + const audioTransceiverKey = getTransceiverKey(callFeed.purpose, "audio"); + const videoTransceiverKey = getTransceiverKey(callFeed.purpose, "video"); + for (const transceiverKey of [audioTransceiverKey, videoTransceiverKey]) { + // this is slightly mixing the track and transceiver API but is basically just shorthand. + // There is no way to actually remove a transceiver, so this just sets it to inactive + // (or recvonly) and replaces the source with nothing. + if (this.transceivers.has(transceiverKey)) { + const transceiver = this.transceivers.get(transceiverKey); + if (transceiver.sender) this.peerConn.removeTrack(transceiver.sender); + } + } + if (callFeed.purpose === _callEventTypes.SDPStreamMetadataPurpose.Screenshare) { + this.client.getMediaHandler().stopScreensharingStream(callFeed.stream); + } + this.deleteFeed(callFeed); + } + deleteAllFeeds() { + for (const feed of this.feeds) { + if (!feed.isLocal() || !this.groupCallId) { + feed.dispose(); + } + } + this.feeds = []; + this.emit(CallEvent.FeedsChanged, this.feeds, this); + } + deleteFeedByStream(stream) { + const feed = this.getFeedByStreamId(stream.id); + if (!feed) { + _logger.logger.warn(`Call ${this.callId} deleteFeedByStream() didn't find the feed to delete (streamId=${stream.id})`); + return; + } + this.deleteFeed(feed); + } + deleteFeed(feed) { + feed.dispose(); + this.feeds.splice(this.feeds.indexOf(feed), 1); + this.emit(CallEvent.FeedsChanged, this.feeds, this); + } + + // The typescript definitions have this type as 'any' :( + async getCurrentCallStats() { + if (this.callHasEnded()) { + return this.callStatsAtEnd; + } + return this.collectCallStats(); + } + async collectCallStats() { + // This happens when the call fails before it starts. + // For example when we fail to get capture sources + if (!this.peerConn) return; + const statsReport = await this.peerConn.getStats(); + const stats = []; + statsReport.forEach(item => { + stats.push(item); + }); + return stats; + } + + /** + * Configure this call from an invite event. Used by MatrixClient. + * @param event - The m.call.invite event + */ + async initWithInvite(event) { + const invite = event.getContent(); + this.direction = CallDirection.Inbound; + + // make sure we have valid turn creds. Unless something's gone wrong, it should + // poll and keep the credentials valid so this should be instant. + const haveTurnCreds = await this.client.checkTurnServers(); + if (!haveTurnCreds) { + _logger.logger.warn(`Call ${this.callId} initWithInvite() failed to get TURN credentials! Proceeding with call anyway...`); + } + const sdpStreamMetadata = invite[_callEventTypes.SDPStreamMetadataKey]; + if (sdpStreamMetadata) { + this.updateRemoteSDPStreamMetadata(sdpStreamMetadata); + } else { + _logger.logger.debug(`Call ${this.callId} initWithInvite() did not get any SDPStreamMetadata! Can not send/receive multiple streams`); + } + this.peerConn = this.createPeerConnection(); + this.emit(CallEvent.PeerConnectionCreated, this.peerConn, this); + // we must set the party ID before await-ing on anything: the call event + // handler will start giving us more call events (eg. candidates) so if + // we haven't set the party ID, we'll ignore them. + this.chooseOpponent(event); + await this.initOpponentCrypto(); + try { + await this.peerConn.setRemoteDescription(invite.offer); + _logger.logger.debug(`Call ${this.callId} initWithInvite() set remote description: ${invite.offer.type}`); + await this.addBufferedIceCandidates(); + } catch (e) { + _logger.logger.debug(`Call ${this.callId} initWithInvite() failed to set remote description`, e); + this.terminate(CallParty.Local, CallErrorCode.SetRemoteDescription, false); + return; + } + const remoteStream = this.feeds.find(feed => !feed.isLocal())?.stream; + + // According to previous comments in this file, firefox at some point did not + // add streams until media started arriving on them. Testing latest firefox + // (81 at time of writing), this is no longer a problem, so let's do it the correct way. + // + // For example in case of no media webrtc connections like screen share only call we have to allow webrtc + // connections without remote media. In this case we always use a data channel. At the moment we allow as well + // only data channel as media in the WebRTC connection with this setup here. + if (!this.isOnlyDataChannelAllowed && (!remoteStream || remoteStream.getTracks().length === 0)) { + _logger.logger.error(`Call ${this.callId} initWithInvite() no remote stream or no tracks after setting remote description!`); + this.terminate(CallParty.Local, CallErrorCode.SetRemoteDescription, false); + return; + } + this.state = CallState.Ringing; + if (event.getLocalAge()) { + // Time out the call if it's ringing for too long + const ringingTimer = setTimeout(() => { + if (this.state == CallState.Ringing) { + _logger.logger.debug(`Call ${this.callId} initWithInvite() invite has expired. Hanging up.`); + this.hangupParty = CallParty.Remote; // effectively + this.state = CallState.Ended; + this.stopAllMedia(); + if (this.peerConn.signalingState != "closed") { + this.peerConn.close(); + } + this.stats?.removeStatsReportGatherer(this.callId); + this.emit(CallEvent.Hangup, this); + } + }, invite.lifetime - event.getLocalAge()); + const onState = state => { + if (state !== CallState.Ringing) { + clearTimeout(ringingTimer); + this.off(CallEvent.State, onState); + } + }; + this.on(CallEvent.State, onState); + } + } + + /** + * Configure this call from a hangup or reject event. Used by MatrixClient. + * @param event - The m.call.hangup event + */ + initWithHangup(event) { + // perverse as it may seem, sometimes we want to instantiate a call with a + // hangup message (because when getting the state of the room on load, events + // come in reverse order and we want to remember that a call has been hung up) + this.state = CallState.Ended; + } + shouldAnswerWithMediaType(wantedValue, valueOfTheOtherSide, type) { + if (wantedValue && !valueOfTheOtherSide) { + // TODO: Figure out how to do this + _logger.logger.warn(`Call ${this.callId} shouldAnswerWithMediaType() unable to answer with ${type} because the other side isn't sending it either.`); + return false; + } else if (!(0, _utils.isNullOrUndefined)(wantedValue) && wantedValue !== valueOfTheOtherSide && !this.opponentSupportsSDPStreamMetadata()) { + _logger.logger.warn(`Call ${this.callId} shouldAnswerWithMediaType() unable to answer with ${type}=${wantedValue} because the other side doesn't support it. Answering with ${type}=${valueOfTheOtherSide}.`); + return valueOfTheOtherSide; + } + return wantedValue ?? valueOfTheOtherSide; + } + + /** + * Answer a call. + */ + async answer(audio, video) { + if (this.inviteOrAnswerSent) return; + // TODO: Figure out how to do this + if (audio === false && video === false) throw new Error("You CANNOT answer a call without media"); + if (!this.localUsermediaStream && !this.waitForLocalAVStream) { + const prevState = this.state; + const answerWithAudio = this.shouldAnswerWithMediaType(audio, this.hasRemoteUserMediaAudioTrack, "audio"); + const answerWithVideo = this.shouldAnswerWithMediaType(video, this.hasRemoteUserMediaVideoTrack, "video"); + this.state = CallState.WaitLocalMedia; + this.waitForLocalAVStream = true; + try { + const stream = await this.client.getMediaHandler().getUserMediaStream(answerWithAudio, answerWithVideo); + this.waitForLocalAVStream = false; + const usermediaFeed = new _callFeed.CallFeed({ + client: this.client, + roomId: this.roomId, + userId: this.client.getUserId(), + deviceId: this.client.getDeviceId() ?? undefined, + stream, + purpose: _callEventTypes.SDPStreamMetadataPurpose.Usermedia, + audioMuted: false, + videoMuted: false + }); + const feeds = [usermediaFeed]; + if (this.localScreensharingFeed) { + feeds.push(this.localScreensharingFeed); + } + this.answerWithCallFeeds(feeds); + } catch (e) { + if (answerWithVideo) { + // Try to answer without video + _logger.logger.warn(`Call ${this.callId} answer() failed to getUserMedia(), trying to getUserMedia() without video`); + this.state = prevState; + this.waitForLocalAVStream = false; + await this.answer(answerWithAudio, false); + } else { + this.getUserMediaFailed(e); + return; + } + } + } else if (this.waitForLocalAVStream) { + this.state = CallState.WaitLocalMedia; + } + } + answerWithCallFeeds(callFeeds) { + if (this.inviteOrAnswerSent) return; + this.queueGotCallFeedsForAnswer(callFeeds); + } + + /** + * Replace this call with a new call, e.g. for glare resolution. Used by + * MatrixClient. + * @param newCall - The new call. + */ + replacedBy(newCall) { + _logger.logger.debug(`Call ${this.callId} replacedBy() running (newCallId=${newCall.callId})`); + if (this.state === CallState.WaitLocalMedia) { + _logger.logger.debug(`Call ${this.callId} replacedBy() telling new call to wait for local media (newCallId=${newCall.callId})`); + newCall.waitForLocalAVStream = true; + } else if ([CallState.CreateOffer, CallState.InviteSent].includes(this.state)) { + if (newCall.direction === CallDirection.Outbound) { + newCall.queueGotCallFeedsForAnswer([]); + } else { + _logger.logger.debug(`Call ${this.callId} replacedBy() handing local stream to new call(newCallId=${newCall.callId})`); + newCall.queueGotCallFeedsForAnswer(this.getLocalFeeds().map(feed => feed.clone())); + } + } + this.successor = newCall; + this.emit(CallEvent.Replaced, newCall, this); + this.hangup(CallErrorCode.Replaced, true); + } + + /** + * Hangup a call. + * @param reason - The reason why the call is being hung up. + * @param suppressEvent - True to suppress emitting an event. + */ + hangup(reason, suppressEvent) { + if (this.callHasEnded()) return; + _logger.logger.debug(`Call ${this.callId} hangup() ending call (reason=${reason})`); + this.terminate(CallParty.Local, reason, !suppressEvent); + // We don't want to send hangup here if we didn't even get to sending an invite + if ([CallState.Fledgling, CallState.WaitLocalMedia].includes(this.state)) return; + const content = {}; + // Don't send UserHangup reason to older clients + if (this.opponentVersion && this.opponentVersion !== 0 || reason !== CallErrorCode.UserHangup) { + content["reason"] = reason; + } + this.sendVoipEvent(_event.EventType.CallHangup, content); + } + + /** + * Reject a call + * This used to be done by calling hangup, but is a separate method and protocol + * event as of MSC2746. + */ + reject() { + if (this.state !== CallState.Ringing) { + throw Error("Call must be in 'ringing' state to reject!"); + } + if (this.opponentVersion === 0) { + _logger.logger.info(`Call ${this.callId} reject() opponent version is less than 1: sending hangup instead of reject (opponentVersion=${this.opponentVersion})`); + this.hangup(CallErrorCode.UserHangup, true); + return; + } + _logger.logger.debug("Rejecting call: " + this.callId); + this.terminate(CallParty.Local, CallErrorCode.UserHangup, true); + this.sendVoipEvent(_event.EventType.CallReject, {}); + } + + /** + * Adds an audio and/or video track - upgrades the call + * @param audio - should add an audio track + * @param video - should add an video track + */ + async upgradeCall(audio, video) { + // We don't do call downgrades + if (!audio && !video) return; + if (!this.opponentSupportsSDPStreamMetadata()) return; + try { + _logger.logger.debug(`Call ${this.callId} upgradeCall() upgrading call (audio=${audio}, video=${video})`); + const getAudio = audio || this.hasLocalUserMediaAudioTrack; + const getVideo = video || this.hasLocalUserMediaVideoTrack; + + // updateLocalUsermediaStream() will take the tracks, use them as + // replacement and throw the stream away, so it isn't reusable + const stream = await this.client.getMediaHandler().getUserMediaStream(getAudio, getVideo, false); + await this.updateLocalUsermediaStream(stream, audio, video); + } catch (error) { + _logger.logger.error(`Call ${this.callId} upgradeCall() failed to upgrade the call`, error); + this.emit(CallEvent.Error, new CallError(CallErrorCode.NoUserMedia, "Failed to get camera access: ", error), this); + } + } + + /** + * Returns true if this.remoteSDPStreamMetadata is defined, otherwise returns false + * @returns can screenshare + */ + opponentSupportsSDPStreamMetadata() { + return Boolean(this.remoteSDPStreamMetadata); + } + + /** + * If there is a screensharing stream returns true, otherwise returns false + * @returns is screensharing + */ + isScreensharing() { + return Boolean(this.localScreensharingStream); + } + + /** + * Starts/stops screensharing + * @param enabled - the desired screensharing state + * @param desktopCapturerSourceId - optional id of the desktop capturer source to use + * @returns new screensharing state + */ + async setScreensharingEnabled(enabled, opts) { + // Skip if there is nothing to do + if (enabled && this.isScreensharing()) { + _logger.logger.warn(`Call ${this.callId} setScreensharingEnabled() there is already a screensharing stream - there is nothing to do!`); + return true; + } else if (!enabled && !this.isScreensharing()) { + _logger.logger.warn(`Call ${this.callId} setScreensharingEnabled() there already isn't a screensharing stream - there is nothing to do!`); + return false; + } + + // Fallback to replaceTrack() + if (!this.opponentSupportsSDPStreamMetadata()) { + return this.setScreensharingEnabledWithoutMetadataSupport(enabled, opts); + } + _logger.logger.debug(`Call ${this.callId} setScreensharingEnabled() running (enabled=${enabled})`); + if (enabled) { + try { + const stream = await this.client.getMediaHandler().getScreensharingStream(opts); + if (!stream) return false; + this.pushNewLocalFeed(stream, _callEventTypes.SDPStreamMetadataPurpose.Screenshare); + return true; + } catch (err) { + _logger.logger.error(`Call ${this.callId} setScreensharingEnabled() failed to get screen-sharing stream:`, err); + return false; + } + } else { + const audioTransceiver = this.transceivers.get(getTransceiverKey(_callEventTypes.SDPStreamMetadataPurpose.Screenshare, "audio")); + const videoTransceiver = this.transceivers.get(getTransceiverKey(_callEventTypes.SDPStreamMetadataPurpose.Screenshare, "video")); + for (const transceiver of [audioTransceiver, videoTransceiver]) { + // this is slightly mixing the track and transceiver API but is basically just shorthand + // for removing the sender. + if (transceiver && transceiver.sender) this.peerConn.removeTrack(transceiver.sender); + } + this.client.getMediaHandler().stopScreensharingStream(this.localScreensharingStream); + this.deleteFeedByStream(this.localScreensharingStream); + return false; + } + } + + /** + * Starts/stops screensharing + * Should be used ONLY if the opponent doesn't support SDPStreamMetadata + * @param enabled - the desired screensharing state + * @param desktopCapturerSourceId - optional id of the desktop capturer source to use + * @returns new screensharing state + */ + async setScreensharingEnabledWithoutMetadataSupport(enabled, opts) { + _logger.logger.debug(`Call ${this.callId} setScreensharingEnabledWithoutMetadataSupport() running (enabled=${enabled})`); + if (enabled) { + try { + const stream = await this.client.getMediaHandler().getScreensharingStream(opts); + if (!stream) return false; + const track = stream.getTracks().find(track => track.kind === "video"); + const sender = this.transceivers.get(getTransceiverKey(_callEventTypes.SDPStreamMetadataPurpose.Usermedia, "video"))?.sender; + sender?.replaceTrack(track ?? null); + this.pushNewLocalFeed(stream, _callEventTypes.SDPStreamMetadataPurpose.Screenshare, false); + return true; + } catch (err) { + _logger.logger.error(`Call ${this.callId} setScreensharingEnabledWithoutMetadataSupport() failed to get screen-sharing stream:`, err); + return false; + } + } else { + const track = this.localUsermediaStream?.getTracks().find(track => track.kind === "video"); + const sender = this.transceivers.get(getTransceiverKey(_callEventTypes.SDPStreamMetadataPurpose.Usermedia, "video"))?.sender; + sender?.replaceTrack(track ?? null); + this.client.getMediaHandler().stopScreensharingStream(this.localScreensharingStream); + this.deleteFeedByStream(this.localScreensharingStream); + return false; + } + } + + /** + * Replaces/adds the tracks from the passed stream to the localUsermediaStream + * @param stream - to use a replacement for the local usermedia stream + */ + async updateLocalUsermediaStream(stream, forceAudio = false, forceVideo = false) { + const callFeed = this.localUsermediaFeed; + const audioEnabled = forceAudio || !callFeed.isAudioMuted() && !this.remoteOnHold; + const videoEnabled = forceVideo || !callFeed.isVideoMuted() && !this.remoteOnHold; + _logger.logger.log(`Call ${this.callId} updateLocalUsermediaStream() running (streamId=${stream.id}, audio=${audioEnabled}, video=${videoEnabled})`); + setTracksEnabled(stream.getAudioTracks(), audioEnabled); + setTracksEnabled(stream.getVideoTracks(), videoEnabled); + + // We want to keep the same stream id, so we replace the tracks rather + // than the whole stream. + + // Firstly, we replace the tracks in our localUsermediaStream. + for (const track of this.localUsermediaStream.getTracks()) { + this.localUsermediaStream.removeTrack(track); + track.stop(); + } + for (const track of stream.getTracks()) { + this.localUsermediaStream.addTrack(track); + } + + // Then replace the old tracks, if possible. + for (const track of stream.getTracks()) { + const tKey = getTransceiverKey(_callEventTypes.SDPStreamMetadataPurpose.Usermedia, track.kind); + const transceiver = this.transceivers.get(tKey); + const oldSender = transceiver?.sender; + let added = false; + if (oldSender) { + try { + _logger.logger.info(`Call ${this.callId} updateLocalUsermediaStream() replacing track (id=${track.id}, kind=${track.kind}, streamId=${stream.id}, streamPurpose=${callFeed.purpose})`); + await oldSender.replaceTrack(track); + // Set the direction to indicate we're going to be sending. + // This is only necessary in the cases where we're upgrading + // the call to video after downgrading it. + transceiver.direction = transceiver.direction === "inactive" ? "sendonly" : "sendrecv"; + added = true; + } catch (error) { + _logger.logger.warn(`Call ${this.callId} updateLocalUsermediaStream() replaceTrack failed: adding new transceiver instead`, error); + } + } + if (!added) { + _logger.logger.info(`Call ${this.callId} updateLocalUsermediaStream() adding track to peer connection (id=${track.id}, kind=${track.kind}, streamId=${stream.id}, streamPurpose=${callFeed.purpose})`); + const newSender = this.peerConn.addTrack(track, this.localUsermediaStream); + const newTransceiver = this.peerConn.getTransceivers().find(t => t.sender === newSender); + if (newTransceiver) { + this.transceivers.set(tKey, newTransceiver); + } else { + _logger.logger.warn(`Call ${this.callId} updateLocalUsermediaStream() couldn't find matching transceiver for newly added track!`); + } + } + } + } + + /** + * Set whether our outbound video should be muted or not. + * @param muted - True to mute the outbound video. + * @returns the new mute state + */ + async setLocalVideoMuted(muted) { + _logger.logger.log(`Call ${this.callId} setLocalVideoMuted() running ${muted}`); + + // if we were still thinking about stopping and removing the video + // track: don't, because we want it back. + if (!muted && this.stopVideoTrackTimer !== undefined) { + clearTimeout(this.stopVideoTrackTimer); + this.stopVideoTrackTimer = undefined; + } + if (!(await this.client.getMediaHandler().hasVideoDevice())) { + return this.isLocalVideoMuted(); + } + if (!this.hasUserMediaVideoSender && !muted) { + this.localUsermediaFeed?.setAudioVideoMuted(null, muted); + await this.upgradeCall(false, true); + return this.isLocalVideoMuted(); + } + + // we may not have a video track - if not, re-request usermedia + if (!muted && this.localUsermediaStream.getVideoTracks().length === 0) { + const stream = await this.client.getMediaHandler().getUserMediaStream(true, true); + await this.updateLocalUsermediaStream(stream); + } + this.localUsermediaFeed?.setAudioVideoMuted(null, muted); + this.updateMuteStatus(); + await this.sendMetadataUpdate(); + + // if we're muting video, set a timeout to stop & remove the video track so we release + // the camera. We wait a short time to do this because when we disable a track, WebRTC + // will send black video for it. If we just stop and remove it straight away, the video + // will just freeze which means that when we unmute video, the other side will briefly + // get a static frame of us from before we muted. This way, the still frame is just black. + // A very small delay is not always enough so the theory here is that it needs to be long + // enough for WebRTC to encode a frame: 120ms should be long enough even if we're only + // doing 10fps. + if (muted) { + this.stopVideoTrackTimer = setTimeout(() => { + for (const t of this.localUsermediaStream.getVideoTracks()) { + t.stop(); + this.localUsermediaStream.removeTrack(t); + } + }, 120); + } + return this.isLocalVideoMuted(); + } + + /** + * Check if local video is muted. + * + * If there are multiple video tracks, <i>all</i> of the tracks need to be muted + * for this to return true. This means if there are no video tracks, this will + * return true. + * @returns True if the local preview video is muted, else false + * (including if the call is not set up yet). + */ + isLocalVideoMuted() { + return this.localUsermediaFeed?.isVideoMuted() ?? false; + } + + /** + * Set whether the microphone should be muted or not. + * @param muted - True to mute the mic. + * @returns the new mute state + */ + async setMicrophoneMuted(muted) { + _logger.logger.log(`Call ${this.callId} setMicrophoneMuted() running ${muted}`); + if (!(await this.client.getMediaHandler().hasAudioDevice())) { + return this.isMicrophoneMuted(); + } + if (!muted && (!this.hasUserMediaAudioSender || !this.hasLocalUserMediaAudioTrack)) { + await this.upgradeCall(true, false); + return this.isMicrophoneMuted(); + } + this.localUsermediaFeed?.setAudioVideoMuted(muted, null); + this.updateMuteStatus(); + await this.sendMetadataUpdate(); + return this.isMicrophoneMuted(); + } + + /** + * Check if the microphone is muted. + * + * If there are multiple audio tracks, <i>all</i> of the tracks need to be muted + * for this to return true. This means if there are no audio tracks, this will + * return true. + * @returns True if the mic is muted, else false (including if the call + * is not set up yet). + */ + isMicrophoneMuted() { + return this.localUsermediaFeed?.isAudioMuted() ?? false; + } + + /** + * @returns true if we have put the party on the other side of the call on hold + * (that is, we are signalling to them that we are not listening) + */ + isRemoteOnHold() { + return this.remoteOnHold; + } + setRemoteOnHold(onHold) { + if (this.isRemoteOnHold() === onHold) return; + this.remoteOnHold = onHold; + for (const transceiver of this.peerConn.getTransceivers()) { + // We don't send hold music or anything so we're not actually + // sending anything, but sendrecv is fairly standard for hold and + // it makes it a lot easier to figure out who's put who on hold. + transceiver.direction = onHold ? "sendonly" : "sendrecv"; + } + this.updateMuteStatus(); + this.sendMetadataUpdate(); + this.emit(CallEvent.RemoteHoldUnhold, this.remoteOnHold, this); + } + + /** + * Indicates whether we are 'on hold' to the remote party (ie. if true, + * they cannot hear us). + * @returns true if the other party has put us on hold + */ + isLocalOnHold() { + if (this.state !== CallState.Connected) return false; + let callOnHold = true; + + // We consider a call to be on hold only if *all* the tracks are on hold + // (is this the right thing to do?) + for (const transceiver of this.peerConn.getTransceivers()) { + const trackOnHold = ["inactive", "recvonly"].includes(transceiver.currentDirection); + if (!trackOnHold) callOnHold = false; + } + return callOnHold; + } + + /** + * Sends a DTMF digit to the other party + * @param digit - The digit (nb. string - '#' and '*' are dtmf too) + */ + sendDtmfDigit(digit) { + for (const sender of this.peerConn.getSenders()) { + if (sender.track?.kind === "audio" && sender.dtmf) { + sender.dtmf.insertDTMF(digit); + return; + } + } + throw new Error("Unable to find a track to send DTMF on"); + } + updateMuteStatus() { + const micShouldBeMuted = this.isMicrophoneMuted() || this.remoteOnHold; + const vidShouldBeMuted = this.isLocalVideoMuted() || this.remoteOnHold; + _logger.logger.log(`Call ${this.callId} updateMuteStatus stream ${this.localUsermediaStream.id} micShouldBeMuted ${micShouldBeMuted} vidShouldBeMuted ${vidShouldBeMuted}`); + setTracksEnabled(this.localUsermediaStream.getAudioTracks(), !micShouldBeMuted); + setTracksEnabled(this.localUsermediaStream.getVideoTracks(), !vidShouldBeMuted); + } + async sendMetadataUpdate() { + await this.sendVoipEvent(_event.EventType.CallSDPStreamMetadataChangedPrefix, { + [_callEventTypes.SDPStreamMetadataKey]: this.getLocalSDPStreamMetadata() + }); + } + gotCallFeedsForInvite(callFeeds, requestScreenshareFeed = false) { + if (this.successor) { + this.successor.queueGotCallFeedsForAnswer(callFeeds); + return; + } + if (this.callHasEnded()) { + this.stopAllMedia(); + return; + } + for (const feed of callFeeds) { + this.pushLocalFeed(feed); + } + if (requestScreenshareFeed) { + this.peerConn.addTransceiver("video", { + direction: "recvonly" + }); + } + this.state = CallState.CreateOffer; + _logger.logger.debug(`Call ${this.callId} gotUserMediaForInvite() run`); + // Now we wait for the negotiationneeded event + } + + async sendAnswer() { + const answerContent = { + answer: { + sdp: this.peerConn.localDescription.sdp, + // type is now deprecated as of Matrix VoIP v1, but + // required to still be sent for backwards compat + type: this.peerConn.localDescription.type + }, + [_callEventTypes.SDPStreamMetadataKey]: this.getLocalSDPStreamMetadata(true) + }; + answerContent.capabilities = { + "m.call.transferee": this.client.supportsCallTransfer, + "m.call.dtmf": false + }; + + // We have just taken the local description from the peerConn which will + // contain all the local candidates added so far, so we can discard any candidates + // we had queued up because they'll be in the answer. + const discardCount = this.discardDuplicateCandidates(); + _logger.logger.info(`Call ${this.callId} sendAnswer() discarding ${discardCount} candidates that will be sent in answer`); + try { + await this.sendVoipEvent(_event.EventType.CallAnswer, answerContent); + // If this isn't the first time we've tried to send the answer, + // we may have candidates queued up, so send them now. + this.inviteOrAnswerSent = true; + } catch (error) { + // We've failed to answer: back to the ringing state + this.state = CallState.Ringing; + if (error instanceof _httpApi.MatrixError && error.event) this.client.cancelPendingEvent(error.event); + let code = CallErrorCode.SendAnswer; + let message = "Failed to send answer"; + if (error.name == "UnknownDeviceError") { + code = CallErrorCode.UnknownDevices; + message = "Unknown devices present in the room"; + } + this.emit(CallEvent.Error, new CallError(code, message, error), this); + throw error; + } + + // error handler re-throws so this won't happen on error, but + // we don't want the same error handling on the candidate queue + this.sendCandidateQueue(); + } + queueGotCallFeedsForAnswer(callFeeds) { + // Ensure only one negotiate/answer event is being processed at a time. + if (this.responsePromiseChain) { + this.responsePromiseChain = this.responsePromiseChain.then(() => this.gotCallFeedsForAnswer(callFeeds)); + } else { + this.responsePromiseChain = this.gotCallFeedsForAnswer(callFeeds); + } + } + + // Enables DTX (discontinuous transmission) on the given session to reduce + // bandwidth when transmitting silence + mungeSdp(description, mods) { + // The only way to enable DTX at this time is through SDP munging + const sdp = (0, _sdpTransform.parse)(description.sdp); + sdp.media.forEach(media => { + const payloadTypeToCodecMap = new Map(); + const codecToPayloadTypeMap = new Map(); + for (const rtp of media.rtp) { + payloadTypeToCodecMap.set(rtp.payload, rtp.codec); + codecToPayloadTypeMap.set(rtp.codec, rtp.payload); + } + for (const mod of mods) { + if (mod.mediaType !== media.type) continue; + if (!codecToPayloadTypeMap.has(mod.codec)) { + _logger.logger.info(`Call ${this.callId} mungeSdp() ignoring SDP modifications for ${mod.codec} as it's not present.`); + continue; + } + const extraConfig = []; + if (mod.enableDtx !== undefined) { + extraConfig.push(`usedtx=${mod.enableDtx ? "1" : "0"}`); + } + if (mod.maxAverageBitrate !== undefined) { + extraConfig.push(`maxaveragebitrate=${mod.maxAverageBitrate}`); + } + let found = false; + for (const fmtp of media.fmtp) { + if (payloadTypeToCodecMap.get(fmtp.payload) === mod.codec) { + found = true; + fmtp.config += ";" + extraConfig.join(";"); + } + } + if (!found) { + media.fmtp.push({ + payload: codecToPayloadTypeMap.get(mod.codec), + config: extraConfig.join(";") + }); + } + } + }); + description.sdp = (0, _sdpTransform.write)(sdp); + } + async createOffer() { + const offer = await this.peerConn.createOffer(); + this.mungeSdp(offer, getCodecParamMods(this.isPtt)); + return offer; + } + async createAnswer() { + const answer = await this.peerConn.createAnswer(); + this.mungeSdp(answer, getCodecParamMods(this.isPtt)); + return answer; + } + async gotCallFeedsForAnswer(callFeeds) { + if (this.callHasEnded()) return; + this.waitForLocalAVStream = false; + for (const feed of callFeeds) { + this.pushLocalFeed(feed); + } + this.state = CallState.CreateAnswer; + let answer; + try { + this.getRidOfRTXCodecs(); + answer = await this.createAnswer(); + } catch (err) { + _logger.logger.debug(`Call ${this.callId} gotCallFeedsForAnswer() failed to create answer: `, err); + this.terminate(CallParty.Local, CallErrorCode.CreateAnswer, true); + return; + } + try { + await this.peerConn.setLocalDescription(answer); + + // make sure we're still going + if (this.callHasEnded()) return; + this.state = CallState.Connecting; + + // Allow a short time for initial candidates to be gathered + await new Promise(resolve => { + setTimeout(resolve, 200); + }); + + // make sure the call hasn't ended before we continue + if (this.callHasEnded()) return; + this.sendAnswer(); + } catch (err) { + _logger.logger.debug(`Call ${this.callId} gotCallFeedsForAnswer() error setting local description!`, err); + this.terminate(CallParty.Local, CallErrorCode.SetLocalDescription, true); + return; + } + } + async onRemoteIceCandidatesReceived(ev) { + if (this.callHasEnded()) { + //debuglog("Ignoring remote ICE candidate because call has ended"); + return; + } + const content = ev.getContent(); + const candidates = content.candidates; + if (!candidates) { + _logger.logger.info(`Call ${this.callId} onRemoteIceCandidatesReceived() ignoring candidates event with no candidates!`); + return; + } + const fromPartyId = content.version === 0 ? null : content.party_id || null; + if (this.opponentPartyId === undefined) { + // we haven't picked an opponent yet so save the candidates + if (fromPartyId) { + _logger.logger.info(`Call ${this.callId} onRemoteIceCandidatesReceived() buffering ${candidates.length} candidates until we pick an opponent`); + const bufferedCandidates = this.remoteCandidateBuffer.get(fromPartyId) || []; + bufferedCandidates.push(...candidates); + this.remoteCandidateBuffer.set(fromPartyId, bufferedCandidates); + } + return; + } + if (!this.partyIdMatches(content)) { + _logger.logger.info(`Call ${this.callId} onRemoteIceCandidatesReceived() ignoring candidates from party ID ${content.party_id}: we have chosen party ID ${this.opponentPartyId}`); + return; + } + await this.addIceCandidates(candidates); + } + + /** + * Used by MatrixClient. + */ + async onAnswerReceived(event) { + const content = event.getContent(); + _logger.logger.debug(`Call ${this.callId} onAnswerReceived() running (hangupParty=${content.party_id})`); + if (this.callHasEnded()) { + _logger.logger.debug(`Call ${this.callId} onAnswerReceived() ignoring answer because call has ended`); + return; + } + if (this.opponentPartyId !== undefined) { + _logger.logger.info(`Call ${this.callId} onAnswerReceived() ignoring answer from party ID ${content.party_id}: we already have an answer/reject from ${this.opponentPartyId}`); + return; + } + this.chooseOpponent(event); + await this.addBufferedIceCandidates(); + this.state = CallState.Connecting; + const sdpStreamMetadata = content[_callEventTypes.SDPStreamMetadataKey]; + if (sdpStreamMetadata) { + this.updateRemoteSDPStreamMetadata(sdpStreamMetadata); + } else { + _logger.logger.warn(`Call ${this.callId} onAnswerReceived() did not get any SDPStreamMetadata! Can not send/receive multiple streams`); + } + try { + this.isSettingRemoteAnswerPending = true; + await this.peerConn.setRemoteDescription(content.answer); + this.isSettingRemoteAnswerPending = false; + _logger.logger.debug(`Call ${this.callId} onAnswerReceived() set remote description: ${content.answer.type}`); + } catch (e) { + this.isSettingRemoteAnswerPending = false; + _logger.logger.debug(`Call ${this.callId} onAnswerReceived() failed to set remote description`, e); + this.terminate(CallParty.Local, CallErrorCode.SetRemoteDescription, false); + return; + } + + // If the answer we selected has a party_id, send a select_answer event + // We do this after setting the remote description since otherwise we'd block + // call setup on it + if (this.opponentPartyId !== null) { + try { + await this.sendVoipEvent(_event.EventType.CallSelectAnswer, { + selected_party_id: this.opponentPartyId + }); + } catch (err) { + // This isn't fatal, and will just mean that if another party has raced to answer + // the call, they won't know they got rejected, so we carry on & don't retry. + _logger.logger.warn(`Call ${this.callId} onAnswerReceived() failed to send select_answer event`, err); + } + } + } + async onSelectAnswerReceived(event) { + if (this.direction !== CallDirection.Inbound) { + _logger.logger.warn(`Call ${this.callId} onSelectAnswerReceived() got select_answer for an outbound call: ignoring`); + return; + } + const selectedPartyId = event.getContent().selected_party_id; + if (selectedPartyId === undefined || selectedPartyId === null) { + _logger.logger.warn(`Call ${this.callId} onSelectAnswerReceived() got nonsensical select_answer with null/undefined selected_party_id: ignoring`); + return; + } + if (selectedPartyId !== this.ourPartyId) { + _logger.logger.info(`Call ${this.callId} onSelectAnswerReceived() got select_answer for party ID ${selectedPartyId}: we are party ID ${this.ourPartyId}.`); + // The other party has picked somebody else's answer + await this.terminate(CallParty.Remote, CallErrorCode.AnsweredElsewhere, true); + } + } + async onNegotiateReceived(event) { + const content = event.getContent(); + const description = content.description; + if (!description || !description.sdp || !description.type) { + _logger.logger.info(`Call ${this.callId} onNegotiateReceived() ignoring invalid m.call.negotiate event`); + return; + } + // Politeness always follows the direction of the call: in a glare situation, + // we pick either the inbound or outbound call, so one side will always be + // inbound and one outbound + const polite = this.direction === CallDirection.Inbound; + + // Here we follow the perfect negotiation logic from + // https://w3c.github.io/webrtc-pc/#perfect-negotiation-example + const readyForOffer = !this.makingOffer && (this.peerConn.signalingState === "stable" || this.isSettingRemoteAnswerPending); + const offerCollision = description.type === "offer" && !readyForOffer; + this.ignoreOffer = !polite && offerCollision; + if (this.ignoreOffer) { + _logger.logger.info(`Call ${this.callId} onNegotiateReceived() ignoring colliding negotiate event because we're impolite`); + return; + } + const prevLocalOnHold = this.isLocalOnHold(); + const sdpStreamMetadata = content[_callEventTypes.SDPStreamMetadataKey]; + if (sdpStreamMetadata) { + this.updateRemoteSDPStreamMetadata(sdpStreamMetadata); + } else { + _logger.logger.warn(`Call ${this.callId} onNegotiateReceived() received negotiation event without SDPStreamMetadata!`); + } + try { + this.isSettingRemoteAnswerPending = description.type == "answer"; + await this.peerConn.setRemoteDescription(description); // SRD rolls back as needed + this.isSettingRemoteAnswerPending = false; + _logger.logger.debug(`Call ${this.callId} onNegotiateReceived() set remote description: ${description.type}`); + if (description.type === "offer") { + let answer; + try { + this.getRidOfRTXCodecs(); + answer = await this.createAnswer(); + } catch (err) { + _logger.logger.debug(`Call ${this.callId} onNegotiateReceived() failed to create answer: `, err); + this.terminate(CallParty.Local, CallErrorCode.CreateAnswer, true); + return; + } + await this.peerConn.setLocalDescription(answer); + _logger.logger.debug(`Call ${this.callId} onNegotiateReceived() create an answer`); + this.sendVoipEvent(_event.EventType.CallNegotiate, { + description: this.peerConn.localDescription?.toJSON(), + [_callEventTypes.SDPStreamMetadataKey]: this.getLocalSDPStreamMetadata(true) + }); + } + } catch (err) { + this.isSettingRemoteAnswerPending = false; + _logger.logger.warn(`Call ${this.callId} onNegotiateReceived() failed to complete negotiation`, err); + } + const newLocalOnHold = this.isLocalOnHold(); + if (prevLocalOnHold !== newLocalOnHold) { + this.emit(CallEvent.LocalHoldUnhold, newLocalOnHold, this); + // also this one for backwards compat + this.emit(CallEvent.HoldUnhold, newLocalOnHold); + } + } + updateRemoteSDPStreamMetadata(metadata) { + this.remoteSDPStreamMetadata = (0, _utils.recursivelyAssign)(this.remoteSDPStreamMetadata || {}, metadata, true); + for (const feed of this.getRemoteFeeds()) { + const streamId = feed.stream.id; + const metadata = this.remoteSDPStreamMetadata[streamId]; + feed.setAudioVideoMuted(metadata?.audio_muted, metadata?.video_muted); + feed.purpose = this.remoteSDPStreamMetadata[streamId]?.purpose; + } + } + onSDPStreamMetadataChangedReceived(event) { + const content = event.getContent(); + const metadata = content[_callEventTypes.SDPStreamMetadataKey]; + this.updateRemoteSDPStreamMetadata(metadata); + } + async onAssertedIdentityReceived(event) { + const content = event.getContent(); + if (!content.asserted_identity) return; + this.remoteAssertedIdentity = { + id: content.asserted_identity.id, + displayName: content.asserted_identity.display_name + }; + this.emit(CallEvent.AssertedIdentityChanged, this); + } + callHasEnded() { + // This exists as workaround to typescript trying to be clever and erroring + // when putting if (this.state === CallState.Ended) return; twice in the same + // function, even though that function is async. + return this.state === CallState.Ended; + } + queueGotLocalOffer() { + // Ensure only one negotiate/answer event is being processed at a time. + if (this.responsePromiseChain) { + this.responsePromiseChain = this.responsePromiseChain.then(() => this.wrappedGotLocalOffer()); + } else { + this.responsePromiseChain = this.wrappedGotLocalOffer(); + } + } + async wrappedGotLocalOffer() { + this.makingOffer = true; + try { + // XXX: in what situations do we believe gotLocalOffer actually throws? It appears + // to handle most of its exceptions itself and terminate the call. I'm not entirely + // sure it would ever throw, so I can't add a test for these lines. + // Also the tense is different between "gotLocalOffer" and "getLocalOfferFailed" so + // it's not entirely clear whether getLocalOfferFailed is just misnamed or whether + // they've been cross-polinated somehow at some point. + await this.gotLocalOffer(); + } catch (e) { + this.getLocalOfferFailed(e); + return; + } finally { + this.makingOffer = false; + } + } + async gotLocalOffer() { + _logger.logger.debug(`Call ${this.callId} gotLocalOffer() running`); + if (this.callHasEnded()) { + _logger.logger.debug(`Call ${this.callId} gotLocalOffer() ignoring newly created offer because the call has ended"`); + return; + } + let offer; + try { + this.getRidOfRTXCodecs(); + offer = await this.createOffer(); + } catch (err) { + _logger.logger.debug(`Call ${this.callId} gotLocalOffer() failed to create offer: `, err); + this.terminate(CallParty.Local, CallErrorCode.CreateOffer, true); + return; + } + try { + await this.peerConn.setLocalDescription(offer); + } catch (err) { + _logger.logger.debug(`Call ${this.callId} gotLocalOffer() error setting local description!`, err); + this.terminate(CallParty.Local, CallErrorCode.SetLocalDescription, true); + return; + } + if (this.peerConn.iceGatheringState === "gathering") { + // Allow a short time for initial candidates to be gathered + await new Promise(resolve => { + setTimeout(resolve, 200); + }); + } + if (this.callHasEnded()) return; + const eventType = this.state === CallState.CreateOffer ? _event.EventType.CallInvite : _event.EventType.CallNegotiate; + const content = { + lifetime: CALL_TIMEOUT_MS + }; + if (eventType === _event.EventType.CallInvite && this.invitee) { + content.invitee = this.invitee; + } + + // clunky because TypeScript can't follow the types through if we use an expression as the key + if (this.state === CallState.CreateOffer) { + content.offer = this.peerConn.localDescription?.toJSON(); + } else { + content.description = this.peerConn.localDescription?.toJSON(); + } + content.capabilities = { + "m.call.transferee": this.client.supportsCallTransfer, + "m.call.dtmf": false + }; + content[_callEventTypes.SDPStreamMetadataKey] = this.getLocalSDPStreamMetadata(true); + + // Get rid of any candidates waiting to be sent: they'll be included in the local + // description we just got and will send in the offer. + const discardCount = this.discardDuplicateCandidates(); + _logger.logger.info(`Call ${this.callId} gotLocalOffer() discarding ${discardCount} candidates that will be sent in offer`); + try { + await this.sendVoipEvent(eventType, content); + } catch (error) { + _logger.logger.error(`Call ${this.callId} gotLocalOffer() failed to send invite`, error); + if (error instanceof _httpApi.MatrixError && error.event) this.client.cancelPendingEvent(error.event); + let code = CallErrorCode.SignallingFailed; + let message = "Signalling failed"; + if (this.state === CallState.CreateOffer) { + code = CallErrorCode.SendInvite; + message = "Failed to send invite"; + } + if (error.name == "UnknownDeviceError") { + code = CallErrorCode.UnknownDevices; + message = "Unknown devices present in the room"; + } + this.emit(CallEvent.Error, new CallError(code, message, error), this); + this.terminate(CallParty.Local, code, false); + + // no need to carry on & send the candidate queue, but we also + // don't want to rethrow the error + return; + } + this.sendCandidateQueue(); + if (this.state === CallState.CreateOffer) { + this.inviteOrAnswerSent = true; + this.state = CallState.InviteSent; + this.inviteTimeout = setTimeout(() => { + this.inviteTimeout = undefined; + if (this.state === CallState.InviteSent) { + this.hangup(CallErrorCode.InviteTimeout, false); + } + }, CALL_TIMEOUT_MS); + } + } + /** + * This method removes all video/rtx codecs from screensharing video + * transceivers. This is necessary since they can cause problems. Without + * this the following steps should produce an error: + * Chromium calls Firefox + * Firefox answers + * Firefox starts screen-sharing + * Chromium starts screen-sharing + * Call crashes for Chromium with: + * [96685:23:0518/162603.933321:ERROR:webrtc_video_engine.cc(3296)] RTX codec (PT=97) mapped to PT=96 which is not in the codec list. + * [96685:23:0518/162603.933377:ERROR:webrtc_video_engine.cc(1171)] GetChangedRecvParameters called without any video codecs. + * [96685:23:0518/162603.933430:ERROR:sdp_offer_answer.cc(4302)] Failed to set local video description recv parameters for m-section with mid='2'. (INVALID_PARAMETER) + */ + getRidOfRTXCodecs() { + // RTCRtpReceiver.getCapabilities and RTCRtpSender.getCapabilities don't seem to be supported on FF before v113 + if (!RTCRtpReceiver.getCapabilities || !RTCRtpSender.getCapabilities) return; + const recvCodecs = RTCRtpReceiver.getCapabilities("video").codecs; + const sendCodecs = RTCRtpSender.getCapabilities("video").codecs; + const codecs = [...sendCodecs, ...recvCodecs]; + for (const codec of codecs) { + if (codec.mimeType === "video/rtx") { + const rtxCodecIndex = codecs.indexOf(codec); + codecs.splice(rtxCodecIndex, 1); + } + } + const screenshareVideoTransceiver = this.transceivers.get(getTransceiverKey(_callEventTypes.SDPStreamMetadataPurpose.Screenshare, "video")); + // setCodecPreferences isn't supported on FF (as of v113) + screenshareVideoTransceiver?.setCodecPreferences?.(codecs); + } + /** + * @internal + */ + async sendVoipEvent(eventType, content) { + const realContent = Object.assign({}, content, { + version: VOIP_PROTO_VERSION, + call_id: this.callId, + party_id: this.ourPartyId, + conf_id: this.groupCallId + }); + if (this.opponentDeviceId) { + const toDeviceSeq = this.toDeviceSeq++; + const content = _objectSpread(_objectSpread({}, realContent), {}, { + device_id: this.client.deviceId, + sender_session_id: this.client.getSessionId(), + dest_session_id: this.opponentSessionId, + seq: toDeviceSeq, + [_event.ToDeviceMessageId]: (0, _uuid.v4)() + }); + this.emit(CallEvent.SendVoipEvent, { + type: "toDevice", + eventType, + userId: this.invitee || this.getOpponentMember()?.userId, + opponentDeviceId: this.opponentDeviceId, + content + }, this); + const userId = this.invitee || this.getOpponentMember().userId; + if (this.client.getUseE2eForGroupCall()) { + if (!this.opponentDeviceInfo) { + _logger.logger.warn(`Call ${this.callId} sendVoipEvent() failed: we do not have opponentDeviceInfo`); + return; + } + await this.client.encryptAndSendToDevices([{ + userId, + deviceInfo: this.opponentDeviceInfo + }], { + type: eventType, + content + }); + } else { + await this.client.sendToDevice(eventType, new Map([[userId, new Map([[this.opponentDeviceId, content]])]])); + } + } else { + this.emit(CallEvent.SendVoipEvent, { + type: "sendEvent", + eventType, + roomId: this.roomId, + content: realContent, + userId: this.invitee || this.getOpponentMember()?.userId + }, this); + await this.client.sendEvent(this.roomId, eventType, realContent); + } + } + + /** + * Queue a candidate to be sent + * @param content - The candidate to queue up, or null if candidates have finished being generated + * and end-of-candidates should be signalled + */ + queueCandidate(content) { + // We partially de-trickle candidates by waiting for `delay` before sending them + // amalgamated, in order to avoid sending too many m.call.candidates events and hitting + // rate limits in Matrix. + // In practice, it'd be better to remove rate limits for m.call.* + + // N.B. this deliberately lets you queue and send blank candidates, which MSC2746 + // currently proposes as the way to indicate that candidate gathering is complete. + // This will hopefully be changed to an explicit rather than implicit notification + // shortly. + if (content) { + this.candidateSendQueue.push(content); + } else { + this.candidatesEnded = true; + } + + // Don't send the ICE candidates yet if the call is in the ringing state: this + // means we tried to pick (ie. started generating candidates) and then failed to + // send the answer and went back to the ringing state. Queue up the candidates + // to send if we successfully send the answer. + // Equally don't send if we haven't yet sent the answer because we can send the + // first batch of candidates along with the answer + if (this.state === CallState.Ringing || !this.inviteOrAnswerSent) return; + + // MSC2746 recommends these values (can be quite long when calling because the + // callee will need a while to answer the call) + const delay = this.direction === CallDirection.Inbound ? 500 : 2000; + if (this.candidateSendTries === 0) { + setTimeout(() => { + this.sendCandidateQueue(); + }, delay); + } + } + + // Discard all non-end-of-candidates messages + // Return the number of candidate messages that were discarded. + // Call this method before sending an invite or answer message + discardDuplicateCandidates() { + let discardCount = 0; + const newQueue = []; + for (let i = 0; i < this.candidateSendQueue.length; i++) { + const candidate = this.candidateSendQueue[i]; + if (candidate.candidate === "") { + newQueue.push(candidate); + } else { + discardCount++; + } + } + this.candidateSendQueue = newQueue; + return discardCount; + } + + /* + * Transfers this call to another user + */ + async transfer(targetUserId) { + // Fetch the target user's global profile info: their room avatar / displayname + // could be different in whatever room we share with them. + const profileInfo = await this.client.getProfileInfo(targetUserId); + const replacementId = genCallID(); + const body = { + replacement_id: genCallID(), + target_user: { + id: targetUserId, + display_name: profileInfo.displayname, + avatar_url: profileInfo.avatar_url + }, + create_call: replacementId + }; + await this.sendVoipEvent(_event.EventType.CallReplaces, body); + await this.terminate(CallParty.Local, CallErrorCode.Transferred, true); + } + + /* + * Transfers this call to the target call, effectively 'joining' the + * two calls (so the remote parties on each call are connected together). + */ + async transferToCall(transferTargetCall) { + const targetUserId = transferTargetCall.getOpponentMember()?.userId; + const targetProfileInfo = targetUserId ? await this.client.getProfileInfo(targetUserId) : undefined; + const opponentUserId = this.getOpponentMember()?.userId; + const transfereeProfileInfo = opponentUserId ? await this.client.getProfileInfo(opponentUserId) : undefined; + const newCallId = genCallID(); + const bodyToTransferTarget = { + // the replacements on each side have their own ID, and it's distinct from the + // ID of the new call (but we can use the same function to generate it) + replacement_id: genCallID(), + target_user: { + id: opponentUserId, + display_name: transfereeProfileInfo?.displayname, + avatar_url: transfereeProfileInfo?.avatar_url + }, + await_call: newCallId + }; + await transferTargetCall.sendVoipEvent(_event.EventType.CallReplaces, bodyToTransferTarget); + const bodyToTransferee = { + replacement_id: genCallID(), + target_user: { + id: targetUserId, + display_name: targetProfileInfo?.displayname, + avatar_url: targetProfileInfo?.avatar_url + }, + create_call: newCallId + }; + await this.sendVoipEvent(_event.EventType.CallReplaces, bodyToTransferee); + await this.terminate(CallParty.Local, CallErrorCode.Transferred, true); + await transferTargetCall.terminate(CallParty.Local, CallErrorCode.Transferred, true); + } + async terminate(hangupParty, hangupReason, shouldEmit) { + if (this.callHasEnded()) return; + this.hangupParty = hangupParty; + this.hangupReason = hangupReason; + this.state = CallState.Ended; + if (this.inviteTimeout) { + clearTimeout(this.inviteTimeout); + this.inviteTimeout = undefined; + } + if (this.iceDisconnectedTimeout !== undefined) { + clearTimeout(this.iceDisconnectedTimeout); + this.iceDisconnectedTimeout = undefined; + } + if (this.callLengthInterval) { + clearInterval(this.callLengthInterval); + this.callLengthInterval = undefined; + } + if (this.stopVideoTrackTimer !== undefined) { + clearTimeout(this.stopVideoTrackTimer); + this.stopVideoTrackTimer = undefined; + } + for (const [stream, listener] of this.removeTrackListeners) { + stream.removeEventListener("removetrack", listener); + } + this.removeTrackListeners.clear(); + this.callStatsAtEnd = await this.collectCallStats(); + + // Order is important here: first we stopAllMedia() and only then we can deleteAllFeeds() + this.stopAllMedia(); + this.deleteAllFeeds(); + if (this.peerConn && this.peerConn.signalingState !== "closed") { + this.peerConn.close(); + } + this.stats?.removeStatsReportGatherer(this.callId); + if (shouldEmit) { + this.emit(CallEvent.Hangup, this); + } + this.client.callEventHandler.calls.delete(this.callId); + } + stopAllMedia() { + _logger.logger.debug(`Call ${this.callId} stopAllMedia() running`); + for (const feed of this.feeds) { + // Slightly awkward as local feed need to go via the correct method on + // the MediaHandler so they get removed from MediaHandler (remote tracks + // don't) + // NB. We clone local streams when passing them to individual calls in a group + // call, so we can (and should) stop the clones once we no longer need them: + // the other clones will continue fine. + if (feed.isLocal() && feed.purpose === _callEventTypes.SDPStreamMetadataPurpose.Usermedia) { + this.client.getMediaHandler().stopUserMediaStream(feed.stream); + } else if (feed.isLocal() && feed.purpose === _callEventTypes.SDPStreamMetadataPurpose.Screenshare) { + this.client.getMediaHandler().stopScreensharingStream(feed.stream); + } else if (!feed.isLocal()) { + _logger.logger.debug(`Call ${this.callId} stopAllMedia() stopping stream (streamId=${feed.stream.id})`); + for (const track of feed.stream.getTracks()) { + track.stop(); + } + } + } + } + checkForErrorListener() { + if (this.listeners(_typedEventEmitter.EventEmitterEvents.Error).length === 0) { + throw new Error("You MUST attach an error listener using call.on('error', function() {})"); + } + } + async sendCandidateQueue() { + if (this.candidateSendQueue.length === 0 || this.callHasEnded()) { + return; + } + const candidates = this.candidateSendQueue; + this.candidateSendQueue = []; + ++this.candidateSendTries; + const content = { + candidates: candidates.map(candidate => candidate.toJSON()) + }; + if (this.candidatesEnded) { + // If there are no more candidates, signal this by adding an empty string candidate + content.candidates.push({ + candidate: "" + }); + } + _logger.logger.debug(`Call ${this.callId} sendCandidateQueue() attempting to send ${candidates.length} candidates`); + try { + await this.sendVoipEvent(_event.EventType.CallCandidates, content); + // reset our retry count if we have successfully sent our candidates + // otherwise queueCandidate() will refuse to try to flush the queue + this.candidateSendTries = 0; + + // Try to send candidates again just in case we received more candidates while sending. + this.sendCandidateQueue(); + } catch (error) { + // don't retry this event: we'll send another one later as we might + // have more candidates by then. + if (error instanceof _httpApi.MatrixError && error.event) this.client.cancelPendingEvent(error.event); + + // put all the candidates we failed to send back in the queue + this.candidateSendQueue.push(...candidates); + if (this.candidateSendTries > 5) { + _logger.logger.debug(`Call ${this.callId} sendCandidateQueue() failed to send candidates on attempt ${this.candidateSendTries}. Giving up on this call.`, error); + const code = CallErrorCode.SignallingFailed; + const message = "Signalling failed"; + this.emit(CallEvent.Error, new CallError(code, message, error), this); + this.hangup(code, false); + return; + } + const delayMs = 500 * Math.pow(2, this.candidateSendTries); + ++this.candidateSendTries; + _logger.logger.debug(`Call ${this.callId} sendCandidateQueue() failed to send candidates. Retrying in ${delayMs}ms`, error); + setTimeout(() => { + this.sendCandidateQueue(); + }, delayMs); + } + } + + /** + * Place a call to this room. + * @throws if you have not specified a listener for 'error' events. + * @throws if have passed audio=false. + */ + async placeCall(audio, video) { + if (!audio) { + throw new Error("You CANNOT start a call without audio"); + } + this.state = CallState.WaitLocalMedia; + try { + const stream = await this.client.getMediaHandler().getUserMediaStream(audio, video); + + // make sure all the tracks are enabled (same as pushNewLocalFeed - + // we probably ought to just have one code path for adding streams) + setTracksEnabled(stream.getAudioTracks(), true); + setTracksEnabled(stream.getVideoTracks(), true); + const callFeed = new _callFeed.CallFeed({ + client: this.client, + roomId: this.roomId, + userId: this.client.getUserId(), + deviceId: this.client.getDeviceId() ?? undefined, + stream, + purpose: _callEventTypes.SDPStreamMetadataPurpose.Usermedia, + audioMuted: false, + videoMuted: false + }); + await this.placeCallWithCallFeeds([callFeed]); + } catch (e) { + this.getUserMediaFailed(e); + return; + } + } + + /** + * Place a call to this room with call feed. + * @param callFeeds - to use + * @throws if you have not specified a listener for 'error' events. + * @throws if have passed audio=false. + */ + async placeCallWithCallFeeds(callFeeds, requestScreenshareFeed = false) { + this.checkForErrorListener(); + this.direction = CallDirection.Outbound; + await this.initOpponentCrypto(); + + // XXX Find a better way to do this + this.client.callEventHandler.calls.set(this.callId, this); + + // make sure we have valid turn creds. Unless something's gone wrong, it should + // poll and keep the credentials valid so this should be instant. + const haveTurnCreds = await this.client.checkTurnServers(); + if (!haveTurnCreds) { + _logger.logger.warn(`Call ${this.callId} placeCallWithCallFeeds() failed to get TURN credentials! Proceeding with call anyway...`); + } + + // create the peer connection now so it can be gathering candidates while we get user + // media (assuming a candidate pool size is configured) + this.peerConn = this.createPeerConnection(); + this.emit(CallEvent.PeerConnectionCreated, this.peerConn, this); + this.gotCallFeedsForInvite(callFeeds, requestScreenshareFeed); + } + createPeerConnection() { + const pc = new window.RTCPeerConnection({ + iceTransportPolicy: this.forceTURN ? "relay" : undefined, + iceServers: this.turnServers, + iceCandidatePoolSize: this.client.iceCandidatePoolSize, + bundlePolicy: "max-bundle" + }); + + // 'connectionstatechange' would be better, but firefox doesn't implement that. + pc.addEventListener("iceconnectionstatechange", this.onIceConnectionStateChanged); + pc.addEventListener("signalingstatechange", this.onSignallingStateChanged); + pc.addEventListener("icecandidate", this.gotLocalIceCandidate); + pc.addEventListener("icegatheringstatechange", this.onIceGatheringStateChange); + pc.addEventListener("track", this.onTrack); + pc.addEventListener("negotiationneeded", this.onNegotiationNeeded); + pc.addEventListener("datachannel", this.onDataChannel); + const opponentMember = this.getOpponentMember(); + const opponentMemberId = opponentMember ? opponentMember.userId : "unknown"; + this.stats?.addStatsReportGatherer(this.callId, opponentMemberId, pc); + return pc; + } + partyIdMatches(msg) { + // They must either match or both be absent (in which case opponentPartyId will be null) + // Also we ignore party IDs on the invite/offer if the version is 0, so we must do the same + // here and use null if the version is 0 (woe betide any opponent sending messages in the + // same call with different versions) + const msgPartyId = msg.version === 0 ? null : msg.party_id || null; + return msgPartyId === this.opponentPartyId; + } + + // Commits to an opponent for the call + // ev: An invite or answer event + chooseOpponent(ev) { + // I choo-choo-choose you + const msg = ev.getContent(); + _logger.logger.debug(`Call ${this.callId} chooseOpponent() running (partyId=${msg.party_id})`); + this.opponentVersion = msg.version; + if (this.opponentVersion === 0) { + // set to null to indicate that we've chosen an opponent, but because + // they're v0 they have no party ID (even if they sent one, we're ignoring it) + this.opponentPartyId = null; + } else { + // set to their party ID, or if they're naughty and didn't send one despite + // not being v0, set it to null to indicate we picked an opponent with no + // party ID + this.opponentPartyId = msg.party_id || null; + } + this.opponentCaps = msg.capabilities || {}; + this.opponentMember = this.client.getRoom(this.roomId).getMember(ev.getSender()) ?? undefined; + if (this.opponentMember) { + this.stats?.updateOpponentMember(this.callId, this.opponentMember.userId); + } + } + async addBufferedIceCandidates() { + const bufferedCandidates = this.remoteCandidateBuffer.get(this.opponentPartyId); + if (bufferedCandidates) { + _logger.logger.info(`Call ${this.callId} addBufferedIceCandidates() adding ${bufferedCandidates.length} buffered candidates for opponent ${this.opponentPartyId}`); + await this.addIceCandidates(bufferedCandidates); + } + this.remoteCandidateBuffer.clear(); + } + async addIceCandidates(candidates) { + for (const candidate of candidates) { + if ((candidate.sdpMid === null || candidate.sdpMid === undefined) && (candidate.sdpMLineIndex === null || candidate.sdpMLineIndex === undefined)) { + _logger.logger.debug(`Call ${this.callId} addIceCandidates() got remote ICE end-of-candidates`); + } else { + _logger.logger.debug(`Call ${this.callId} addIceCandidates() got remote ICE candidate (sdpMid=${candidate.sdpMid}, candidate=${candidate.candidate})`); + } + try { + await this.peerConn.addIceCandidate(candidate); + } catch (err) { + if (!this.ignoreOffer) { + _logger.logger.info(`Call ${this.callId} addIceCandidates() failed to add remote ICE candidate`, err); + } else { + _logger.logger.debug(`Call ${this.callId} addIceCandidates() failed to add remote ICE candidate because ignoring offer`, err); + } + } + } + } + get hasPeerConnection() { + return Boolean(this.peerConn); + } + initStats(stats, peerId = "unknown") { + this.stats = stats; + this.stats.start(); + } +} +exports.MatrixCall = MatrixCall; +function setTracksEnabled(tracks, enabled) { + for (const track of tracks) { + track.enabled = enabled; + } +} +function supportsMatrixCall() { + // typeof prevents Node from erroring on an undefined reference + if (typeof window === "undefined" || typeof document === "undefined") { + // NB. We don't log here as apps try to create a call object as a test for + // whether calls are supported, so we shouldn't fill the logs up. + return false; + } + + // Firefox throws on so little as accessing the RTCPeerConnection when operating in a secure mode. + // There's some information at https://bugzilla.mozilla.org/show_bug.cgi?id=1542616 though the concern + // is that the browser throwing a SecurityError will brick the client creation process. + try { + const supported = Boolean(window.RTCPeerConnection || window.RTCSessionDescription || window.RTCIceCandidate || navigator.mediaDevices); + if (!supported) { + /* istanbul ignore if */ // Adds a lot of noise to test runs, so disable logging there. + if (process.env.NODE_ENV !== "test") { + _logger.logger.error("WebRTC is not supported in this browser / environment"); + } + return false; + } + } catch (e) { + _logger.logger.error("Exception thrown when trying to access WebRTC", e); + return false; + } + return true; +} + +/** + * DEPRECATED + * Use client.createCall() + * + * Create a new Matrix call for the browser. + * @param client - The client instance to use. + * @param roomId - The room the call is in. + * @param options - DEPRECATED optional options map. + * @returns the call or null if the browser doesn't support calling. + */ +function createNewMatrixCall(client, roomId, options) { + if (!supportsMatrixCall()) return null; + const optionsForceTURN = options ? options.forceTURN : false; + const opts = { + client: client, + roomId: roomId, + invitee: options?.invitee, + turnServers: client.getTurnServers(), + // call level options + forceTURN: client.forceTURN || optionsForceTURN, + opponentDeviceId: options?.opponentDeviceId, + opponentSessionId: options?.opponentSessionId, + groupCallId: options?.groupCallId + }; + const call = new MatrixCall(opts); + client.reEmitter.reEmit(call, Object.values(CallEvent)); + return call; +}
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callEventHandler.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callEventHandler.js new file mode 100644 index 0000000000..caf1cc9d2b --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callEventHandler.js @@ -0,0 +1,339 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.CallEventHandlerEvent = exports.CallEventHandler = void 0; +var _logger = require("../logger"); +var _call = require("./call"); +var _event = require("../@types/event"); +var _client = require("../client"); +var _groupCall = require("./groupCall"); +var _room = require("../models/room"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2020 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +// Don't ring unless we'd be ringing for at least 3 seconds: the user needs some +// time to press the 'accept' button +const RING_GRACE_PERIOD = 3000; +let CallEventHandlerEvent = /*#__PURE__*/function (CallEventHandlerEvent) { + CallEventHandlerEvent["Incoming"] = "Call.incoming"; + return CallEventHandlerEvent; +}({}); +exports.CallEventHandlerEvent = CallEventHandlerEvent; +class CallEventHandler { + constructor(client) { + // XXX: Most of these are only public because of the tests + _defineProperty(this, "calls", void 0); + _defineProperty(this, "callEventBuffer", void 0); + _defineProperty(this, "nextSeqByCall", new Map()); + _defineProperty(this, "toDeviceEventBuffers", new Map()); + _defineProperty(this, "client", void 0); + _defineProperty(this, "candidateEventsByCall", void 0); + _defineProperty(this, "eventBufferPromiseChain", void 0); + _defineProperty(this, "onSync", () => { + // Process the current event buffer and start queuing into a new one. + const currentEventBuffer = this.callEventBuffer; + this.callEventBuffer = []; + + // Ensure correct ordering by only processing this queue after the previous one has finished processing + if (this.eventBufferPromiseChain) { + this.eventBufferPromiseChain = this.eventBufferPromiseChain.then(() => this.evaluateEventBuffer(currentEventBuffer)); + } else { + this.eventBufferPromiseChain = this.evaluateEventBuffer(currentEventBuffer); + } + }); + _defineProperty(this, "onRoomTimeline", event => { + this.callEventBuffer.push(event); + }); + _defineProperty(this, "onToDeviceEvent", event => { + const content = event.getContent(); + if (!content.call_id) { + this.callEventBuffer.push(event); + return; + } + if (!this.nextSeqByCall.has(content.call_id)) { + this.nextSeqByCall.set(content.call_id, 0); + } + if (content.seq === undefined) { + this.callEventBuffer.push(event); + return; + } + const nextSeq = this.nextSeqByCall.get(content.call_id) || 0; + if (content.seq !== nextSeq) { + if (!this.toDeviceEventBuffers.has(content.call_id)) { + this.toDeviceEventBuffers.set(content.call_id, []); + } + const buffer = this.toDeviceEventBuffers.get(content.call_id); + const index = buffer.findIndex(e => e.getContent().seq > content.seq); + if (index === -1) { + buffer.push(event); + } else { + buffer.splice(index, 0, event); + } + } else { + const callId = content.call_id; + this.callEventBuffer.push(event); + this.nextSeqByCall.set(callId, content.seq + 1); + const buffer = this.toDeviceEventBuffers.get(callId); + let nextEvent = buffer && buffer.shift(); + while (nextEvent && nextEvent.getContent().seq === this.nextSeqByCall.get(callId)) { + this.callEventBuffer.push(nextEvent); + this.nextSeqByCall.set(callId, nextEvent.getContent().seq + 1); + nextEvent = buffer.shift(); + } + } + }); + this.client = client; + this.calls = new Map(); + // The sync code always emits one event at a time, so it will patiently + // wait for us to finish processing a call invite before delivering the + // next event, even if that next event is a hangup. We therefore accumulate + // all our call events and then process them on the 'sync' event, ie. + // each time a sync has completed. This way, we can avoid emitting incoming + // call events if we get both the invite and answer/hangup in the same sync. + // This happens quite often, eg. replaying sync from storage, catchup sync + // after loading and after we've been offline for a bit. + this.callEventBuffer = []; + this.candidateEventsByCall = new Map(); + } + start() { + this.client.on(_client.ClientEvent.Sync, this.onSync); + this.client.on(_room.RoomEvent.Timeline, this.onRoomTimeline); + this.client.on(_client.ClientEvent.ToDeviceEvent, this.onToDeviceEvent); + } + stop() { + this.client.removeListener(_client.ClientEvent.Sync, this.onSync); + this.client.removeListener(_room.RoomEvent.Timeline, this.onRoomTimeline); + this.client.removeListener(_client.ClientEvent.ToDeviceEvent, this.onToDeviceEvent); + } + async evaluateEventBuffer(eventBuffer) { + await Promise.all(eventBuffer.map(event => this.client.decryptEventIfNeeded(event))); + const callEvents = eventBuffer.filter(event => { + const eventType = event.getType(); + return eventType.startsWith("m.call.") || eventType.startsWith("org.matrix.call."); + }); + const ignoreCallIds = new Set(); + + // inspect the buffer and mark all calls which have been answered + // or hung up before passing them to the call event handler. + for (const event of callEvents) { + const eventType = event.getType(); + if (eventType === _event.EventType.CallAnswer || eventType === _event.EventType.CallHangup) { + ignoreCallIds.add(event.getContent().call_id); + } + } + + // Process call events in the order that they were received + for (const event of callEvents) { + const eventType = event.getType(); + const callId = event.getContent().call_id; + if (eventType === _event.EventType.CallInvite && ignoreCallIds.has(callId)) { + // This call has previously been answered or hung up: ignore it + continue; + } + try { + await this.handleCallEvent(event); + } catch (e) { + _logger.logger.error("CallEventHandler evaluateEventBuffer() caught exception handling call event", e); + } + } + } + async handleCallEvent(event) { + this.client.emit(_client.ClientEvent.ReceivedVoipEvent, event); + const content = event.getContent(); + const callRoomId = event.getRoomId() || this.client.groupCallEventHandler.getGroupCallById(content.conf_id)?.room?.roomId; + const groupCallId = content.conf_id; + const type = event.getType(); + const senderId = event.getSender(); + let call = content.call_id ? this.calls.get(content.call_id) : undefined; + let opponentDeviceId; + let groupCall; + if (groupCallId) { + groupCall = this.client.groupCallEventHandler.getGroupCallById(groupCallId); + if (!groupCall) { + _logger.logger.warn(`CallEventHandler handleCallEvent() could not find a group call - ignoring event (groupCallId=${groupCallId}, type=${type})`); + return; + } + opponentDeviceId = content.device_id; + if (!opponentDeviceId) { + _logger.logger.warn(`CallEventHandler handleCallEvent() could not find a device id - ignoring event (senderId=${senderId})`); + groupCall.emit(_groupCall.GroupCallEvent.Error, new _groupCall.GroupCallUnknownDeviceError(senderId)); + return; + } + if (content.dest_session_id !== this.client.getSessionId()) { + _logger.logger.warn("CallEventHandler handleCallEvent() call event does not match current session id - ignoring"); + return; + } + } + const weSentTheEvent = senderId === this.client.credentials.userId && (opponentDeviceId === undefined || opponentDeviceId === this.client.getDeviceId()); + if (!callRoomId) return; + if (type === _event.EventType.CallInvite) { + // ignore invites you send + if (weSentTheEvent) return; + // expired call + if (event.getLocalAge() > content.lifetime - RING_GRACE_PERIOD) return; + // stale/old invite event + if (call && call.state === _call.CallState.Ended) return; + if (call) { + _logger.logger.warn(`CallEventHandler handleCallEvent() already has a call but got an invite - clobbering (callId=${content.call_id})`); + } + if (content.invitee && content.invitee !== this.client.getUserId()) { + return; // This invite was meant for another user in the room + } + + const timeUntilTurnCresExpire = (this.client.getTurnServersExpiry() ?? 0) - Date.now(); + _logger.logger.info("CallEventHandler handleCallEvent() current turn creds expire in " + timeUntilTurnCresExpire + " ms"); + call = (0, _call.createNewMatrixCall)(this.client, callRoomId, { + forceTURN: this.client.forceTURN, + opponentDeviceId, + groupCallId, + opponentSessionId: content.sender_session_id + }) ?? undefined; + if (!call) { + _logger.logger.log(`CallEventHandler handleCallEvent() this client does not support WebRTC (callId=${content.call_id})`); + // don't hang up the call: there could be other clients + // connected that do support WebRTC and declining the + // the call on their behalf would be really annoying. + return; + } + call.callId = content.call_id; + const stats = groupCall?.getGroupCallStats(); + if (stats) { + call.initStats(stats); + } + try { + await call.initWithInvite(event); + } catch (e) { + if (e instanceof _call.CallError) { + if (e.code === _groupCall.GroupCallErrorCode.UnknownDevice) { + groupCall?.emit(_groupCall.GroupCallEvent.Error, e); + } else { + _logger.logger.error(e); + } + } + } + this.calls.set(call.callId, call); + + // if we stashed candidate events for that call ID, play them back now + if (this.candidateEventsByCall.get(call.callId)) { + for (const ev of this.candidateEventsByCall.get(call.callId)) { + call.onRemoteIceCandidatesReceived(ev); + } + } + + // Were we trying to call that user (room)? + let existingCall; + for (const thisCall of this.calls.values()) { + const isCalling = [_call.CallState.WaitLocalMedia, _call.CallState.CreateOffer, _call.CallState.InviteSent].includes(thisCall.state); + if (call.roomId === thisCall.roomId && thisCall.direction === _call.CallDirection.Outbound && call.getOpponentMember()?.userId === thisCall.invitee && isCalling) { + existingCall = thisCall; + break; + } + } + if (existingCall) { + if (existingCall.callId > call.callId) { + _logger.logger.log(`CallEventHandler handleCallEvent() detected glare - answering incoming call and canceling outgoing call (incomingId=${call.callId}, outgoingId=${existingCall.callId})`); + existingCall.replacedBy(call); + } else { + _logger.logger.log(`CallEventHandler handleCallEvent() detected glare - hanging up incoming call (incomingId=${call.callId}, outgoingId=${existingCall.callId})`); + call.hangup(_call.CallErrorCode.Replaced, true); + } + } else { + this.client.emit(CallEventHandlerEvent.Incoming, call); + } + return; + } else if (type === _event.EventType.CallCandidates) { + if (weSentTheEvent) return; + if (!call) { + // store the candidates; we may get a call eventually. + if (!this.candidateEventsByCall.has(content.call_id)) { + this.candidateEventsByCall.set(content.call_id, []); + } + this.candidateEventsByCall.get(content.call_id).push(event); + } else { + call.onRemoteIceCandidatesReceived(event); + } + return; + } else if ([_event.EventType.CallHangup, _event.EventType.CallReject].includes(type)) { + // Note that we also observe our own hangups here so we can see + // if we've already rejected a call that would otherwise be valid + if (!call) { + // if not live, store the fact that the call has ended because + // we're probably getting events backwards so + // the hangup will come before the invite + call = (0, _call.createNewMatrixCall)(this.client, callRoomId, { + opponentDeviceId, + opponentSessionId: content.sender_session_id + }) ?? undefined; + if (call) { + call.callId = content.call_id; + call.initWithHangup(event); + this.calls.set(content.call_id, call); + } + } else { + if (call.state !== _call.CallState.Ended) { + if (type === _event.EventType.CallHangup) { + call.onHangupReceived(content); + } else { + call.onRejectReceived(content); + } + + // @ts-expect-error typescript thinks the state can't be 'ended' because we're + // inside the if block where it wasn't, but it could have changed because + // on[Hangup|Reject]Received are side-effecty. + if (call.state === _call.CallState.Ended) this.calls.delete(content.call_id); + } + } + return; + } + + // The following events need a call and a peer connection + if (!call || !call.hasPeerConnection) { + _logger.logger.info(`CallEventHandler handleCallEvent() discarding possible call event as we don't have a call (type=${type})`); + return; + } + // Ignore remote echo + if (event.getContent().party_id === call.ourPartyId) return; + switch (type) { + case _event.EventType.CallAnswer: + if (weSentTheEvent) { + if (call.state === _call.CallState.Ringing) { + call.onAnsweredElsewhere(content); + } + } else { + call.onAnswerReceived(event); + } + break; + case _event.EventType.CallSelectAnswer: + call.onSelectAnswerReceived(event); + break; + case _event.EventType.CallNegotiate: + call.onNegotiateReceived(event); + break; + case _event.EventType.CallAssertedIdentity: + case _event.EventType.CallAssertedIdentityPrefix: + call.onAssertedIdentityReceived(event); + break; + case _event.EventType.CallSDPStreamMetadataChanged: + case _event.EventType.CallSDPStreamMetadataChangedPrefix: + call.onSDPStreamMetadataChangedReceived(event); + break; + } + } +} +exports.CallEventHandler = CallEventHandler;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callEventTypes.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callEventTypes.js new file mode 100644 index 0000000000..fae0c8f1e9 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callEventTypes.js @@ -0,0 +1,19 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.SDPStreamMetadataPurpose = exports.SDPStreamMetadataKey = void 0; +// allow non-camelcase as these are events type that go onto the wire +/* eslint-disable camelcase */ + +// TODO: Change to "sdp_stream_metadata" when MSC3077 is merged +const SDPStreamMetadataKey = "org.matrix.msc3077.sdp_stream_metadata"; +exports.SDPStreamMetadataKey = SDPStreamMetadataKey; +let SDPStreamMetadataPurpose = /*#__PURE__*/function (SDPStreamMetadataPurpose) { + SDPStreamMetadataPurpose["Usermedia"] = "m.usermedia"; + SDPStreamMetadataPurpose["Screenshare"] = "m.screenshare"; + return SDPStreamMetadataPurpose; +}({}); +/* eslint-enable camelcase */ +exports.SDPStreamMetadataPurpose = SDPStreamMetadataPurpose;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callFeed.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callFeed.js new file mode 100644 index 0000000000..25af7aa5e8 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callFeed.js @@ -0,0 +1,294 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.SPEAKING_THRESHOLD = exports.CallFeedEvent = exports.CallFeed = void 0; +var _callEventTypes = require("./callEventTypes"); +var _audioContext = require("./audioContext"); +var _logger = require("../logger"); +var _typedEventEmitter = require("../models/typed-event-emitter"); +var _call = require("./call"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com> + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +const POLLING_INTERVAL = 200; // ms +const SPEAKING_THRESHOLD = -60; // dB +exports.SPEAKING_THRESHOLD = SPEAKING_THRESHOLD; +const SPEAKING_SAMPLE_COUNT = 8; // samples +let CallFeedEvent = /*#__PURE__*/function (CallFeedEvent) { + CallFeedEvent["NewStream"] = "new_stream"; + CallFeedEvent["MuteStateChanged"] = "mute_state_changed"; + CallFeedEvent["LocalVolumeChanged"] = "local_volume_changed"; + CallFeedEvent["VolumeChanged"] = "volume_changed"; + CallFeedEvent["ConnectedChanged"] = "connected_changed"; + CallFeedEvent["Speaking"] = "speaking"; + CallFeedEvent["Disposed"] = "disposed"; + return CallFeedEvent; +}({}); +exports.CallFeedEvent = CallFeedEvent; +class CallFeed extends _typedEventEmitter.TypedEventEmitter { + constructor(opts) { + super(); + _defineProperty(this, "stream", void 0); + _defineProperty(this, "sdpMetadataStreamId", void 0); + _defineProperty(this, "userId", void 0); + _defineProperty(this, "deviceId", void 0); + _defineProperty(this, "purpose", void 0); + _defineProperty(this, "speakingVolumeSamples", void 0); + _defineProperty(this, "client", void 0); + _defineProperty(this, "call", void 0); + _defineProperty(this, "roomId", void 0); + _defineProperty(this, "audioMuted", void 0); + _defineProperty(this, "videoMuted", void 0); + _defineProperty(this, "localVolume", 1); + _defineProperty(this, "measuringVolumeActivity", false); + _defineProperty(this, "audioContext", void 0); + _defineProperty(this, "analyser", void 0); + _defineProperty(this, "frequencyBinCount", void 0); + _defineProperty(this, "speakingThreshold", SPEAKING_THRESHOLD); + _defineProperty(this, "speaking", false); + _defineProperty(this, "volumeLooperTimeout", void 0); + _defineProperty(this, "_disposed", false); + _defineProperty(this, "_connected", false); + _defineProperty(this, "onAddTrack", () => { + this.emit(CallFeedEvent.NewStream, this.stream); + }); + _defineProperty(this, "onCallState", state => { + if (state === _call.CallState.Connected) { + this.connected = true; + } else if (state === _call.CallState.Connecting) { + this.connected = false; + } + }); + _defineProperty(this, "volumeLooper", () => { + if (!this.analyser) return; + if (!this.measuringVolumeActivity) return; + this.analyser.getFloatFrequencyData(this.frequencyBinCount); + let maxVolume = -Infinity; + for (const volume of this.frequencyBinCount) { + if (volume > maxVolume) { + maxVolume = volume; + } + } + this.speakingVolumeSamples.shift(); + this.speakingVolumeSamples.push(maxVolume); + this.emit(CallFeedEvent.VolumeChanged, maxVolume); + let newSpeaking = false; + for (const volume of this.speakingVolumeSamples) { + if (volume > this.speakingThreshold) { + newSpeaking = true; + break; + } + } + if (this.speaking !== newSpeaking) { + this.speaking = newSpeaking; + this.emit(CallFeedEvent.Speaking, this.speaking); + } + this.volumeLooperTimeout = setTimeout(this.volumeLooper, POLLING_INTERVAL); + }); + this.client = opts.client; + this.call = opts.call; + this.roomId = opts.roomId; + this.userId = opts.userId; + this.deviceId = opts.deviceId; + this.purpose = opts.purpose; + this.audioMuted = opts.audioMuted; + this.videoMuted = opts.videoMuted; + this.speakingVolumeSamples = new Array(SPEAKING_SAMPLE_COUNT).fill(-Infinity); + this.sdpMetadataStreamId = opts.stream.id; + this.updateStream(null, opts.stream); + this.stream = opts.stream; // updateStream does this, but this makes TS happier + + if (this.hasAudioTrack) { + this.initVolumeMeasuring(); + } + if (opts.call) { + opts.call.addListener(_call.CallEvent.State, this.onCallState); + this.onCallState(opts.call.state); + } + } + get connected() { + // Local feeds are always considered connected + return this.isLocal() || this._connected; + } + set connected(connected) { + this._connected = connected; + this.emit(CallFeedEvent.ConnectedChanged, this.connected); + } + get hasAudioTrack() { + return this.stream.getAudioTracks().length > 0; + } + updateStream(oldStream, newStream) { + if (newStream === oldStream) return; + const wasMeasuringVolumeActivity = this.measuringVolumeActivity; + if (oldStream) { + oldStream.removeEventListener("addtrack", this.onAddTrack); + this.measureVolumeActivity(false); + } + this.stream = newStream; + newStream.addEventListener("addtrack", this.onAddTrack); + if (this.hasAudioTrack) { + this.initVolumeMeasuring(); + if (wasMeasuringVolumeActivity) this.measureVolumeActivity(true); + } else { + this.measureVolumeActivity(false); + } + this.emit(CallFeedEvent.NewStream, this.stream); + } + initVolumeMeasuring() { + if (!this.hasAudioTrack) return; + if (!this.audioContext) this.audioContext = (0, _audioContext.acquireContext)(); + this.analyser = this.audioContext.createAnalyser(); + this.analyser.fftSize = 512; + this.analyser.smoothingTimeConstant = 0.1; + const mediaStreamAudioSourceNode = this.audioContext.createMediaStreamSource(this.stream); + mediaStreamAudioSourceNode.connect(this.analyser); + this.frequencyBinCount = new Float32Array(this.analyser.frequencyBinCount); + } + /** + * Returns callRoom member + * @returns member of the callRoom + */ + getMember() { + const callRoom = this.client.getRoom(this.roomId); + return callRoom?.getMember(this.userId) ?? null; + } + + /** + * Returns true if CallFeed is local, otherwise returns false + * @returns is local? + */ + isLocal() { + return this.userId === this.client.getUserId() && (this.deviceId === undefined || this.deviceId === this.client.getDeviceId()); + } + + /** + * Returns true if audio is muted or if there are no audio + * tracks, otherwise returns false + * @returns is audio muted? + */ + isAudioMuted() { + return this.stream.getAudioTracks().length === 0 || this.audioMuted; + } + + /** + * Returns true video is muted or if there are no video + * tracks, otherwise returns false + * @returns is video muted? + */ + isVideoMuted() { + // We assume only one video track + return this.stream.getVideoTracks().length === 0 || this.videoMuted; + } + isSpeaking() { + return this.speaking; + } + + /** + * Replaces the current MediaStream with a new one. + * The stream will be different and new stream as remote parties are + * concerned, but this can be used for convenience locally to set up + * volume listeners automatically on the new stream etc. + * @param newStream - new stream with which to replace the current one + */ + setNewStream(newStream) { + this.updateStream(this.stream, newStream); + } + + /** + * Set one or both of feed's internal audio and video video mute state + * Either value may be null to leave it as-is + * @param audioMuted - is the feed's audio muted? + * @param videoMuted - is the feed's video muted? + */ + setAudioVideoMuted(audioMuted, videoMuted) { + if (audioMuted !== null) { + if (this.audioMuted !== audioMuted) { + this.speakingVolumeSamples.fill(-Infinity); + } + this.audioMuted = audioMuted; + } + if (videoMuted !== null) this.videoMuted = videoMuted; + this.emit(CallFeedEvent.MuteStateChanged, this.audioMuted, this.videoMuted); + } + + /** + * Starts emitting volume_changed events where the emitter value is in decibels + * @param enabled - emit volume changes + */ + measureVolumeActivity(enabled) { + if (enabled) { + if (!this.analyser || !this.frequencyBinCount || !this.hasAudioTrack) return; + this.measuringVolumeActivity = true; + this.volumeLooper(); + } else { + this.measuringVolumeActivity = false; + this.speakingVolumeSamples.fill(-Infinity); + this.emit(CallFeedEvent.VolumeChanged, -Infinity); + } + } + setSpeakingThreshold(threshold) { + this.speakingThreshold = threshold; + } + clone() { + const mediaHandler = this.client.getMediaHandler(); + const stream = this.stream.clone(); + _logger.logger.log(`CallFeed clone() cloning stream (originalStreamId=${this.stream.id}, newStreamId${stream.id})`); + if (this.purpose === _callEventTypes.SDPStreamMetadataPurpose.Usermedia) { + mediaHandler.userMediaStreams.push(stream); + } else { + mediaHandler.screensharingStreams.push(stream); + } + return new CallFeed({ + client: this.client, + roomId: this.roomId, + userId: this.userId, + deviceId: this.deviceId, + stream, + purpose: this.purpose, + audioMuted: this.audioMuted, + videoMuted: this.videoMuted + }); + } + dispose() { + clearTimeout(this.volumeLooperTimeout); + this.stream?.removeEventListener("addtrack", this.onAddTrack); + this.call?.removeListener(_call.CallEvent.State, this.onCallState); + if (this.audioContext) { + this.audioContext = undefined; + this.analyser = undefined; + (0, _audioContext.releaseContext)(); + } + this._disposed = true; + this.emit(CallFeedEvent.Disposed); + } + get disposed() { + return this._disposed; + } + set disposed(value) { + this._disposed = value; + } + getLocalVolume() { + return this.localVolume; + } + setLocalVolume(localVolume) { + this.localVolume = localVolume; + this.emit(CallFeedEvent.LocalVolumeChanged, localVolume); + } +} +exports.CallFeed = CallFeed;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/groupCall.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/groupCall.js new file mode 100644 index 0000000000..ac6da49d3b --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/groupCall.js @@ -0,0 +1,1213 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.OtherUserSpeakingError = exports.GroupCallUnknownDeviceError = exports.GroupCallType = exports.GroupCallTerminationReason = exports.GroupCallStatsReportEvent = exports.GroupCallState = exports.GroupCallIntent = exports.GroupCallEvent = exports.GroupCallErrorCode = exports.GroupCallError = exports.GroupCall = void 0; +var _typedEventEmitter = require("../models/typed-event-emitter"); +var _callFeed = require("./callFeed"); +var _call = require("./call"); +var _roomState = require("../models/room-state"); +var _logger = require("../logger"); +var _ReEmitter = require("../ReEmitter"); +var _callEventTypes = require("./callEventTypes"); +var _event = require("../@types/event"); +var _callEventHandler = require("./callEventHandler"); +var _groupCallEventHandler = require("./groupCallEventHandler"); +var _utils = require("../utils"); +var _groupCallStats = require("./stats/groupCallStats"); +var _statsReport = require("./stats/statsReport"); +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } +let GroupCallIntent = /*#__PURE__*/function (GroupCallIntent) { + GroupCallIntent["Ring"] = "m.ring"; + GroupCallIntent["Prompt"] = "m.prompt"; + GroupCallIntent["Room"] = "m.room"; + return GroupCallIntent; +}({}); +exports.GroupCallIntent = GroupCallIntent; +let GroupCallType = /*#__PURE__*/function (GroupCallType) { + GroupCallType["Video"] = "m.video"; + GroupCallType["Voice"] = "m.voice"; + return GroupCallType; +}({}); +exports.GroupCallType = GroupCallType; +let GroupCallTerminationReason = /*#__PURE__*/function (GroupCallTerminationReason) { + GroupCallTerminationReason["CallEnded"] = "call_ended"; + return GroupCallTerminationReason; +}({}); +exports.GroupCallTerminationReason = GroupCallTerminationReason; +/** + * Because event names are just strings, they do need + * to be unique over all event types of event emitter. + * Some objects could emit more then one set of events. + */ +let GroupCallEvent = /*#__PURE__*/function (GroupCallEvent) { + GroupCallEvent["GroupCallStateChanged"] = "group_call_state_changed"; + GroupCallEvent["ActiveSpeakerChanged"] = "active_speaker_changed"; + GroupCallEvent["CallsChanged"] = "calls_changed"; + GroupCallEvent["UserMediaFeedsChanged"] = "user_media_feeds_changed"; + GroupCallEvent["ScreenshareFeedsChanged"] = "screenshare_feeds_changed"; + GroupCallEvent["LocalScreenshareStateChanged"] = "local_screenshare_state_changed"; + GroupCallEvent["LocalMuteStateChanged"] = "local_mute_state_changed"; + GroupCallEvent["ParticipantsChanged"] = "participants_changed"; + GroupCallEvent["Error"] = "group_call_error"; + return GroupCallEvent; +}({}); +exports.GroupCallEvent = GroupCallEvent; +let GroupCallStatsReportEvent = /*#__PURE__*/function (GroupCallStatsReportEvent) { + GroupCallStatsReportEvent["ConnectionStats"] = "GroupCall.connection_stats"; + GroupCallStatsReportEvent["ByteSentStats"] = "GroupCall.byte_sent_stats"; + GroupCallStatsReportEvent["SummaryStats"] = "GroupCall.summary_stats"; + return GroupCallStatsReportEvent; +}({}); +exports.GroupCallStatsReportEvent = GroupCallStatsReportEvent; +let GroupCallErrorCode = /*#__PURE__*/function (GroupCallErrorCode) { + GroupCallErrorCode["NoUserMedia"] = "no_user_media"; + GroupCallErrorCode["UnknownDevice"] = "unknown_device"; + GroupCallErrorCode["PlaceCallFailed"] = "place_call_failed"; + return GroupCallErrorCode; +}({}); +exports.GroupCallErrorCode = GroupCallErrorCode; +class GroupCallError extends Error { + constructor(code, msg, err) { + // Still don't think there's any way to have proper nested errors + if (err) { + super(msg + ": " + err); + _defineProperty(this, "code", void 0); + } else { + super(msg); + _defineProperty(this, "code", void 0); + } + this.code = code; + } +} +exports.GroupCallError = GroupCallError; +class GroupCallUnknownDeviceError extends GroupCallError { + constructor(userId) { + super(GroupCallErrorCode.UnknownDevice, "No device found for " + userId); + this.userId = userId; + } +} +exports.GroupCallUnknownDeviceError = GroupCallUnknownDeviceError; +class OtherUserSpeakingError extends Error { + constructor() { + super("Cannot unmute: another user is speaking"); + } +} +exports.OtherUserSpeakingError = OtherUserSpeakingError; +let GroupCallState = /*#__PURE__*/function (GroupCallState) { + GroupCallState["LocalCallFeedUninitialized"] = "local_call_feed_uninitialized"; + GroupCallState["InitializingLocalCallFeed"] = "initializing_local_call_feed"; + GroupCallState["LocalCallFeedInitialized"] = "local_call_feed_initialized"; + GroupCallState["Entered"] = "entered"; + GroupCallState["Ended"] = "ended"; + return GroupCallState; +}({}); +exports.GroupCallState = GroupCallState; +const DEVICE_TIMEOUT = 1000 * 60 * 60; // 1 hour + +function getCallUserId(call) { + return call.getOpponentMember()?.userId || call.invitee || null; +} +class GroupCall extends _typedEventEmitter.TypedEventEmitter { + constructor(client, room, type, isPtt, intent, groupCallId, dataChannelsEnabled, dataChannelOptions, isCallWithoutVideoAndAudio) { + super(); + this.client = client; + this.room = room; + this.type = type; + this.isPtt = isPtt; + this.intent = intent; + this.dataChannelsEnabled = dataChannelsEnabled; + this.dataChannelOptions = dataChannelOptions; + // Config + _defineProperty(this, "activeSpeakerInterval", 1000); + _defineProperty(this, "retryCallInterval", 5000); + _defineProperty(this, "participantTimeout", 1000 * 15); + _defineProperty(this, "pttMaxTransmitTime", 1000 * 20); + _defineProperty(this, "activeSpeaker", void 0); + _defineProperty(this, "localCallFeed", void 0); + _defineProperty(this, "localScreenshareFeed", void 0); + _defineProperty(this, "localDesktopCapturerSourceId", void 0); + _defineProperty(this, "userMediaFeeds", []); + _defineProperty(this, "screenshareFeeds", []); + _defineProperty(this, "groupCallId", void 0); + _defineProperty(this, "allowCallWithoutVideoAndAudio", void 0); + _defineProperty(this, "calls", new Map()); + // user_id -> device_id -> MatrixCall + _defineProperty(this, "callHandlers", new Map()); + // user_id -> device_id -> ICallHandlers + _defineProperty(this, "activeSpeakerLoopInterval", void 0); + _defineProperty(this, "retryCallLoopInterval", void 0); + _defineProperty(this, "retryCallCounts", new Map()); + // user_id -> device_id -> count + _defineProperty(this, "reEmitter", void 0); + _defineProperty(this, "transmitTimer", null); + _defineProperty(this, "participantsExpirationTimer", null); + _defineProperty(this, "resendMemberStateTimer", null); + _defineProperty(this, "initWithAudioMuted", false); + _defineProperty(this, "initWithVideoMuted", false); + _defineProperty(this, "initCallFeedPromise", void 0); + _defineProperty(this, "stats", void 0); + /** + * Configure default webrtc stats collection interval in ms + * Disable collecting webrtc stats by setting interval to 0 + */ + _defineProperty(this, "statsCollectIntervalTime", 0); + _defineProperty(this, "onConnectionStats", report => { + this.emit(GroupCallStatsReportEvent.ConnectionStats, { + report + }); + }); + _defineProperty(this, "onByteSentStats", report => { + this.emit(GroupCallStatsReportEvent.ByteSentStats, { + report + }); + }); + _defineProperty(this, "onSummaryStats", report => { + this.emit(GroupCallStatsReportEvent.SummaryStats, { + report + }); + }); + _defineProperty(this, "_state", GroupCallState.LocalCallFeedUninitialized); + _defineProperty(this, "_participants", new Map()); + _defineProperty(this, "_creationTs", null); + _defineProperty(this, "_enteredViaAnotherSession", false); + /* + * Call Setup + * + * There are two different paths for calls to be created: + * 1. Incoming calls triggered by the Call.incoming event. + * 2. Outgoing calls to the initial members of a room or new members + * as they are observed by the RoomState.members event. + */ + _defineProperty(this, "onIncomingCall", newCall => { + // The incoming calls may be for another room, which we will ignore. + if (newCall.roomId !== this.room.roomId) { + return; + } + if (newCall.state !== _call.CallState.Ringing) { + _logger.logger.warn(`GroupCall ${this.groupCallId} onIncomingCall() incoming call no longer in ringing state - ignoring`); + return; + } + if (!newCall.groupCallId || newCall.groupCallId !== this.groupCallId) { + _logger.logger.log(`GroupCall ${this.groupCallId} onIncomingCall() ignored because it doesn't match the current group call`); + newCall.reject(); + return; + } + const opponentUserId = newCall.getOpponentMember()?.userId; + if (opponentUserId === undefined) { + _logger.logger.warn(`GroupCall ${this.groupCallId} onIncomingCall() incoming call with no member - ignoring`); + return; + } + const deviceMap = this.calls.get(opponentUserId) ?? new Map(); + const prevCall = deviceMap.get(newCall.getOpponentDeviceId()); + if (prevCall?.callId === newCall.callId) return; + _logger.logger.log(`GroupCall ${this.groupCallId} onIncomingCall() incoming call (userId=${opponentUserId}, callId=${newCall.callId})`); + if (prevCall) prevCall.hangup(_call.CallErrorCode.Replaced, false); + // We must do this before we start initialising / answering the call as we + // need to know it is the active call for this user+deviceId and to not ignore + // events from it. + deviceMap.set(newCall.getOpponentDeviceId(), newCall); + this.calls.set(opponentUserId, deviceMap); + this.initCall(newCall); + const feeds = this.getLocalFeeds().map(feed => feed.clone()); + if (!this.callExpected(newCall)) { + // Disable our tracks for users not explicitly participating in the + // call but trying to receive the feeds + for (const feed of feeds) { + (0, _call.setTracksEnabled)(feed.stream.getAudioTracks(), false); + (0, _call.setTracksEnabled)(feed.stream.getVideoTracks(), false); + } + } + newCall.answerWithCallFeeds(feeds); + this.emit(GroupCallEvent.CallsChanged, this.calls); + }); + _defineProperty(this, "onRetryCallLoop", () => { + let needsRetry = false; + for (const [{ + userId + }, participantMap] of this.participants) { + const callMap = this.calls.get(userId); + let retriesMap = this.retryCallCounts.get(userId); + for (const [deviceId, participant] of participantMap) { + const call = callMap?.get(deviceId); + const retries = retriesMap?.get(deviceId) ?? 0; + if (call?.getOpponentSessionId() !== participant.sessionId && this.wantsOutgoingCall(userId, deviceId) && retries < 3) { + if (retriesMap === undefined) { + retriesMap = new Map(); + this.retryCallCounts.set(userId, retriesMap); + } + retriesMap.set(deviceId, retries + 1); + needsRetry = true; + } + } + } + if (needsRetry) this.placeOutgoingCalls(); + }); + _defineProperty(this, "onCallFeedsChanged", call => { + const opponentMemberId = getCallUserId(call); + const opponentDeviceId = call.getOpponentDeviceId(); + if (!opponentMemberId) { + throw new Error("Cannot change call feeds without user id"); + } + const currentUserMediaFeed = this.getUserMediaFeed(opponentMemberId, opponentDeviceId); + const remoteUsermediaFeed = call.remoteUsermediaFeed; + const remoteFeedChanged = remoteUsermediaFeed !== currentUserMediaFeed; + const deviceMap = this.calls.get(opponentMemberId); + const currentCallForUserDevice = deviceMap?.get(opponentDeviceId); + if (currentCallForUserDevice?.callId !== call.callId) { + // the call in question is not the current call for this user/deviceId + // so ignore feed events from it otherwise we'll remove our real feeds + return; + } + if (remoteFeedChanged) { + if (!currentUserMediaFeed && remoteUsermediaFeed) { + this.addUserMediaFeed(remoteUsermediaFeed); + } else if (currentUserMediaFeed && remoteUsermediaFeed) { + this.replaceUserMediaFeed(currentUserMediaFeed, remoteUsermediaFeed); + } else if (currentUserMediaFeed && !remoteUsermediaFeed) { + this.removeUserMediaFeed(currentUserMediaFeed); + } + } + const currentScreenshareFeed = this.getScreenshareFeed(opponentMemberId, opponentDeviceId); + const remoteScreensharingFeed = call.remoteScreensharingFeed; + const remoteScreenshareFeedChanged = remoteScreensharingFeed !== currentScreenshareFeed; + if (remoteScreenshareFeedChanged) { + if (!currentScreenshareFeed && remoteScreensharingFeed) { + this.addScreenshareFeed(remoteScreensharingFeed); + } else if (currentScreenshareFeed && remoteScreensharingFeed) { + this.replaceScreenshareFeed(currentScreenshareFeed, remoteScreensharingFeed); + } else if (currentScreenshareFeed && !remoteScreensharingFeed) { + this.removeScreenshareFeed(currentScreenshareFeed); + } + } + }); + _defineProperty(this, "onCallStateChanged", (call, state, _oldState) => { + if (state === _call.CallState.Ended) return; + const audioMuted = this.localCallFeed.isAudioMuted(); + if (call.localUsermediaStream && call.isMicrophoneMuted() !== audioMuted) { + call.setMicrophoneMuted(audioMuted); + } + const videoMuted = this.localCallFeed.isVideoMuted(); + if (call.localUsermediaStream && call.isLocalVideoMuted() !== videoMuted) { + call.setLocalVideoMuted(videoMuted); + } + const opponentUserId = call.getOpponentMember()?.userId; + if (state === _call.CallState.Connected && opponentUserId) { + const retriesMap = this.retryCallCounts.get(opponentUserId); + retriesMap?.delete(call.getOpponentDeviceId()); + if (retriesMap?.size === 0) this.retryCallCounts.delete(opponentUserId); + } + }); + _defineProperty(this, "onCallHangup", call => { + if (call.hangupReason === _call.CallErrorCode.Replaced) return; + const opponentUserId = call.getOpponentMember()?.userId ?? this.room.getMember(call.invitee).userId; + const deviceMap = this.calls.get(opponentUserId); + + // Sanity check that this call is in fact in the map + if (deviceMap?.get(call.getOpponentDeviceId()) === call) { + this.disposeCall(call, call.hangupReason); + deviceMap.delete(call.getOpponentDeviceId()); + if (deviceMap.size === 0) this.calls.delete(opponentUserId); + this.emit(GroupCallEvent.CallsChanged, this.calls); + } + }); + _defineProperty(this, "onCallReplaced", (prevCall, newCall) => { + const opponentUserId = prevCall.getOpponentMember().userId; + let deviceMap = this.calls.get(opponentUserId); + if (deviceMap === undefined) { + deviceMap = new Map(); + this.calls.set(opponentUserId, deviceMap); + } + prevCall.hangup(_call.CallErrorCode.Replaced, false); + this.initCall(newCall); + deviceMap.set(prevCall.getOpponentDeviceId(), newCall); + this.emit(GroupCallEvent.CallsChanged, this.calls); + }); + _defineProperty(this, "onActiveSpeakerLoop", () => { + let topAvg = undefined; + let nextActiveSpeaker = undefined; + for (const callFeed of this.userMediaFeeds) { + if (callFeed.isLocal() && this.userMediaFeeds.length > 1) continue; + const total = callFeed.speakingVolumeSamples.reduce((acc, volume) => acc + Math.max(volume, _callFeed.SPEAKING_THRESHOLD)); + const avg = total / callFeed.speakingVolumeSamples.length; + if (!topAvg || avg > topAvg) { + topAvg = avg; + nextActiveSpeaker = callFeed; + } + } + if (nextActiveSpeaker && this.activeSpeaker !== nextActiveSpeaker && topAvg && topAvg > _callFeed.SPEAKING_THRESHOLD) { + this.activeSpeaker = nextActiveSpeaker; + this.emit(GroupCallEvent.ActiveSpeakerChanged, this.activeSpeaker); + } + }); + _defineProperty(this, "onRoomState", () => this.updateParticipants()); + _defineProperty(this, "onParticipantsChanged", () => { + // Re-run setTracksEnabled on all calls, so that participants that just + // left get denied access to our media, and participants that just + // joined get granted access + this.forEachCall(call => { + const expected = this.callExpected(call); + for (const feed of call.getLocalFeeds()) { + (0, _call.setTracksEnabled)(feed.stream.getAudioTracks(), !feed.isAudioMuted() && expected); + (0, _call.setTracksEnabled)(feed.stream.getVideoTracks(), !feed.isVideoMuted() && expected); + } + }); + if (this.state === GroupCallState.Entered) this.placeOutgoingCalls(); + }); + _defineProperty(this, "onStateChanged", (newState, oldState) => { + if (newState === GroupCallState.Entered || oldState === GroupCallState.Entered || newState === GroupCallState.Ended) { + // We either entered, left, or ended the call + this.updateParticipants(); + this.updateMemberState().catch(e => _logger.logger.error(`GroupCall ${this.groupCallId} onStateChanged() failed to update member state devices"`, e)); + } + }); + _defineProperty(this, "onLocalFeedsChanged", () => { + if (this.state === GroupCallState.Entered) { + this.updateMemberState().catch(e => _logger.logger.error(`GroupCall ${this.groupCallId} onLocalFeedsChanged() failed to update member state feeds`, e)); + } + }); + this.reEmitter = new _ReEmitter.ReEmitter(this); + this.groupCallId = groupCallId ?? (0, _call.genCallID)(); + this.creationTs = room.currentState.getStateEvents(_event.EventType.GroupCallPrefix, this.groupCallId)?.getTs() ?? null; + this.updateParticipants(); + room.on(_roomState.RoomStateEvent.Update, this.onRoomState); + this.on(GroupCallEvent.ParticipantsChanged, this.onParticipantsChanged); + this.on(GroupCallEvent.GroupCallStateChanged, this.onStateChanged); + this.on(GroupCallEvent.LocalScreenshareStateChanged, this.onLocalFeedsChanged); + this.allowCallWithoutVideoAndAudio = !!isCallWithoutVideoAndAudio; + } + async create() { + this.creationTs = Date.now(); + this.client.groupCallEventHandler.groupCalls.set(this.room.roomId, this); + this.client.emit(_groupCallEventHandler.GroupCallEventHandlerEvent.Outgoing, this); + const groupCallState = { + "m.intent": this.intent, + "m.type": this.type, + "io.element.ptt": this.isPtt, + // TODO: Specify data-channels better + "dataChannelsEnabled": this.dataChannelsEnabled, + "dataChannelOptions": this.dataChannelsEnabled ? this.dataChannelOptions : undefined + }; + await this.client.sendStateEvent(this.room.roomId, _event.EventType.GroupCallPrefix, groupCallState, this.groupCallId); + return this; + } + /** + * The group call's state. + */ + get state() { + return this._state; + } + set state(value) { + const prevValue = this._state; + if (value !== prevValue) { + this._state = value; + this.emit(GroupCallEvent.GroupCallStateChanged, value, prevValue); + } + } + /** + * The current participants in the call, as a map from members to device IDs + * to participant info. + */ + get participants() { + return this._participants; + } + set participants(value) { + const prevValue = this._participants; + const participantStateEqual = (x, y) => x.sessionId === y.sessionId && x.screensharing === y.screensharing; + const deviceMapsEqual = (x, y) => (0, _utils.mapsEqual)(x, y, participantStateEqual); + + // Only update if the map actually changed + if (!(0, _utils.mapsEqual)(value, prevValue, deviceMapsEqual)) { + this._participants = value; + this.emit(GroupCallEvent.ParticipantsChanged, value); + } + } + /** + * The timestamp at which the call was created, or null if it has not yet + * been created. + */ + get creationTs() { + return this._creationTs; + } + set creationTs(value) { + this._creationTs = value; + } + /** + * Whether the local device has entered this call via another session, such + * as a widget. + */ + get enteredViaAnotherSession() { + return this._enteredViaAnotherSession; + } + set enteredViaAnotherSession(value) { + this._enteredViaAnotherSession = value; + this.updateParticipants(); + } + + /** + * Executes the given callback on all calls in this group call. + * @param f - The callback. + */ + forEachCall(f) { + for (const deviceMap of this.calls.values()) { + for (const call of deviceMap.values()) f(call); + } + } + getLocalFeeds() { + const feeds = []; + if (this.localCallFeed) feeds.push(this.localCallFeed); + if (this.localScreenshareFeed) feeds.push(this.localScreenshareFeed); + return feeds; + } + hasLocalParticipant() { + return this.participants.get(this.room.getMember(this.client.getUserId()))?.has(this.client.getDeviceId()) ?? false; + } + + /** + * Determines whether the given call is one that we were expecting to exist + * given our knowledge of who is participating in the group call. + */ + callExpected(call) { + const userId = getCallUserId(call); + const member = userId === null ? null : this.room.getMember(userId); + const deviceId = call.getOpponentDeviceId(); + return member !== null && deviceId !== undefined && this.participants.get(member)?.get(deviceId) !== undefined; + } + async initLocalCallFeed() { + if (this.state !== GroupCallState.LocalCallFeedUninitialized) { + throw new Error(`Cannot initialize local call feed in the "${this.state}" state.`); + } + this.state = GroupCallState.InitializingLocalCallFeed; + + // wraps the real method to serialise calls, because we don't want to try starting + // multiple call feeds at once + if (this.initCallFeedPromise) return this.initCallFeedPromise; + try { + this.initCallFeedPromise = this.initLocalCallFeedInternal(); + await this.initCallFeedPromise; + } finally { + this.initCallFeedPromise = undefined; + } + } + async initLocalCallFeedInternal() { + _logger.logger.log(`GroupCall ${this.groupCallId} initLocalCallFeedInternal() running`); + let stream; + try { + stream = await this.client.getMediaHandler().getUserMediaStream(true, this.type === GroupCallType.Video); + } catch (error) { + // If is allowed to join a call without a media stream, then we + // don't throw an error here. But we need an empty Local Feed to establish + // a connection later. + if (this.allowCallWithoutVideoAndAudio) { + stream = new MediaStream(); + } else { + this.state = GroupCallState.LocalCallFeedUninitialized; + throw error; + } + } + + // The call could've been disposed while we were waiting, and could + // also have been started back up again (hello, React 18) so if we're + // still in this 'initializing' state, carry on, otherwise bail. + if (this._state !== GroupCallState.InitializingLocalCallFeed) { + this.client.getMediaHandler().stopUserMediaStream(stream); + throw new Error("Group call disposed while gathering media stream"); + } + const callFeed = new _callFeed.CallFeed({ + client: this.client, + roomId: this.room.roomId, + userId: this.client.getUserId(), + deviceId: this.client.getDeviceId(), + stream, + purpose: _callEventTypes.SDPStreamMetadataPurpose.Usermedia, + audioMuted: this.initWithAudioMuted || stream.getAudioTracks().length === 0 || this.isPtt, + videoMuted: this.initWithVideoMuted || stream.getVideoTracks().length === 0 + }); + (0, _call.setTracksEnabled)(stream.getAudioTracks(), !callFeed.isAudioMuted()); + (0, _call.setTracksEnabled)(stream.getVideoTracks(), !callFeed.isVideoMuted()); + this.localCallFeed = callFeed; + this.addUserMediaFeed(callFeed); + this.state = GroupCallState.LocalCallFeedInitialized; + } + async updateLocalUsermediaStream(stream) { + if (this.localCallFeed) { + const oldStream = this.localCallFeed.stream; + this.localCallFeed.setNewStream(stream); + const micShouldBeMuted = this.localCallFeed.isAudioMuted(); + const vidShouldBeMuted = this.localCallFeed.isVideoMuted(); + _logger.logger.log(`GroupCall ${this.groupCallId} updateLocalUsermediaStream() (oldStreamId=${oldStream.id}, newStreamId=${stream.id}, micShouldBeMuted=${micShouldBeMuted}, vidShouldBeMuted=${vidShouldBeMuted})`); + (0, _call.setTracksEnabled)(stream.getAudioTracks(), !micShouldBeMuted); + (0, _call.setTracksEnabled)(stream.getVideoTracks(), !vidShouldBeMuted); + this.client.getMediaHandler().stopUserMediaStream(oldStream); + } + } + async enter() { + if (this.state === GroupCallState.LocalCallFeedUninitialized) { + await this.initLocalCallFeed(); + } else if (this.state !== GroupCallState.LocalCallFeedInitialized) { + throw new Error(`Cannot enter call in the "${this.state}" state`); + } + _logger.logger.log(`GroupCall ${this.groupCallId} enter() running`); + this.state = GroupCallState.Entered; + this.client.on(_callEventHandler.CallEventHandlerEvent.Incoming, this.onIncomingCall); + for (const call of this.client.callEventHandler.calls.values()) { + this.onIncomingCall(call); + } + this.retryCallLoopInterval = setInterval(this.onRetryCallLoop, this.retryCallInterval); + this.activeSpeaker = undefined; + this.onActiveSpeakerLoop(); + this.activeSpeakerLoopInterval = setInterval(this.onActiveSpeakerLoop, this.activeSpeakerInterval); + } + dispose() { + if (this.localCallFeed) { + this.removeUserMediaFeed(this.localCallFeed); + this.localCallFeed = undefined; + } + if (this.localScreenshareFeed) { + this.client.getMediaHandler().stopScreensharingStream(this.localScreenshareFeed.stream); + this.removeScreenshareFeed(this.localScreenshareFeed); + this.localScreenshareFeed = undefined; + this.localDesktopCapturerSourceId = undefined; + } + this.client.getMediaHandler().stopAllStreams(); + if (this.transmitTimer !== null) { + clearTimeout(this.transmitTimer); + this.transmitTimer = null; + } + if (this.retryCallLoopInterval !== undefined) { + clearInterval(this.retryCallLoopInterval); + this.retryCallLoopInterval = undefined; + } + if (this.participantsExpirationTimer !== null) { + clearTimeout(this.participantsExpirationTimer); + this.participantsExpirationTimer = null; + } + if (this.state !== GroupCallState.Entered) { + return; + } + this.forEachCall(call => call.hangup(_call.CallErrorCode.UserHangup, false)); + this.activeSpeaker = undefined; + clearInterval(this.activeSpeakerLoopInterval); + this.retryCallCounts.clear(); + clearInterval(this.retryCallLoopInterval); + this.client.removeListener(_callEventHandler.CallEventHandlerEvent.Incoming, this.onIncomingCall); + this.stats?.stop(); + } + leave() { + this.dispose(); + this.state = GroupCallState.LocalCallFeedUninitialized; + } + async terminate(emitStateEvent = true) { + this.dispose(); + this.room.off(_roomState.RoomStateEvent.Update, this.onRoomState); + this.client.groupCallEventHandler.groupCalls.delete(this.room.roomId); + this.client.emit(_groupCallEventHandler.GroupCallEventHandlerEvent.Ended, this); + this.state = GroupCallState.Ended; + if (emitStateEvent) { + const existingStateEvent = this.room.currentState.getStateEvents(_event.EventType.GroupCallPrefix, this.groupCallId); + await this.client.sendStateEvent(this.room.roomId, _event.EventType.GroupCallPrefix, _objectSpread(_objectSpread({}, existingStateEvent.getContent()), {}, { + "m.terminated": GroupCallTerminationReason.CallEnded + }), this.groupCallId); + } + } + + /* + * Local Usermedia + */ + + isLocalVideoMuted() { + if (this.localCallFeed) { + return this.localCallFeed.isVideoMuted(); + } + return true; + } + isMicrophoneMuted() { + if (this.localCallFeed) { + return this.localCallFeed.isAudioMuted(); + } + return true; + } + + /** + * Sets the mute state of the local participants's microphone. + * @param muted - Whether to mute the microphone + * @returns Whether muting/unmuting was successful + */ + async setMicrophoneMuted(muted) { + // hasAudioDevice can block indefinitely if the window has lost focus, + // and it doesn't make much sense to keep a device from being muted, so + // we always allow muted = true changes to go through + if (!muted && !(await this.client.getMediaHandler().hasAudioDevice())) { + return false; + } + const sendUpdatesBefore = !muted && this.isPtt; + + // set a timer for the maximum transmit time on PTT calls + if (this.isPtt) { + // Set or clear the max transmit timer + if (!muted && this.isMicrophoneMuted()) { + this.transmitTimer = setTimeout(() => { + this.setMicrophoneMuted(true); + }, this.pttMaxTransmitTime); + } else if (muted && !this.isMicrophoneMuted()) { + if (this.transmitTimer !== null) clearTimeout(this.transmitTimer); + this.transmitTimer = null; + } + } + this.forEachCall(call => call.localUsermediaFeed?.setAudioVideoMuted(muted, null)); + const sendUpdates = async () => { + const updates = []; + this.forEachCall(call => updates.push(call.sendMetadataUpdate())); + await Promise.all(updates).catch(e => _logger.logger.info(`GroupCall ${this.groupCallId} setMicrophoneMuted() failed to send some metadata updates`, e)); + }; + if (sendUpdatesBefore) await sendUpdates(); + if (this.localCallFeed) { + _logger.logger.log(`GroupCall ${this.groupCallId} setMicrophoneMuted() (streamId=${this.localCallFeed.stream.id}, muted=${muted})`); + const hasPermission = await this.checkAudioPermissionIfNecessary(muted); + if (!hasPermission) { + return false; + } + this.localCallFeed.setAudioVideoMuted(muted, null); + // I don't believe its actually necessary to enable these tracks: they + // are the one on the GroupCall's own CallFeed and are cloned before being + // given to any of the actual calls, so these tracks don't actually go + // anywhere. Let's do it anyway to avoid confusion. + (0, _call.setTracksEnabled)(this.localCallFeed.stream.getAudioTracks(), !muted); + } else { + _logger.logger.log(`GroupCall ${this.groupCallId} setMicrophoneMuted() no stream muted (muted=${muted})`); + this.initWithAudioMuted = muted; + } + this.forEachCall(call => (0, _call.setTracksEnabled)(call.localUsermediaFeed.stream.getAudioTracks(), !muted && this.callExpected(call))); + this.emit(GroupCallEvent.LocalMuteStateChanged, muted, this.isLocalVideoMuted()); + if (!sendUpdatesBefore) await sendUpdates(); + return true; + } + + /** + * If we allow entering a call without a camera and without video, it can happen that the access rights to the + * devices have not yet been queried. If a stream does not yet have an audio track, we assume that the rights have + * not yet been checked. + * + * `this.client.getMediaHandler().getUserMediaStream` clones the current stream, so it only wanted to be called when + * not Audio Track exists. + * As such, this is a compromise, because, the access rights should always be queried before the call. + */ + async checkAudioPermissionIfNecessary(muted) { + // We needed this here to avoid an error in case user join a call without a device. + try { + if (!muted && this.localCallFeed && !this.localCallFeed.hasAudioTrack) { + const stream = await this.client.getMediaHandler().getUserMediaStream(true, !this.localCallFeed.isVideoMuted()); + if (stream?.getTracks().length === 0) { + // if case permission denied to get a stream stop this here + /* istanbul ignore next */ + _logger.logger.log(`GroupCall ${this.groupCallId} setMicrophoneMuted() no device to receive local stream, muted=${muted}`); + return false; + } + } + } catch (e) { + /* istanbul ignore next */ + _logger.logger.log(`GroupCall ${this.groupCallId} setMicrophoneMuted() no device or permission to receive local stream, muted=${muted}`); + return false; + } + return true; + } + + /** + * Sets the mute state of the local participants's video. + * @param muted - Whether to mute the video + * @returns Whether muting/unmuting was successful + */ + async setLocalVideoMuted(muted) { + // hasAudioDevice can block indefinitely if the window has lost focus, + // and it doesn't make much sense to keep a device from being muted, so + // we always allow muted = true changes to go through + if (!muted && !(await this.client.getMediaHandler().hasVideoDevice())) { + return false; + } + if (this.localCallFeed) { + /* istanbul ignore next */ + _logger.logger.log(`GroupCall ${this.groupCallId} setLocalVideoMuted() (stream=${this.localCallFeed.stream.id}, muted=${muted})`); + try { + const stream = await this.client.getMediaHandler().getUserMediaStream(true, !muted); + await this.updateLocalUsermediaStream(stream); + this.localCallFeed.setAudioVideoMuted(null, muted); + (0, _call.setTracksEnabled)(this.localCallFeed.stream.getVideoTracks(), !muted); + } catch (_) { + // No permission to video device + /* istanbul ignore next */ + _logger.logger.log(`GroupCall ${this.groupCallId} setLocalVideoMuted() no device or permission to receive local stream, muted=${muted}`); + return false; + } + } else { + _logger.logger.log(`GroupCall ${this.groupCallId} setLocalVideoMuted() no stream muted (muted=${muted})`); + this.initWithVideoMuted = muted; + } + const updates = []; + this.forEachCall(call => updates.push(call.setLocalVideoMuted(muted))); + await Promise.all(updates); + + // We setTracksEnabled again, independently from the call doing it + // internally, since we might not be expecting the call + this.forEachCall(call => (0, _call.setTracksEnabled)(call.localUsermediaFeed.stream.getVideoTracks(), !muted && this.callExpected(call))); + this.emit(GroupCallEvent.LocalMuteStateChanged, this.isMicrophoneMuted(), muted); + return true; + } + async setScreensharingEnabled(enabled, opts = {}) { + if (enabled === this.isScreensharing()) { + return enabled; + } + if (enabled) { + try { + _logger.logger.log(`GroupCall ${this.groupCallId} setScreensharingEnabled() is asking for screensharing permissions`); + const stream = await this.client.getMediaHandler().getScreensharingStream(opts); + for (const track of stream.getTracks()) { + const onTrackEnded = () => { + this.setScreensharingEnabled(false); + track.removeEventListener("ended", onTrackEnded); + }; + track.addEventListener("ended", onTrackEnded); + } + _logger.logger.log(`GroupCall ${this.groupCallId} setScreensharingEnabled() granted screensharing permissions. Setting screensharing enabled on all calls`); + this.localDesktopCapturerSourceId = opts.desktopCapturerSourceId; + this.localScreenshareFeed = new _callFeed.CallFeed({ + client: this.client, + roomId: this.room.roomId, + userId: this.client.getUserId(), + deviceId: this.client.getDeviceId(), + stream, + purpose: _callEventTypes.SDPStreamMetadataPurpose.Screenshare, + audioMuted: false, + videoMuted: false + }); + this.addScreenshareFeed(this.localScreenshareFeed); + this.emit(GroupCallEvent.LocalScreenshareStateChanged, true, this.localScreenshareFeed, this.localDesktopCapturerSourceId); + + // TODO: handle errors + this.forEachCall(call => call.pushLocalFeed(this.localScreenshareFeed.clone())); + return true; + } catch (error) { + if (opts.throwOnFail) throw error; + _logger.logger.error(`GroupCall ${this.groupCallId} setScreensharingEnabled() enabling screensharing error`, error); + this.emit(GroupCallEvent.Error, new GroupCallError(GroupCallErrorCode.NoUserMedia, "Failed to get screen-sharing stream: ", error)); + return false; + } + } else { + this.forEachCall(call => { + if (call.localScreensharingFeed) call.removeLocalFeed(call.localScreensharingFeed); + }); + this.client.getMediaHandler().stopScreensharingStream(this.localScreenshareFeed.stream); + this.removeScreenshareFeed(this.localScreenshareFeed); + this.localScreenshareFeed = undefined; + this.localDesktopCapturerSourceId = undefined; + this.emit(GroupCallEvent.LocalScreenshareStateChanged, false, undefined, undefined); + return false; + } + } + isScreensharing() { + return !!this.localScreenshareFeed; + } + /** + * Determines whether a given participant expects us to call them (versus + * them calling us). + * @param userId - The participant's user ID. + * @param deviceId - The participant's device ID. + * @returns Whether we need to place an outgoing call to the participant. + */ + wantsOutgoingCall(userId, deviceId) { + const localUserId = this.client.getUserId(); + const localDeviceId = this.client.getDeviceId(); + return ( + // If a user's ID is less than our own, they'll call us + userId >= localUserId && ( + // If this is another one of our devices, compare device IDs to tell whether it'll call us + userId !== localUserId || deviceId > localDeviceId) + ); + } + + /** + * Places calls to all participants that we're responsible for calling. + */ + placeOutgoingCalls() { + let callsChanged = false; + for (const [{ + userId + }, participantMap] of this.participants) { + const callMap = this.calls.get(userId) ?? new Map(); + for (const [deviceId, participant] of participantMap) { + const prevCall = callMap.get(deviceId); + if (prevCall?.getOpponentSessionId() !== participant.sessionId && this.wantsOutgoingCall(userId, deviceId)) { + callsChanged = true; + if (prevCall !== undefined) { + _logger.logger.debug(`GroupCall ${this.groupCallId} placeOutgoingCalls() replacing call (userId=${userId}, deviceId=${deviceId}, callId=${prevCall.callId})`); + prevCall.hangup(_call.CallErrorCode.NewSession, false); + } + const newCall = (0, _call.createNewMatrixCall)(this.client, this.room.roomId, { + invitee: userId, + opponentDeviceId: deviceId, + opponentSessionId: participant.sessionId, + groupCallId: this.groupCallId + }); + if (newCall === null) { + _logger.logger.error(`GroupCall ${this.groupCallId} placeOutgoingCalls() failed to create call (userId=${userId}, device=${deviceId})`); + callMap.delete(deviceId); + } else { + this.initCall(newCall); + callMap.set(deviceId, newCall); + _logger.logger.debug(`GroupCall ${this.groupCallId} placeOutgoingCalls() placing call (userId=${userId}, deviceId=${deviceId}, sessionId=${participant.sessionId})`); + newCall.placeCallWithCallFeeds(this.getLocalFeeds().map(feed => feed.clone()), participant.screensharing).then(() => { + if (this.dataChannelsEnabled) { + newCall.createDataChannel("datachannel", this.dataChannelOptions); + } + }).catch(e => { + _logger.logger.warn(`GroupCall ${this.groupCallId} placeOutgoingCalls() failed to place call (userId=${userId})`, e); + if (e instanceof _call.CallError && e.code === GroupCallErrorCode.UnknownDevice) { + this.emit(GroupCallEvent.Error, e); + } else { + this.emit(GroupCallEvent.Error, new GroupCallError(GroupCallErrorCode.PlaceCallFailed, `Failed to place call to ${userId}`)); + } + newCall.hangup(_call.CallErrorCode.SignallingFailed, false); + if (callMap.get(deviceId) === newCall) callMap.delete(deviceId); + }); + } + } + } + if (callMap.size > 0) { + this.calls.set(userId, callMap); + } else { + this.calls.delete(userId); + } + } + if (callsChanged) this.emit(GroupCallEvent.CallsChanged, this.calls); + } + + /* + * Room Member State + */ + + getMemberStateEvents(userId) { + return userId === undefined ? this.room.currentState.getStateEvents(_event.EventType.GroupCallMemberPrefix) : this.room.currentState.getStateEvents(_event.EventType.GroupCallMemberPrefix, userId); + } + initCall(call) { + const opponentMemberId = getCallUserId(call); + if (!opponentMemberId) { + throw new Error("Cannot init call without user id"); + } + const onCallFeedsChanged = () => this.onCallFeedsChanged(call); + const onCallStateChanged = (state, oldState) => this.onCallStateChanged(call, state, oldState); + const onCallHangup = this.onCallHangup; + const onCallReplaced = newCall => this.onCallReplaced(call, newCall); + let deviceMap = this.callHandlers.get(opponentMemberId); + if (deviceMap === undefined) { + deviceMap = new Map(); + this.callHandlers.set(opponentMemberId, deviceMap); + } + deviceMap.set(call.getOpponentDeviceId(), { + onCallFeedsChanged, + onCallStateChanged, + onCallHangup, + onCallReplaced + }); + call.on(_call.CallEvent.FeedsChanged, onCallFeedsChanged); + call.on(_call.CallEvent.State, onCallStateChanged); + call.on(_call.CallEvent.Hangup, onCallHangup); + call.on(_call.CallEvent.Replaced, onCallReplaced); + call.isPtt = this.isPtt; + this.reEmitter.reEmit(call, Object.values(_call.CallEvent)); + call.initStats(this.getGroupCallStats()); + onCallFeedsChanged(); + } + disposeCall(call, hangupReason) { + const opponentMemberId = getCallUserId(call); + const opponentDeviceId = call.getOpponentDeviceId(); + if (!opponentMemberId) { + throw new Error("Cannot dispose call without user id"); + } + const deviceMap = this.callHandlers.get(opponentMemberId); + const { + onCallFeedsChanged, + onCallStateChanged, + onCallHangup, + onCallReplaced + } = deviceMap.get(opponentDeviceId); + call.removeListener(_call.CallEvent.FeedsChanged, onCallFeedsChanged); + call.removeListener(_call.CallEvent.State, onCallStateChanged); + call.removeListener(_call.CallEvent.Hangup, onCallHangup); + call.removeListener(_call.CallEvent.Replaced, onCallReplaced); + deviceMap.delete(opponentMemberId); + if (deviceMap.size === 0) this.callHandlers.delete(opponentMemberId); + if (call.hangupReason === _call.CallErrorCode.Replaced) { + return; + } + const usermediaFeed = this.getUserMediaFeed(opponentMemberId, opponentDeviceId); + if (usermediaFeed) { + this.removeUserMediaFeed(usermediaFeed); + } + const screenshareFeed = this.getScreenshareFeed(opponentMemberId, opponentDeviceId); + if (screenshareFeed) { + this.removeScreenshareFeed(screenshareFeed); + } + } + /* + * UserMedia CallFeed Event Handlers + */ + + getUserMediaFeed(userId, deviceId) { + return this.userMediaFeeds.find(f => f.userId === userId && f.deviceId === deviceId); + } + addUserMediaFeed(callFeed) { + this.userMediaFeeds.push(callFeed); + callFeed.measureVolumeActivity(true); + this.emit(GroupCallEvent.UserMediaFeedsChanged, this.userMediaFeeds); + } + replaceUserMediaFeed(existingFeed, replacementFeed) { + const feedIndex = this.userMediaFeeds.findIndex(f => f.userId === existingFeed.userId && f.deviceId === existingFeed.deviceId); + if (feedIndex === -1) { + throw new Error("Couldn't find user media feed to replace"); + } + this.userMediaFeeds.splice(feedIndex, 1, replacementFeed); + existingFeed.dispose(); + replacementFeed.measureVolumeActivity(true); + this.emit(GroupCallEvent.UserMediaFeedsChanged, this.userMediaFeeds); + } + removeUserMediaFeed(callFeed) { + const feedIndex = this.userMediaFeeds.findIndex(f => f.userId === callFeed.userId && f.deviceId === callFeed.deviceId); + if (feedIndex === -1) { + throw new Error("Couldn't find user media feed to remove"); + } + this.userMediaFeeds.splice(feedIndex, 1); + callFeed.dispose(); + this.emit(GroupCallEvent.UserMediaFeedsChanged, this.userMediaFeeds); + if (this.activeSpeaker === callFeed) { + this.activeSpeaker = this.userMediaFeeds[0]; + this.emit(GroupCallEvent.ActiveSpeakerChanged, this.activeSpeaker); + } + } + /* + * Screenshare Call Feed Event Handlers + */ + + getScreenshareFeed(userId, deviceId) { + return this.screenshareFeeds.find(f => f.userId === userId && f.deviceId === deviceId); + } + addScreenshareFeed(callFeed) { + this.screenshareFeeds.push(callFeed); + this.emit(GroupCallEvent.ScreenshareFeedsChanged, this.screenshareFeeds); + } + replaceScreenshareFeed(existingFeed, replacementFeed) { + const feedIndex = this.screenshareFeeds.findIndex(f => f.userId === existingFeed.userId && f.deviceId === existingFeed.deviceId); + if (feedIndex === -1) { + throw new Error("Couldn't find screenshare feed to replace"); + } + this.screenshareFeeds.splice(feedIndex, 1, replacementFeed); + existingFeed.dispose(); + this.emit(GroupCallEvent.ScreenshareFeedsChanged, this.screenshareFeeds); + } + removeScreenshareFeed(callFeed) { + const feedIndex = this.screenshareFeeds.findIndex(f => f.userId === callFeed.userId && f.deviceId === callFeed.deviceId); + if (feedIndex === -1) { + throw new Error("Couldn't find screenshare feed to remove"); + } + this.screenshareFeeds.splice(feedIndex, 1); + callFeed.dispose(); + this.emit(GroupCallEvent.ScreenshareFeedsChanged, this.screenshareFeeds); + } + + /** + * Recalculates and updates the participant map to match the room state. + */ + updateParticipants() { + const localMember = this.room.getMember(this.client.getUserId()); + if (!localMember) { + // The client hasn't fetched enough of the room state to get our own member + // event. This probably shouldn't happen, but sanity check & exit for now. + _logger.logger.warn(`GroupCall ${this.groupCallId} updateParticipants() tried to update participants before local room member is available`); + return; + } + if (this.participantsExpirationTimer !== null) { + clearTimeout(this.participantsExpirationTimer); + this.participantsExpirationTimer = null; + } + if (this.state === GroupCallState.Ended) { + this.participants = new Map(); + return; + } + const participants = new Map(); + const now = Date.now(); + const entered = this.state === GroupCallState.Entered || this.enteredViaAnotherSession; + let nextExpiration = Infinity; + for (const e of this.getMemberStateEvents()) { + const member = this.room.getMember(e.getStateKey()); + const content = e.getContent(); + const calls = Array.isArray(content["m.calls"]) ? content["m.calls"] : []; + const call = calls.find(call => call["m.call_id"] === this.groupCallId); + const devices = Array.isArray(call?.["m.devices"]) ? call["m.devices"] : []; + + // Filter out invalid and expired devices + let validDevices = devices.filter(d => typeof d.device_id === "string" && typeof d.session_id === "string" && typeof d.expires_ts === "number" && d.expires_ts > now && Array.isArray(d.feeds)); + + // Apply local echo for the unentered case + if (!entered && member?.userId === this.client.getUserId()) { + validDevices = validDevices.filter(d => d.device_id !== this.client.getDeviceId()); + } + + // Must have a connected device and be joined to the room + if (validDevices.length > 0 && member?.membership === "join") { + const deviceMap = new Map(); + participants.set(member, deviceMap); + for (const d of validDevices) { + deviceMap.set(d.device_id, { + sessionId: d.session_id, + screensharing: d.feeds.some(f => f.purpose === _callEventTypes.SDPStreamMetadataPurpose.Screenshare) + }); + if (d.expires_ts < nextExpiration) nextExpiration = d.expires_ts; + } + } + } + + // Apply local echo for the entered case + if (entered) { + let deviceMap = participants.get(localMember); + if (deviceMap === undefined) { + deviceMap = new Map(); + participants.set(localMember, deviceMap); + } + if (!deviceMap.has(this.client.getDeviceId())) { + deviceMap.set(this.client.getDeviceId(), { + sessionId: this.client.getSessionId(), + screensharing: this.getLocalFeeds().some(f => f.purpose === _callEventTypes.SDPStreamMetadataPurpose.Screenshare) + }); + } + } + this.participants = participants; + if (nextExpiration < Infinity) { + this.participantsExpirationTimer = setTimeout(() => this.updateParticipants(), nextExpiration - now); + } + } + + /** + * Updates the local user's member state with the devices returned by the given function. + * @param fn - A function from the current devices to the new devices. If it + * returns null, the update will be skipped. + * @param keepAlive - Whether the request should outlive the window. + */ + async updateDevices(fn, keepAlive = false) { + const now = Date.now(); + const localUserId = this.client.getUserId(); + const event = this.getMemberStateEvents(localUserId); + const content = event?.getContent() ?? {}; + const calls = Array.isArray(content["m.calls"]) ? content["m.calls"] : []; + let call = null; + const otherCalls = []; + for (const c of calls) { + if (c["m.call_id"] === this.groupCallId) { + call = c; + } else { + otherCalls.push(c); + } + } + if (call === null) call = {}; + const devices = Array.isArray(call["m.devices"]) ? call["m.devices"] : []; + + // Filter out invalid and expired devices + const validDevices = devices.filter(d => typeof d.device_id === "string" && typeof d.session_id === "string" && typeof d.expires_ts === "number" && d.expires_ts > now && Array.isArray(d.feeds)); + const newDevices = fn(validDevices); + if (newDevices === null) return; + const newCalls = [...otherCalls]; + if (newDevices.length > 0) { + newCalls.push(_objectSpread(_objectSpread({}, call), {}, { + "m.call_id": this.groupCallId, + "m.devices": newDevices + })); + } + const newContent = { + "m.calls": newCalls + }; + await this.client.sendStateEvent(this.room.roomId, _event.EventType.GroupCallMemberPrefix, newContent, localUserId, { + keepAlive + }); + } + async addDeviceToMemberState() { + await this.updateDevices(devices => [...devices.filter(d => d.device_id !== this.client.getDeviceId()), { + device_id: this.client.getDeviceId(), + session_id: this.client.getSessionId(), + expires_ts: Date.now() + DEVICE_TIMEOUT, + feeds: this.getLocalFeeds().map(feed => ({ + purpose: feed.purpose + })) + // TODO: Add data channels + }]); + } + + async updateMemberState() { + // Clear the old update interval before proceeding + if (this.resendMemberStateTimer !== null) { + clearInterval(this.resendMemberStateTimer); + this.resendMemberStateTimer = null; + } + if (this.state === GroupCallState.Entered) { + // Add the local device + await this.addDeviceToMemberState(); + + // Resend the state event every so often so it doesn't become stale + this.resendMemberStateTimer = setInterval(async () => { + _logger.logger.log(`GroupCall ${this.groupCallId} updateMemberState() resending call member state"`); + try { + await this.addDeviceToMemberState(); + } catch (e) { + _logger.logger.error(`GroupCall ${this.groupCallId} updateMemberState() failed to resend call member state`, e); + } + }, DEVICE_TIMEOUT * 3 / 4); + } else { + // Remove the local device + await this.updateDevices(devices => devices.filter(d => d.device_id !== this.client.getDeviceId()), true); + } + } + + /** + * Cleans up our member state by filtering out logged out devices, inactive + * devices, and our own device (if we know we haven't entered). + */ + async cleanMemberState() { + const { + devices: myDevices + } = await this.client.getDevices(); + const deviceMap = new Map(myDevices.map(d => [d.device_id, d])); + + // updateDevices takes care of filtering out inactive devices for us + await this.updateDevices(devices => { + const newDevices = devices.filter(d => { + const device = deviceMap.get(d.device_id); + return device?.last_seen_ts !== undefined && !(d.device_id === this.client.getDeviceId() && this.state !== GroupCallState.Entered && !this.enteredViaAnotherSession); + }); + + // Skip the update if the devices are unchanged + return newDevices.length === devices.length ? null : newDevices; + }); + } + getGroupCallStats() { + if (this.stats === undefined) { + const userID = this.client.getUserId() || "unknown"; + this.stats = new _groupCallStats.GroupCallStats(this.groupCallId, userID, this.statsCollectIntervalTime); + this.stats.reports.on(_statsReport.StatsReport.CONNECTION_STATS, this.onConnectionStats); + this.stats.reports.on(_statsReport.StatsReport.BYTE_SENT_STATS, this.onByteSentStats); + this.stats.reports.on(_statsReport.StatsReport.SUMMARY_STATS, this.onSummaryStats); + } + return this.stats; + } + setGroupCallStatsInterval(interval) { + this.statsCollectIntervalTime = interval; + if (this.stats !== undefined) { + this.stats.stop(); + this.stats.setInterval(interval); + if (interval > 0) { + this.stats.start(); + } + } + } +} +exports.GroupCall = GroupCall;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/groupCallEventHandler.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/groupCallEventHandler.js new file mode 100644 index 0000000000..6c80c5b4da --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/groupCallEventHandler.js @@ -0,0 +1,181 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.GroupCallEventHandlerEvent = exports.GroupCallEventHandler = void 0; +var _client = require("../client"); +var _groupCall = require("./groupCall"); +var _roomState = require("../models/room-state"); +var _logger = require("../logger"); +var _event = require("../@types/event"); +var _sync = require("../sync"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com> + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +let GroupCallEventHandlerEvent = /*#__PURE__*/function (GroupCallEventHandlerEvent) { + GroupCallEventHandlerEvent["Incoming"] = "GroupCall.incoming"; + GroupCallEventHandlerEvent["Outgoing"] = "GroupCall.outgoing"; + GroupCallEventHandlerEvent["Ended"] = "GroupCall.ended"; + GroupCallEventHandlerEvent["Participants"] = "GroupCall.participants"; + return GroupCallEventHandlerEvent; +}({}); +exports.GroupCallEventHandlerEvent = GroupCallEventHandlerEvent; +class GroupCallEventHandler { + constructor(client) { + this.client = client; + _defineProperty(this, "groupCalls", new Map()); + // roomId -> GroupCall + // All rooms we know about and whether we've seen a 'Room' event + // for them. The promise will be fulfilled once we've processed that + // event which means we're "up to date" on what calls are in a room + // and get + _defineProperty(this, "roomDeferreds", new Map()); + _defineProperty(this, "onRoomsChanged", room => { + this.createGroupCallForRoom(room); + }); + _defineProperty(this, "onRoomStateChanged", (event, state) => { + const eventType = event.getType(); + if (eventType === _event.EventType.GroupCallPrefix) { + const groupCallId = event.getStateKey(); + const content = event.getContent(); + const currentGroupCall = this.groupCalls.get(state.roomId); + if (!currentGroupCall && !content["m.terminated"] && !event.isRedacted()) { + this.createGroupCallFromRoomStateEvent(event); + } else if (currentGroupCall && currentGroupCall.groupCallId === groupCallId) { + if (content["m.terminated"] || event.isRedacted()) { + currentGroupCall.terminate(false); + } else if (content["m.type"] !== currentGroupCall.type) { + // TODO: Handle the callType changing when the room state changes + _logger.logger.warn(`GroupCallEventHandler onRoomStateChanged() currently does not support changing type (roomId=${state.roomId})`); + } + } else if (currentGroupCall && currentGroupCall.groupCallId !== groupCallId) { + // TODO: Handle new group calls and multiple group calls + _logger.logger.warn(`GroupCallEventHandler onRoomStateChanged() currently does not support multiple calls (roomId=${state.roomId})`); + } + } + }); + } + async start() { + // We wait until the client has started syncing for real. + // This is because we only support one call at a time, and want + // the latest. We therefore want the latest state of the room before + // we create a group call for the room so we can be fairly sure that + // the group call we create is really the latest one. + if (this.client.getSyncState() !== _sync.SyncState.Syncing) { + _logger.logger.debug("GroupCallEventHandler start() waiting for client to start syncing"); + await new Promise(resolve => { + const onSync = () => { + if (this.client.getSyncState() === _sync.SyncState.Syncing) { + this.client.off(_client.ClientEvent.Sync, onSync); + return resolve(); + } + }; + this.client.on(_client.ClientEvent.Sync, onSync); + }); + } + const rooms = this.client.getRooms(); + for (const room of rooms) { + this.createGroupCallForRoom(room); + } + this.client.on(_client.ClientEvent.Room, this.onRoomsChanged); + this.client.on(_roomState.RoomStateEvent.Events, this.onRoomStateChanged); + } + stop() { + this.client.removeListener(_roomState.RoomStateEvent.Events, this.onRoomStateChanged); + } + getRoomDeferred(roomId) { + let deferred = this.roomDeferreds.get(roomId); + if (deferred === undefined) { + let resolveFunc; + deferred = { + prom: new Promise(resolve => { + resolveFunc = resolve; + }) + }; + deferred.resolve = resolveFunc; + this.roomDeferreds.set(roomId, deferred); + } + return deferred; + } + waitUntilRoomReadyForGroupCalls(roomId) { + return this.getRoomDeferred(roomId).prom; + } + getGroupCallById(groupCallId) { + return [...this.groupCalls.values()].find(groupCall => groupCall.groupCallId === groupCallId); + } + createGroupCallForRoom(room) { + const callEvents = room.currentState.getStateEvents(_event.EventType.GroupCallPrefix); + const sortedCallEvents = callEvents.sort((a, b) => b.getTs() - a.getTs()); + for (const callEvent of sortedCallEvents) { + const content = callEvent.getContent(); + if (content["m.terminated"] || callEvent.isRedacted()) { + continue; + } + _logger.logger.debug(`GroupCallEventHandler createGroupCallForRoom() choosing group call from possible calls (stateKey=${callEvent.getStateKey()}, ts=${callEvent.getTs()}, roomId=${room.roomId}, numOfPossibleCalls=${callEvents.length})`); + this.createGroupCallFromRoomStateEvent(callEvent); + break; + } + _logger.logger.info(`GroupCallEventHandler createGroupCallForRoom() processed room (roomId=${room.roomId})`); + this.getRoomDeferred(room.roomId).resolve(); + } + createGroupCallFromRoomStateEvent(event) { + const roomId = event.getRoomId(); + const content = event.getContent(); + const room = this.client.getRoom(roomId); + if (!room) { + _logger.logger.warn(`GroupCallEventHandler createGroupCallFromRoomStateEvent() couldn't find room for call (roomId=${roomId})`); + return; + } + const groupCallId = event.getStateKey(); + const callType = content["m.type"]; + if (!Object.values(_groupCall.GroupCallType).includes(callType)) { + _logger.logger.warn(`GroupCallEventHandler createGroupCallFromRoomStateEvent() received invalid call type (type=${callType}, roomId=${roomId})`); + return; + } + const callIntent = content["m.intent"]; + if (!Object.values(_groupCall.GroupCallIntent).includes(callIntent)) { + _logger.logger.warn(`Received invalid group call intent (type=${callType}, roomId=${roomId})`); + return; + } + const isPtt = Boolean(content["io.element.ptt"]); + let dataChannelOptions; + if (content?.dataChannelsEnabled && content?.dataChannelOptions) { + // Pull out just the dataChannelOptions we want to support. + const { + ordered, + maxPacketLifeTime, + maxRetransmits, + protocol + } = content.dataChannelOptions; + dataChannelOptions = { + ordered, + maxPacketLifeTime, + maxRetransmits, + protocol + }; + } + const groupCall = new _groupCall.GroupCall(this.client, room, callType, isPtt, callIntent, groupCallId, + // Because without Media section a WebRTC connection is not possible, so need a RTCDataChannel to set up a + // no media WebRTC connection anyway. + content?.dataChannelsEnabled || this.client.isVoipWithNoMediaAllowed, dataChannelOptions, this.client.isVoipWithNoMediaAllowed); + this.groupCalls.set(room.roomId, groupCall); + this.client.emit(GroupCallEventHandlerEvent.Incoming, groupCall); + return groupCall; + } +} +exports.GroupCallEventHandler = GroupCallEventHandler;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/mediaHandler.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/mediaHandler.js new file mode 100644 index 0000000000..2077d05a27 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/mediaHandler.js @@ -0,0 +1,395 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.MediaHandlerEvent = exports.MediaHandler = void 0; +var _typedEventEmitter = require("../models/typed-event-emitter"); +var _groupCall = require("../webrtc/groupCall"); +var _logger = require("../logger"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2015, 2016 OpenMarket Ltd + Copyright 2017 New Vector Ltd + Copyright 2019, 2020 The Matrix.org Foundation C.I.C. + Copyright 2021 - 2022 Šimon Brandner <simon.bra.ag@gmail.com> + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +let MediaHandlerEvent = /*#__PURE__*/function (MediaHandlerEvent) { + MediaHandlerEvent["LocalStreamsChanged"] = "local_streams_changed"; + return MediaHandlerEvent; +}({}); +exports.MediaHandlerEvent = MediaHandlerEvent; +class MediaHandler extends _typedEventEmitter.TypedEventEmitter { + constructor(client) { + super(); + this.client = client; + _defineProperty(this, "audioInput", void 0); + _defineProperty(this, "audioSettings", void 0); + _defineProperty(this, "videoInput", void 0); + _defineProperty(this, "localUserMediaStream", void 0); + _defineProperty(this, "userMediaStreams", []); + _defineProperty(this, "screensharingStreams", []); + // Promise chain to serialise calls to getMediaStream + _defineProperty(this, "getMediaStreamPromise", void 0); + } + restoreMediaSettings(audioInput, videoInput) { + this.audioInput = audioInput; + this.videoInput = videoInput; + } + + /** + * Set an audio input device to use for MatrixCalls + * @param deviceId - the identifier for the device + * undefined treated as unset + */ + async setAudioInput(deviceId) { + _logger.logger.info(`MediaHandler setAudioInput() running (deviceId=${deviceId})`); + if (this.audioInput === deviceId) return; + this.audioInput = deviceId; + await this.updateLocalUsermediaStreams(); + } + + /** + * Set audio settings for MatrixCalls + * @param opts - audio options to set + */ + async setAudioSettings(opts) { + _logger.logger.info(`MediaHandler setAudioSettings() running (opts=${JSON.stringify(opts)})`); + this.audioSettings = Object.assign({}, opts); + await this.updateLocalUsermediaStreams(); + } + + /** + * Set a video input device to use for MatrixCalls + * @param deviceId - the identifier for the device + * undefined treated as unset + */ + async setVideoInput(deviceId) { + _logger.logger.info(`MediaHandler setVideoInput() running (deviceId=${deviceId})`); + if (this.videoInput === deviceId) return; + this.videoInput = deviceId; + await this.updateLocalUsermediaStreams(); + } + + /** + * Set media input devices to use for MatrixCalls + * @param audioInput - the identifier for the audio device + * @param videoInput - the identifier for the video device + * undefined treated as unset + */ + async setMediaInputs(audioInput, videoInput) { + _logger.logger.log(`MediaHandler setMediaInputs() running (audioInput: ${audioInput} videoInput: ${videoInput})`); + this.audioInput = audioInput; + this.videoInput = videoInput; + await this.updateLocalUsermediaStreams(); + } + + /* + * Requests new usermedia streams and replace the old ones + */ + async updateLocalUsermediaStreams() { + if (this.userMediaStreams.length === 0) return; + const callMediaStreamParams = new Map(); + for (const call of this.client.callEventHandler.calls.values()) { + callMediaStreamParams.set(call.callId, { + audio: call.hasLocalUserMediaAudioTrack, + video: call.hasLocalUserMediaVideoTrack + }); + } + for (const stream of this.userMediaStreams) { + _logger.logger.log(`MediaHandler updateLocalUsermediaStreams() stopping all tracks (streamId=${stream.id})`); + for (const track of stream.getTracks()) { + track.stop(); + } + } + this.userMediaStreams = []; + this.localUserMediaStream = undefined; + for (const call of this.client.callEventHandler.calls.values()) { + if (call.callHasEnded() || !callMediaStreamParams.has(call.callId)) { + continue; + } + const { + audio, + video + } = callMediaStreamParams.get(call.callId); + _logger.logger.log(`MediaHandler updateLocalUsermediaStreams() calling getUserMediaStream() (callId=${call.callId})`); + const stream = await this.getUserMediaStream(audio, video); + if (call.callHasEnded()) { + continue; + } + await call.updateLocalUsermediaStream(stream); + } + for (const groupCall of this.client.groupCallEventHandler.groupCalls.values()) { + if (!groupCall.localCallFeed) { + continue; + } + _logger.logger.log(`MediaHandler updateLocalUsermediaStreams() calling getUserMediaStream() (groupCallId=${groupCall.groupCallId})`); + const stream = await this.getUserMediaStream(true, groupCall.type === _groupCall.GroupCallType.Video); + if (groupCall.state === _groupCall.GroupCallState.Ended) { + continue; + } + await groupCall.updateLocalUsermediaStream(stream); + } + this.emit(MediaHandlerEvent.LocalStreamsChanged); + } + async hasAudioDevice() { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + return devices.filter(device => device.kind === "audioinput").length > 0; + } catch (err) { + _logger.logger.log(`MediaHandler hasAudioDevice() calling navigator.mediaDevices.enumerateDevices with error`, err); + return false; + } + } + async hasVideoDevice() { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + return devices.filter(device => device.kind === "videoinput").length > 0; + } catch (err) { + _logger.logger.log(`MediaHandler hasVideoDevice() calling navigator.mediaDevices.enumerateDevices with error`, err); + return false; + } + } + + /** + * @param audio - should have an audio track + * @param video - should have a video track + * @param reusable - is allowed to be reused by the MediaHandler + * @returns based on passed parameters + */ + async getUserMediaStream(audio, video, reusable = true) { + // Serialise calls, othertwise we can't sensibly re-use the stream + if (this.getMediaStreamPromise) { + this.getMediaStreamPromise = this.getMediaStreamPromise.then(() => { + return this.getUserMediaStreamInternal(audio, video, reusable); + }); + } else { + this.getMediaStreamPromise = this.getUserMediaStreamInternal(audio, video, reusable); + } + return this.getMediaStreamPromise; + } + async getUserMediaStreamInternal(audio, video, reusable) { + const shouldRequestAudio = audio && (await this.hasAudioDevice()); + const shouldRequestVideo = video && (await this.hasVideoDevice()); + let stream; + let canReuseStream = true; + if (this.localUserMediaStream) { + // This figures out if we can reuse the current localUsermediaStream + // based on whether or not the "mute state" (presence of tracks of a + // given kind) matches what is being requested + if (shouldRequestAudio !== this.localUserMediaStream.getAudioTracks().length > 0) { + canReuseStream = false; + } + if (shouldRequestVideo !== this.localUserMediaStream.getVideoTracks().length > 0) { + canReuseStream = false; + } + + // This code checks that the device ID is the same as the localUserMediaStream stream, but we update + // the localUserMediaStream whenever the device ID changes (apart from when restoring) so it's not + // clear why this would ever be different, unless there's a race. + if (shouldRequestAudio && this.localUserMediaStream.getAudioTracks()[0]?.getSettings()?.deviceId !== this.audioInput) { + canReuseStream = false; + } + if (shouldRequestVideo && this.localUserMediaStream.getVideoTracks()[0]?.getSettings()?.deviceId !== this.videoInput) { + canReuseStream = false; + } + } else { + canReuseStream = false; + } + if (!canReuseStream) { + const constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo); + stream = await navigator.mediaDevices.getUserMedia(constraints); + _logger.logger.log(`MediaHandler getUserMediaStreamInternal() calling getUserMediaStream (streamId=${stream.id}, shouldRequestAudio=${shouldRequestAudio}, shouldRequestVideo=${shouldRequestVideo}, constraints=${JSON.stringify(constraints)})`); + for (const track of stream.getTracks()) { + const settings = track.getSettings(); + if (track.kind === "audio") { + this.audioInput = settings.deviceId; + } else if (track.kind === "video") { + this.videoInput = settings.deviceId; + } + } + if (reusable) { + this.localUserMediaStream = stream; + } + } else { + stream = this.localUserMediaStream.clone(); + _logger.logger.log(`MediaHandler getUserMediaStreamInternal() cloning (oldStreamId=${this.localUserMediaStream?.id} newStreamId=${stream.id} shouldRequestAudio=${shouldRequestAudio} shouldRequestVideo=${shouldRequestVideo})`); + if (!shouldRequestAudio) { + for (const track of stream.getAudioTracks()) { + stream.removeTrack(track); + } + } + if (!shouldRequestVideo) { + for (const track of stream.getVideoTracks()) { + stream.removeTrack(track); + } + } + } + if (reusable) { + this.userMediaStreams.push(stream); + } + this.emit(MediaHandlerEvent.LocalStreamsChanged); + return stream; + } + + /** + * Stops all tracks on the provided usermedia stream + */ + stopUserMediaStream(mediaStream) { + _logger.logger.log(`MediaHandler stopUserMediaStream() stopping (streamId=${mediaStream.id})`); + for (const track of mediaStream.getTracks()) { + track.stop(); + } + const index = this.userMediaStreams.indexOf(mediaStream); + if (index !== -1) { + _logger.logger.debug(`MediaHandler stopUserMediaStream() splicing usermedia stream out stream array (streamId=${mediaStream.id})`, mediaStream.id); + this.userMediaStreams.splice(index, 1); + } + this.emit(MediaHandlerEvent.LocalStreamsChanged); + if (this.localUserMediaStream === mediaStream) { + this.localUserMediaStream = undefined; + } + } + + /** + * @param desktopCapturerSourceId - sourceId for Electron DesktopCapturer + * @param reusable - is allowed to be reused by the MediaHandler + * @returns based on passed parameters + */ + async getScreensharingStream(opts = {}, reusable = true) { + let stream; + if (this.screensharingStreams.length === 0) { + const screenshareConstraints = this.getScreenshareContraints(opts); + if (opts.desktopCapturerSourceId) { + // We are using Electron + _logger.logger.debug(`MediaHandler getScreensharingStream() calling getUserMedia() (opts=${JSON.stringify(opts)})`); + stream = await navigator.mediaDevices.getUserMedia(screenshareConstraints); + } else { + // We are not using Electron + _logger.logger.debug(`MediaHandler getScreensharingStream() calling getDisplayMedia() (opts=${JSON.stringify(opts)})`); + stream = await navigator.mediaDevices.getDisplayMedia(screenshareConstraints); + } + } else { + const matchingStream = this.screensharingStreams[this.screensharingStreams.length - 1]; + _logger.logger.log(`MediaHandler getScreensharingStream() cloning (streamId=${matchingStream.id})`); + stream = matchingStream.clone(); + } + if (reusable) { + this.screensharingStreams.push(stream); + } + this.emit(MediaHandlerEvent.LocalStreamsChanged); + return stream; + } + + /** + * Stops all tracks on the provided screensharing stream + */ + stopScreensharingStream(mediaStream) { + _logger.logger.debug(`MediaHandler stopScreensharingStream() stopping stream (streamId=${mediaStream.id})`); + for (const track of mediaStream.getTracks()) { + track.stop(); + } + const index = this.screensharingStreams.indexOf(mediaStream); + if (index !== -1) { + _logger.logger.debug(`MediaHandler stopScreensharingStream() splicing stream out (streamId=${mediaStream.id})`); + this.screensharingStreams.splice(index, 1); + } + this.emit(MediaHandlerEvent.LocalStreamsChanged); + } + + /** + * Stops all local media tracks + */ + stopAllStreams() { + for (const stream of this.userMediaStreams) { + _logger.logger.log(`MediaHandler stopAllStreams() stopping (streamId=${stream.id})`); + for (const track of stream.getTracks()) { + track.stop(); + } + } + for (const stream of this.screensharingStreams) { + for (const track of stream.getTracks()) { + track.stop(); + } + } + this.userMediaStreams = []; + this.screensharingStreams = []; + this.localUserMediaStream = undefined; + this.emit(MediaHandlerEvent.LocalStreamsChanged); + } + getUserMediaContraints(audio, video) { + const isWebkit = !!navigator.webkitGetUserMedia; + return { + audio: audio ? { + deviceId: this.audioInput ? { + ideal: this.audioInput + } : undefined, + autoGainControl: this.audioSettings ? { + ideal: this.audioSettings.autoGainControl + } : undefined, + echoCancellation: this.audioSettings ? { + ideal: this.audioSettings.echoCancellation + } : undefined, + noiseSuppression: this.audioSettings ? { + ideal: this.audioSettings.noiseSuppression + } : undefined + } : false, + video: video ? { + deviceId: this.videoInput ? { + ideal: this.videoInput + } : undefined, + /* We want 640x360. Chrome will give it only if we ask exactly, + FF refuses entirely if we ask exactly, so have to ask for ideal + instead + XXX: Is this still true? + */ + width: isWebkit ? { + exact: 640 + } : { + ideal: 640 + }, + height: isWebkit ? { + exact: 360 + } : { + ideal: 360 + } + } : false + }; + } + getScreenshareContraints(opts) { + const { + desktopCapturerSourceId, + audio + } = opts; + if (desktopCapturerSourceId) { + return { + audio: audio ?? false, + video: { + mandatory: { + chromeMediaSource: "desktop", + chromeMediaSourceId: desktopCapturerSourceId + } + } + }; + } else { + return { + audio: audio ?? false, + video: true + }; + } + } +} +exports.MediaHandler = MediaHandler;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/callStatsReportGatherer.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/callStatsReportGatherer.js new file mode 100644 index 0000000000..b830e469a1 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/callStatsReportGatherer.js @@ -0,0 +1,194 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.CallStatsReportGatherer = void 0; +var _connectionStats = require("./connectionStats"); +var _connectionStatsBuilder = require("./connectionStatsBuilder"); +var _transportStatsBuilder = require("./transportStatsBuilder"); +var _mediaSsrcHandler = require("./media/mediaSsrcHandler"); +var _mediaTrackHandler = require("./media/mediaTrackHandler"); +var _mediaTrackStatsHandler = require("./media/mediaTrackStatsHandler"); +var _trackStatsBuilder = require("./trackStatsBuilder"); +var _connectionStatsReportBuilder = require("./connectionStatsReportBuilder"); +var _valueFormatter = require("./valueFormatter"); +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +class CallStatsReportGatherer { + constructor(callId, opponentMemberId, pc, emitter, isFocus = true) { + this.callId = callId; + this.opponentMemberId = opponentMemberId; + this.pc = pc; + this.emitter = emitter; + this.isFocus = isFocus; + _defineProperty(this, "isActive", true); + _defineProperty(this, "previousStatsReport", void 0); + _defineProperty(this, "currentStatsReport", void 0); + _defineProperty(this, "connectionStats", new _connectionStats.ConnectionStats()); + _defineProperty(this, "trackStats", void 0); + pc.addEventListener("signalingstatechange", this.onSignalStateChange.bind(this)); + this.trackStats = new _mediaTrackStatsHandler.MediaTrackStatsHandler(new _mediaSsrcHandler.MediaSsrcHandler(), new _mediaTrackHandler.MediaTrackHandler(pc)); + } + async processStats(groupCallId, localUserId) { + const summary = { + isFirstCollection: this.previousStatsReport === undefined, + receivedMedia: 0, + receivedAudioMedia: 0, + receivedVideoMedia: 0, + audioTrackSummary: { + count: 0, + muted: 0, + maxPacketLoss: 0, + maxJitter: 0, + concealedAudio: 0, + totalAudio: 0 + }, + videoTrackSummary: { + count: 0, + muted: 0, + maxPacketLoss: 0, + maxJitter: 0, + concealedAudio: 0, + totalAudio: 0 + } + }; + if (this.isActive) { + const statsPromise = this.pc.getStats(); + if (typeof statsPromise?.then === "function") { + return statsPromise.then(report => { + // @ts-ignore + this.currentStatsReport = typeof report?.result === "function" ? report.result() : report; + try { + this.processStatsReport(groupCallId, localUserId); + } catch (error) { + this.isActive = false; + return summary; + } + this.previousStatsReport = this.currentStatsReport; + summary.receivedMedia = this.connectionStats.bitrate.download; + summary.receivedAudioMedia = this.connectionStats.bitrate.audio?.download || 0; + summary.receivedVideoMedia = this.connectionStats.bitrate.video?.download || 0; + const trackSummary = _trackStatsBuilder.TrackStatsBuilder.buildTrackSummary(Array.from(this.trackStats.getTrack2stats().values())); + return _objectSpread(_objectSpread({}, summary), {}, { + audioTrackSummary: trackSummary.audioTrackSummary, + videoTrackSummary: trackSummary.videoTrackSummary + }); + }).catch(error => { + this.handleError(error); + return summary; + }); + } + this.isActive = false; + } + return Promise.resolve(summary); + } + processStatsReport(groupCallId, localUserId) { + const byteSentStatsReport = new Map(); + byteSentStatsReport.callId = this.callId; + byteSentStatsReport.opponentMemberId = this.opponentMemberId; + this.currentStatsReport?.forEach(now => { + const before = this.previousStatsReport ? this.previousStatsReport.get(now.id) : null; + // RTCIceCandidatePairStats - https://w3c.github.io/webrtc-stats/#candidatepair-dict* + if (now.type === "candidate-pair" && now.nominated && now.state === "succeeded") { + this.connectionStats.bandwidth = _connectionStatsBuilder.ConnectionStatsBuilder.buildBandwidthReport(now); + this.connectionStats.transport = _transportStatsBuilder.TransportStatsBuilder.buildReport(this.currentStatsReport, now, this.connectionStats.transport, this.isFocus); + + // RTCReceivedRtpStreamStats + // https://w3c.github.io/webrtc-stats/#receivedrtpstats-dict* + // RTCSentRtpStreamStats + // https://w3c.github.io/webrtc-stats/#sentrtpstats-dict* + } else if (now.type === "inbound-rtp" || now.type === "outbound-rtp") { + const trackStats = this.trackStats.findTrack2Stats(now, now.type === "inbound-rtp" ? "remote" : "local"); + if (!trackStats) { + return; + } + if (before) { + _trackStatsBuilder.TrackStatsBuilder.buildPacketsLost(trackStats, now, before); + } + + // Get the resolution and framerate for only remote video sources here. For the local video sources, + // 'track' stats will be used since they have the updated resolution based on the simulcast streams + // currently being sent. Promise based getStats reports three 'outbound-rtp' streams and there will be + // more calculations needed to determine what is the highest resolution stream sent by the client if the + // 'outbound-rtp' stats are used. + if (now.type === "inbound-rtp") { + _trackStatsBuilder.TrackStatsBuilder.buildFramerateResolution(trackStats, now); + if (before) { + _trackStatsBuilder.TrackStatsBuilder.buildBitrateReceived(trackStats, now, before); + } + const ts = this.trackStats.findTransceiverByTrackId(trackStats.trackId); + _trackStatsBuilder.TrackStatsBuilder.setTrackStatsState(trackStats, ts); + _trackStatsBuilder.TrackStatsBuilder.buildJitter(trackStats, now); + _trackStatsBuilder.TrackStatsBuilder.buildAudioConcealment(trackStats, now); + } else if (before) { + byteSentStatsReport.set(trackStats.trackId, _valueFormatter.ValueFormatter.getNonNegativeValue(now.bytesSent)); + _trackStatsBuilder.TrackStatsBuilder.buildBitrateSend(trackStats, now, before); + } + _trackStatsBuilder.TrackStatsBuilder.buildCodec(this.currentStatsReport, trackStats, now); + } else if (now.type === "track" && now.kind === "video" && !now.remoteSource) { + const trackStats = this.trackStats.findLocalVideoTrackStats(now); + if (!trackStats) { + return; + } + _trackStatsBuilder.TrackStatsBuilder.buildFramerateResolution(trackStats, now); + _trackStatsBuilder.TrackStatsBuilder.calculateSimulcastFramerate(trackStats, now, before, this.trackStats.mediaTrackHandler.getActiveSimulcastStreams()); + } + }); + this.emitter.emitByteSendReport(byteSentStatsReport); + this.processAndEmitConnectionStatsReport(); + } + setActive(isActive) { + this.isActive = isActive; + } + getActive() { + return this.isActive; + } + handleError(_) { + this.isActive = false; + } + processAndEmitConnectionStatsReport() { + const report = _connectionStatsReportBuilder.ConnectionStatsReportBuilder.build(this.trackStats.getTrack2stats()); + report.callId = this.callId; + report.opponentMemberId = this.opponentMemberId; + this.connectionStats.bandwidth = report.bandwidth; + this.connectionStats.bitrate = report.bitrate; + this.connectionStats.packetLoss = report.packetLoss; + this.emitter.emitConnectionStatsReport(_objectSpread(_objectSpread({}, report), {}, { + transport: this.connectionStats.transport + })); + this.connectionStats.transport = []; + } + stopProcessingStats() {} + onSignalStateChange() { + if (this.pc.signalingState === "stable") { + if (this.pc.currentRemoteDescription) { + this.trackStats.mediaSsrcHandler.parse(this.pc.currentRemoteDescription.sdp, "remote"); + } + if (this.pc.currentLocalDescription) { + this.trackStats.mediaSsrcHandler.parse(this.pc.currentLocalDescription.sdp, "local"); + } + } + } + setOpponentMemberId(id) { + this.opponentMemberId = id; + } +} +exports.CallStatsReportGatherer = CallStatsReportGatherer;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/callStatsReportSummary.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/callStatsReportSummary.js new file mode 100644 index 0000000000..430afc16cd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/callStatsReportSummary.js @@ -0,0 +1,5 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStats.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStats.js new file mode 100644 index 0000000000..16374812d0 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStats.js @@ -0,0 +1,34 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.ConnectionStats = void 0; +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +class ConnectionStats { + constructor() { + _defineProperty(this, "bandwidth", {}); + _defineProperty(this, "bitrate", {}); + _defineProperty(this, "packetLoss", {}); + _defineProperty(this, "transport", []); + } +} +exports.ConnectionStats = ConnectionStats;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStatsBuilder.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStatsBuilder.js new file mode 100644 index 0000000000..64bf4082ff --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStatsBuilder.js @@ -0,0 +1,33 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.ConnectionStatsBuilder = void 0; +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +class ConnectionStatsBuilder { + static buildBandwidthReport(now) { + const availableIncomingBitrate = now.availableIncomingBitrate; + const availableOutgoingBitrate = now.availableOutgoingBitrate; + return { + download: availableIncomingBitrate ? Math.round(availableIncomingBitrate / 1000) : 0, + upload: availableOutgoingBitrate ? Math.round(availableOutgoingBitrate / 1000) : 0 + }; + } +} +exports.ConnectionStatsBuilder = ConnectionStatsBuilder;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStatsReportBuilder.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStatsReportBuilder.js new file mode 100644 index 0000000000..7178b5411e --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStatsReportBuilder.js @@ -0,0 +1,127 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.ConnectionStatsReportBuilder = void 0; +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +class ConnectionStatsReportBuilder { + static build(stats) { + const report = {}; + + // process stats + const totalPackets = { + download: 0, + upload: 0 + }; + const lostPackets = { + download: 0, + upload: 0 + }; + let bitrateDownload = 0; + let bitrateUpload = 0; + const resolutions = { + local: new Map(), + remote: new Map() + }; + const framerates = { + local: new Map(), + remote: new Map() + }; + const codecs = { + local: new Map(), + remote: new Map() + }; + const jitter = new Map(); + const audioConcealment = new Map(); + let audioBitrateDownload = 0; + let audioBitrateUpload = 0; + let videoBitrateDownload = 0; + let videoBitrateUpload = 0; + let totalConcealedAudio = 0; + let totalAudioDuration = 0; + for (const [trackId, trackStats] of stats) { + // process packet loss stats + const loss = trackStats.getLoss(); + const type = loss.isDownloadStream ? "download" : "upload"; + totalPackets[type] += loss.packetsTotal; + lostPackets[type] += loss.packetsLost; + + // process bitrate stats + bitrateDownload += trackStats.getBitrate().download; + bitrateUpload += trackStats.getBitrate().upload; + + // collect resolutions and framerates + if (trackStats.kind === "audio") { + // process audio quality stats + const audioConcealmentForTrack = trackStats.getAudioConcealment(); + totalConcealedAudio += audioConcealmentForTrack.concealedAudio; + totalAudioDuration += audioConcealmentForTrack.totalAudioDuration; + audioBitrateDownload += trackStats.getBitrate().download; + audioBitrateUpload += trackStats.getBitrate().upload; + } else { + videoBitrateDownload += trackStats.getBitrate().download; + videoBitrateUpload += trackStats.getBitrate().upload; + } + resolutions[trackStats.getType()].set(trackId, trackStats.getResolution()); + framerates[trackStats.getType()].set(trackId, trackStats.getFramerate()); + codecs[trackStats.getType()].set(trackId, trackStats.getCodec()); + if (trackStats.getType() === "remote") { + jitter.set(trackId, trackStats.getJitter()); + if (trackStats.kind === "audio") { + audioConcealment.set(trackId, trackStats.getAudioConcealment()); + } + } + trackStats.resetBitrate(); + } + report.bitrate = { + upload: bitrateUpload, + download: bitrateDownload + }; + report.bitrate.audio = { + upload: audioBitrateUpload, + download: audioBitrateDownload + }; + report.bitrate.video = { + upload: videoBitrateUpload, + download: videoBitrateDownload + }; + report.packetLoss = { + total: ConnectionStatsReportBuilder.calculatePacketLoss(lostPackets.download + lostPackets.upload, totalPackets.download + totalPackets.upload), + download: ConnectionStatsReportBuilder.calculatePacketLoss(lostPackets.download, totalPackets.download), + upload: ConnectionStatsReportBuilder.calculatePacketLoss(lostPackets.upload, totalPackets.upload) + }; + report.audioConcealment = audioConcealment; + report.totalAudioConcealment = { + concealedAudio: totalConcealedAudio, + totalAudioDuration + }; + report.framerate = framerates; + report.resolution = resolutions; + report.codec = codecs; + report.jitter = jitter; + return report; + } + static calculatePacketLoss(lostPackets, totalPackets) { + if (!totalPackets || totalPackets <= 0 || !lostPackets || lostPackets <= 0) { + return 0; + } + return Math.round(lostPackets / totalPackets * 100); + } +} +exports.ConnectionStatsReportBuilder = ConnectionStatsReportBuilder;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/groupCallStats.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/groupCallStats.js new file mode 100644 index 0000000000..4ed8a1062f --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/groupCallStats.js @@ -0,0 +1,80 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.GroupCallStats = void 0; +var _callStatsReportGatherer = require("./callStatsReportGatherer"); +var _statsReportEmitter = require("./statsReportEmitter"); +var _summaryStatsReportGatherer = require("./summaryStatsReportGatherer"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +class GroupCallStats { + constructor(groupCallId, userId, interval = 10000) { + this.groupCallId = groupCallId; + this.userId = userId; + this.interval = interval; + _defineProperty(this, "timer", void 0); + _defineProperty(this, "gatherers", new Map()); + _defineProperty(this, "reports", new _statsReportEmitter.StatsReportEmitter()); + _defineProperty(this, "summaryStatsReportGatherer", new _summaryStatsReportGatherer.SummaryStatsReportGatherer(this.reports)); + } + start() { + if (this.timer === undefined && this.interval > 0) { + this.timer = setInterval(() => { + this.processStats(); + }, this.interval); + } + } + stop() { + if (this.timer !== undefined) { + clearInterval(this.timer); + this.gatherers.forEach(c => c.stopProcessingStats()); + } + } + hasStatsReportGatherer(callId) { + return this.gatherers.has(callId); + } + addStatsReportGatherer(callId, opponentMemberId, peerConnection) { + if (this.hasStatsReportGatherer(callId)) { + return false; + } + this.gatherers.set(callId, new _callStatsReportGatherer.CallStatsReportGatherer(callId, opponentMemberId, peerConnection, this.reports)); + return true; + } + removeStatsReportGatherer(callId) { + return this.gatherers.delete(callId); + } + getStatsReportGatherer(callId) { + return this.hasStatsReportGatherer(callId) ? this.gatherers.get(callId) : undefined; + } + updateOpponentMember(callId, opponentMember) { + this.getStatsReportGatherer(callId)?.setOpponentMemberId(opponentMember); + } + processStats() { + const summary = []; + this.gatherers.forEach(c => { + summary.push(c.processStats(this.groupCallId, this.userId)); + }); + Promise.all(summary).then(s => this.summaryStatsReportGatherer.build(s)); + } + setInterval(interval) { + this.interval = interval; + } +} +exports.GroupCallStats = GroupCallStats;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaSsrcHandler.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaSsrcHandler.js new file mode 100644 index 0000000000..5e43415558 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaSsrcHandler.js @@ -0,0 +1,62 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.MediaSsrcHandler = void 0; +var _sdpTransform = require("sdp-transform"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +class MediaSsrcHandler { + constructor() { + _defineProperty(this, "ssrcToMid", { + local: new Map(), + remote: new Map() + }); + } + findMidBySsrc(ssrc, type) { + let mid; + this.ssrcToMid[type].forEach((ssrcs, m) => { + if (ssrcs.find(s => s == ssrc)) { + mid = m; + return; + } + }); + return mid; + } + parse(description, type) { + const sdp = (0, _sdpTransform.parse)(description); + const ssrcToMid = new Map(); + sdp.media.forEach(m => { + if (!!m.mid && m.type === "video" || m.type === "audio") { + const ssrcs = []; + m.ssrcs?.forEach(ssrc => { + if (ssrc.attribute === "cname") { + ssrcs.push(`${ssrc.id}`); + } + }); + ssrcToMid.set(`${m.mid}`, ssrcs); + } + }); + this.ssrcToMid[type] = ssrcToMid; + } + getSsrcToMidMap(type) { + return this.ssrcToMid[type]; + } +} +exports.MediaSsrcHandler = MediaSsrcHandler;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackHandler.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackHandler.js new file mode 100644 index 0000000000..c4252a9cbd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackHandler.js @@ -0,0 +1,69 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.MediaTrackHandler = void 0; +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +class MediaTrackHandler { + constructor(pc) { + this.pc = pc; + } + getLocalTracks(kind) { + const isNotNullAndKind = track => { + return track !== null && track.kind === kind; + }; + // @ts-ignore The linter don't get it + return this.pc.getTransceivers().filter(t => t.currentDirection === "sendonly" || t.currentDirection === "sendrecv").filter(t => t.sender !== null).map(t => t.sender).map(s => s.track).filter(isNotNullAndKind); + } + getTackById(trackId) { + return this.pc.getTransceivers().map(t => { + if (t?.sender.track !== null && t.sender.track.id === trackId) { + return t.sender.track; + } + if (t?.receiver.track !== null && t.receiver.track.id === trackId) { + return t.receiver.track; + } + return undefined; + }).find(t => t !== undefined); + } + getLocalTrackIdByMid(mid) { + const transceiver = this.pc.getTransceivers().find(t => t.mid === mid); + if (transceiver !== undefined && !!transceiver.sender && !!transceiver.sender.track) { + return transceiver.sender.track.id; + } + return undefined; + } + getRemoteTrackIdByMid(mid) { + const transceiver = this.pc.getTransceivers().find(t => t.mid === mid); + if (transceiver !== undefined && !!transceiver.receiver && !!transceiver.receiver.track) { + return transceiver.receiver.track.id; + } + return undefined; + } + getActiveSimulcastStreams() { + //@TODO implement this right.. Check how many layer configured + return 3; + } + getTransceiverByTrackId(trackId) { + return this.pc.getTransceivers().find(t => { + return t.receiver.track.id === trackId || t.sender.track !== null && t.sender.track.id === trackId; + }); + } +} +exports.MediaTrackHandler = MediaTrackHandler;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackStats.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackStats.js new file mode 100644 index 0000000000..d5a7963c23 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackStats.js @@ -0,0 +1,150 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.MediaTrackStats = void 0; +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +class MediaTrackStats { + constructor(trackId, type, kind) { + this.trackId = trackId; + this.type = type; + this.kind = kind; + _defineProperty(this, "loss", { + packetsTotal: 0, + packetsLost: 0, + isDownloadStream: false + }); + _defineProperty(this, "bitrate", { + download: 0, + upload: 0 + }); + _defineProperty(this, "resolution", { + width: -1, + height: -1 + }); + _defineProperty(this, "audioConcealment", { + concealedAudio: 0, + totalAudioDuration: 0 + }); + _defineProperty(this, "framerate", 0); + _defineProperty(this, "jitter", 0); + _defineProperty(this, "codec", ""); + _defineProperty(this, "isAlive", true); + _defineProperty(this, "isMuted", false); + _defineProperty(this, "isEnabled", true); + } + getType() { + return this.type; + } + setLoss(loss) { + this.loss = loss; + } + getLoss() { + return this.loss; + } + setResolution(resolution) { + this.resolution = resolution; + } + getResolution() { + return this.resolution; + } + setFramerate(framerate) { + this.framerate = framerate; + } + getFramerate() { + return this.framerate; + } + setBitrate(bitrate) { + this.bitrate = bitrate; + } + getBitrate() { + return this.bitrate; + } + setCodec(codecShortType) { + this.codec = codecShortType; + return true; + } + getCodec() { + return this.codec; + } + resetBitrate() { + this.bitrate = { + download: 0, + upload: 0 + }; + } + set alive(isAlive) { + this.isAlive = isAlive; + } + + /** + * A MediaTrackState is alive if the corresponding MediaStreamTrack track bound to a transceiver and the + * MediaStreamTrack is in state MediaStreamTrack.readyState === live + */ + get alive() { + return this.isAlive; + } + set muted(isMuted) { + this.isMuted = isMuted; + } + + /** + * A MediaTrackState.isMuted corresponding to MediaStreamTrack.muted. + * But these values only match if MediaTrackState.isAlive. + */ + get muted() { + return this.isMuted; + } + set enabled(isEnabled) { + this.isEnabled = isEnabled; + } + + /** + * A MediaTrackState.isEnabled corresponding to MediaStreamTrack.enabled. + * But these values only match if MediaTrackState.isAlive. + */ + get enabled() { + return this.isEnabled; + } + setJitter(jitter) { + this.jitter = jitter; + } + + /** + * Jitter in milliseconds + */ + getJitter() { + return this.jitter; + } + + /** + * Audio concealment ration (conceled duration / total duration) + */ + setAudioConcealment(concealedAudioDuration, totalAudioDuration) { + this.audioConcealment.concealedAudio = concealedAudioDuration; + this.audioConcealment.totalAudioDuration = totalAudioDuration; + } + getAudioConcealment() { + return this.audioConcealment; + } +} +exports.MediaTrackStats = MediaTrackStats;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackStatsHandler.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackStatsHandler.js new file mode 100644 index 0000000000..f72f644cb3 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackStatsHandler.js @@ -0,0 +1,82 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.MediaTrackStatsHandler = void 0; +var _mediaTrackStats = require("./mediaTrackStats"); +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /* + Copyright 2023 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +class MediaTrackStatsHandler { + constructor(mediaSsrcHandler, mediaTrackHandler) { + this.mediaSsrcHandler = mediaSsrcHandler; + this.mediaTrackHandler = mediaTrackHandler; + _defineProperty(this, "track2stats", new Map()); + } + + /** + * Find tracks by rtc stats + * Argument report is any because the stats api is not consistent: + * For example `trackIdentifier`, `mid` not existing in every implementations + * https://www.w3.org/TR/webrtc-stats/#dom-rtcinboundrtpstreamstats + * https://developer.mozilla.org/en-US/docs/Web/API/RTCInboundRtpStreamStats + */ + findTrack2Stats(report, type) { + let trackID; + if (report.trackIdentifier) { + trackID = report.trackIdentifier; + } else if (report.mid) { + trackID = type === "remote" ? this.mediaTrackHandler.getRemoteTrackIdByMid(report.mid) : this.mediaTrackHandler.getLocalTrackIdByMid(report.mid); + } else if (report.ssrc) { + const mid = this.mediaSsrcHandler.findMidBySsrc(report.ssrc, type); + if (!mid) { + return undefined; + } + trackID = type === "remote" ? this.mediaTrackHandler.getRemoteTrackIdByMid(report.mid) : this.mediaTrackHandler.getLocalTrackIdByMid(report.mid); + } + if (!trackID) { + return undefined; + } + let trackStats = this.track2stats.get(trackID); + if (!trackStats) { + const track = this.mediaTrackHandler.getTackById(trackID); + if (track !== undefined) { + const kind = track.kind === "audio" ? track.kind : "video"; + trackStats = new _mediaTrackStats.MediaTrackStats(trackID, type, kind); + this.track2stats.set(trackID, trackStats); + } else { + return undefined; + } + } + return trackStats; + } + findLocalVideoTrackStats(report) { + const localVideoTracks = this.mediaTrackHandler.getLocalTracks("video"); + if (localVideoTracks.length === 0) { + return undefined; + } + return this.findTrack2Stats(report, "local"); + } + getTrack2stats() { + return this.track2stats; + } + findTransceiverByTrackId(trackID) { + return this.mediaTrackHandler.getTransceiverByTrackId(trackID); + } +} +exports.MediaTrackStatsHandler = MediaTrackStatsHandler;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/statsReport.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/statsReport.js new file mode 100644 index 0000000000..d020a9e7f9 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/statsReport.js @@ -0,0 +1,28 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.StatsReport = void 0; +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +let StatsReport = /*#__PURE__*/function (StatsReport) { + StatsReport["CONNECTION_STATS"] = "StatsReport.connection_stats"; + StatsReport["BYTE_SENT_STATS"] = "StatsReport.byte_sent_stats"; + StatsReport["SUMMARY_STATS"] = "StatsReport.summary_stats"; + return StatsReport; +}({}); +exports.StatsReport = StatsReport;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/statsReportEmitter.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/statsReportEmitter.js new file mode 100644 index 0000000000..c25da81743 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/statsReportEmitter.js @@ -0,0 +1,36 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.StatsReportEmitter = void 0; +var _typedEventEmitter = require("../../models/typed-event-emitter"); +var _statsReport = require("./statsReport"); +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +class StatsReportEmitter extends _typedEventEmitter.TypedEventEmitter { + emitByteSendReport(byteSentStats) { + this.emit(_statsReport.StatsReport.BYTE_SENT_STATS, byteSentStats); + } + emitConnectionStatsReport(report) { + this.emit(_statsReport.StatsReport.CONNECTION_STATS, report); + } + emitSummaryStatsReport(report) { + this.emit(_statsReport.StatsReport.SUMMARY_STATS, report); + } +} +exports.StatsReportEmitter = StatsReportEmitter;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/summaryStatsReportGatherer.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/summaryStatsReportGatherer.js new file mode 100644 index 0000000000..fb78690e64 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/summaryStatsReportGatherer.js @@ -0,0 +1,103 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.SummaryStatsReportGatherer = void 0; +/* +Copyright 2023 The Matrix.org Foundation C.I.C. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +class SummaryStatsReportGatherer { + constructor(emitter) { + this.emitter = emitter; + } + build(allSummary) { + // Filter all stats which collect the first time webrtc stats. + // Because stats based on time interval and the first collection of a summery stats has no previous + // webrtcStats as basement all the calculation are 0. We don't want track the 0 stats. + const summary = allSummary.filter(s => !s.isFirstCollection); + const summaryTotalCount = summary.length; + if (summaryTotalCount === 0) { + return; + } + const summaryCounter = { + receivedAudio: 0, + receivedVideo: 0, + receivedMedia: 0, + concealedAudio: 0, + totalAudio: 0 + }; + let maxJitter = 0; + let maxPacketLoss = 0; + summary.forEach(stats => { + this.countTrackListReceivedMedia(summaryCounter, stats); + this.countConcealedAudio(summaryCounter, stats); + maxJitter = this.buildMaxJitter(maxJitter, stats); + maxPacketLoss = this.buildMaxPacketLoss(maxPacketLoss, stats); + }); + const decimalPlaces = 5; + const report = { + percentageReceivedMedia: Number((summaryCounter.receivedMedia / summaryTotalCount).toFixed(decimalPlaces)), + percentageReceivedVideoMedia: Number((summaryCounter.receivedVideo / summaryTotalCount).toFixed(decimalPlaces)), + percentageReceivedAudioMedia: Number((summaryCounter.receivedAudio / summaryTotalCount).toFixed(decimalPlaces)), + maxJitter, + maxPacketLoss, + percentageConcealedAudio: Number(summaryCounter.totalAudio > 0 ? (summaryCounter.concealedAudio / summaryCounter.totalAudio).toFixed(decimalPlaces) : 0), + peerConnections: summaryTotalCount + }; + this.emitter.emitSummaryStatsReport(report); + } + countTrackListReceivedMedia(counter, stats) { + let hasReceivedAudio = false; + let hasReceivedVideo = false; + if (stats.receivedAudioMedia > 0 || stats.audioTrackSummary.count === 0) { + counter.receivedAudio++; + hasReceivedAudio = true; + } + if (stats.receivedVideoMedia > 0 || stats.videoTrackSummary.count === 0) { + counter.receivedVideo++; + hasReceivedVideo = true; + } else { + if (stats.videoTrackSummary.muted > 0 && stats.videoTrackSummary.muted === stats.videoTrackSummary.count) { + counter.receivedVideo++; + hasReceivedVideo = true; + } + } + if (hasReceivedVideo && hasReceivedAudio) { + counter.receivedMedia++; + } + } + buildMaxJitter(maxJitter, stats) { + if (maxJitter < stats.videoTrackSummary.maxJitter) { + maxJitter = stats.videoTrackSummary.maxJitter; + } + if (maxJitter < stats.audioTrackSummary.maxJitter) { + maxJitter = stats.audioTrackSummary.maxJitter; + } + return maxJitter; + } + buildMaxPacketLoss(maxPacketLoss, stats) { + if (maxPacketLoss < stats.videoTrackSummary.maxPacketLoss) { + maxPacketLoss = stats.videoTrackSummary.maxPacketLoss; + } + if (maxPacketLoss < stats.audioTrackSummary.maxPacketLoss) { + maxPacketLoss = stats.audioTrackSummary.maxPacketLoss; + } + return maxPacketLoss; + } + countConcealedAudio(summaryCounter, stats) { + summaryCounter.concealedAudio += stats.audioTrackSummary.concealedAudio; + summaryCounter.totalAudio += stats.audioTrackSummary.totalAudio; + } +} +exports.SummaryStatsReportGatherer = SummaryStatsReportGatherer;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/trackStatsBuilder.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/trackStatsBuilder.js new file mode 100644 index 0000000000..563a14b784 --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/trackStatsBuilder.js @@ -0,0 +1,172 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.TrackStatsBuilder = void 0; +var _valueFormatter = require("./valueFormatter"); +class TrackStatsBuilder { + static buildFramerateResolution(trackStats, now) { + const resolution = { + height: now.frameHeight, + width: now.frameWidth + }; + const frameRate = now.framesPerSecond; + if (resolution.height && resolution.width) { + trackStats.setResolution(resolution); + } + trackStats.setFramerate(Math.round(frameRate || 0)); + } + static calculateSimulcastFramerate(trackStats, now, before, layer) { + let frameRate = trackStats.getFramerate(); + if (!frameRate) { + if (before) { + const timeMs = now.timestamp - before.timestamp; + if (timeMs > 0 && now.framesSent) { + const numberOfFramesSinceBefore = now.framesSent - before.framesSent; + frameRate = numberOfFramesSinceBefore / timeMs * 1000; + } + } + if (!frameRate) { + return; + } + } + + // Reset frame rate to 0 when video is suspended as a result of endpoint falling out of last-n. + frameRate = layer ? Math.round(frameRate / layer) : 0; + trackStats.setFramerate(frameRate); + } + static buildCodec(report, trackStats, now) { + const codec = report?.get(now.codecId); + if (codec) { + /** + * The mime type has the following form: video/VP8 or audio/ISAC, + * so we what to keep just the type after the '/', audio and video + * keys will be added on the processing side. + */ + const codecShortType = codec.mimeType.split("/")[1]; + codecShortType && trackStats.setCodec(codecShortType); + } + } + static buildBitrateReceived(trackStats, now, before) { + trackStats.setBitrate({ + download: TrackStatsBuilder.calculateBitrate(now.bytesReceived, before.bytesReceived, now.timestamp, before.timestamp), + upload: 0 + }); + } + static buildBitrateSend(trackStats, now, before) { + trackStats.setBitrate({ + download: 0, + upload: this.calculateBitrate(now.bytesSent, before.bytesSent, now.timestamp, before.timestamp) + }); + } + static buildPacketsLost(trackStats, now, before) { + const key = now.type === "outbound-rtp" ? "packetsSent" : "packetsReceived"; + let packetsNow = now[key]; + if (!packetsNow || packetsNow < 0) { + packetsNow = 0; + } + const packetsBefore = _valueFormatter.ValueFormatter.getNonNegativeValue(before[key]); + const packetsDiff = Math.max(0, packetsNow - packetsBefore); + const packetsLostNow = _valueFormatter.ValueFormatter.getNonNegativeValue(now.packetsLost); + const packetsLostBefore = _valueFormatter.ValueFormatter.getNonNegativeValue(before.packetsLost); + const packetsLostDiff = Math.max(0, packetsLostNow - packetsLostBefore); + trackStats.setLoss({ + packetsTotal: packetsDiff + packetsLostDiff, + packetsLost: packetsLostDiff, + isDownloadStream: now.type !== "outbound-rtp" + }); + } + static calculateBitrate(bytesNowAny, bytesBeforeAny, nowTimestamp, beforeTimestamp) { + const bytesNow = _valueFormatter.ValueFormatter.getNonNegativeValue(bytesNowAny); + const bytesBefore = _valueFormatter.ValueFormatter.getNonNegativeValue(bytesBeforeAny); + const bytesProcessed = Math.max(0, bytesNow - bytesBefore); + const timeMs = nowTimestamp - beforeTimestamp; + let bitrateKbps = 0; + if (timeMs > 0) { + bitrateKbps = Math.round(bytesProcessed * 8 / timeMs); + } + return bitrateKbps; + } + static setTrackStatsState(trackStats, transceiver) { + if (transceiver === undefined) { + trackStats.alive = false; + return; + } + const track = trackStats.getType() === "remote" ? transceiver.receiver.track : transceiver?.sender?.track; + if (track === undefined || track === null) { + trackStats.alive = false; + return; + } + if (track.readyState === "ended") { + trackStats.alive = false; + return; + } + trackStats.muted = track.muted; + trackStats.enabled = track.enabled; + trackStats.alive = true; + } + static buildTrackSummary(trackStatsList) { + const videoTrackSummary = { + count: 0, + muted: 0, + maxJitter: 0, + maxPacketLoss: 0, + concealedAudio: 0, + totalAudio: 0 + }; + const audioTrackSummary = { + count: 0, + muted: 0, + maxJitter: 0, + maxPacketLoss: 0, + concealedAudio: 0, + totalAudio: 0 + }; + const remoteTrackList = trackStatsList.filter(t => t.getType() === "remote"); + const audioTrackList = remoteTrackList.filter(t => t.kind === "audio"); + remoteTrackList.forEach(stats => { + const trackSummary = stats.kind === "video" ? videoTrackSummary : audioTrackSummary; + trackSummary.count++; + if (stats.alive && stats.muted) { + trackSummary.muted++; + } + if (trackSummary.maxJitter < stats.getJitter()) { + trackSummary.maxJitter = stats.getJitter(); + } + if (trackSummary.maxPacketLoss < stats.getLoss().packetsLost) { + trackSummary.maxPacketLoss = stats.getLoss().packetsLost; + } + if (audioTrackList.length > 0) { + trackSummary.concealedAudio += stats.getAudioConcealment()?.concealedAudio; + trackSummary.totalAudio += stats.getAudioConcealment()?.totalAudioDuration; + } + }); + return { + audioTrackSummary, + videoTrackSummary + }; + } + static buildJitter(trackStats, statsReport) { + if (statsReport.type !== "inbound-rtp") { + return; + } + const jitterStr = statsReport?.jitter; + if (jitterStr !== undefined) { + const jitter = _valueFormatter.ValueFormatter.getNonNegativeValue(jitterStr); + trackStats.setJitter(Math.round(jitter * 1000)); + } else { + trackStats.setJitter(-1); + } + } + static buildAudioConcealment(trackStats, statsReport) { + if (statsReport.type !== "inbound-rtp") { + return; + } + const msPerSample = 1000 * statsReport?.totalSamplesDuration / statsReport?.totalSamplesReceived; + const concealedAudioDuration = msPerSample * statsReport?.concealedSamples; + const totalAudioDuration = 1000 * statsReport?.totalSamplesDuration; + trackStats.setAudioConcealment(concealedAudioDuration, totalAudioDuration); + } +} +exports.TrackStatsBuilder = TrackStatsBuilder;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/transportStats.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/transportStats.js new file mode 100644 index 0000000000..430afc16cd --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/transportStats.js @@ -0,0 +1,5 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +});
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/transportStatsBuilder.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/transportStatsBuilder.js new file mode 100644 index 0000000000..d65aa28dba --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/transportStatsBuilder.js @@ -0,0 +1,40 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.TransportStatsBuilder = void 0; +class TransportStatsBuilder { + static buildReport(report, now, conferenceStatsTransport, isFocus) { + const localUsedCandidate = report?.get(now.localCandidateId); + const remoteUsedCandidate = report?.get(now.remoteCandidateId); + + // RTCIceCandidateStats + // https://w3c.github.io/webrtc-stats/#icecandidate-dict* + if (remoteUsedCandidate && localUsedCandidate) { + const remoteIpAddress = remoteUsedCandidate.ip !== undefined ? remoteUsedCandidate.ip : remoteUsedCandidate.address; + const remotePort = remoteUsedCandidate.port; + const ip = `${remoteIpAddress}:${remotePort}`; + const localIpAddress = localUsedCandidate.ip !== undefined ? localUsedCandidate.ip : localUsedCandidate.address; + const localPort = localUsedCandidate.port; + const localIp = `${localIpAddress}:${localPort}`; + const type = remoteUsedCandidate.protocol; + + // Save the address unless it has been saved already. + if (!conferenceStatsTransport.some(t => t.ip === ip && t.type === type && t.localIp === localIp)) { + conferenceStatsTransport.push({ + ip, + type, + localIp, + isFocus, + localCandidateType: localUsedCandidate.candidateType, + remoteCandidateType: remoteUsedCandidate.candidateType, + networkType: localUsedCandidate.networkType, + rtt: now.currentRoundTripTime ? now.currentRoundTripTime * 1000 : NaN + }); + } + } + return conferenceStatsTransport; + } +} +exports.TransportStatsBuilder = TransportStatsBuilder;
\ No newline at end of file diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/valueFormatter.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/valueFormatter.js new file mode 100644 index 0000000000..17050d260e --- /dev/null +++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/valueFormatter.js @@ -0,0 +1,31 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.ValueFormatter = void 0; +/* +Copyright 2023 The Matrix.org Foundation C.I.C. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +class ValueFormatter { + static getNonNegativeValue(imput) { + let value = imput; + if (typeof value !== "number") { + value = Number(value); + } + if (isNaN(value)) { + return 0; + } + return Math.max(0, value); + } +} +exports.ValueFormatter = ValueFormatter;
\ No newline at end of file |