diff options
Diffstat (limited to 'includes/external/matrix/node_modules/matrix-js-sdk/src')
184 files changed, 0 insertions, 67460 deletions
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/IIdentityServerProvider.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/IIdentityServerProvider.ts deleted file mode 100644 index 8e30497..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/IIdentityServerProvider.ts +++ /dev/null @@ -1,24 +0,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. -*/ - -export interface IIdentityServerProvider { - /** - * Gets an access token for use against the identity server, - * for the associated client. - * @returns Promise which resolves to the access token. - */ - getAccessToken(): Promise<string | null>; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/PushRules.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/PushRules.ts deleted file mode 100644 index da3b01b..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/PushRules.ts +++ /dev/null @@ -1,209 +0,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. -*/ - -// allow camelcase as these are things that go onto the wire -/* eslint-disable camelcase */ - -export enum PushRuleActionName { - DontNotify = "dont_notify", - Notify = "notify", - Coalesce = "coalesce", -} - -export enum TweakName { - Highlight = "highlight", - Sound = "sound", -} - -export type Tweak<N extends TweakName, V> = { - set_tweak: N; - value?: V; -}; - -export type TweakHighlight = Tweak<TweakName.Highlight, boolean>; -export type TweakSound = Tweak<TweakName.Sound, string>; - -export type Tweaks = TweakHighlight | TweakSound; - -export enum ConditionOperator { - ExactEquals = "==", - LessThan = "<", - GreaterThan = ">", - GreaterThanOrEqual = ">=", - LessThanOrEqual = "<=", -} - -export type PushRuleAction = Tweaks | PushRuleActionName; - -export type MemberCountCondition<N extends number, Op extends ConditionOperator = ConditionOperator.ExactEquals> = - | `${Op}${N}` - | (Op extends ConditionOperator.ExactEquals ? `${N}` : never); - -export type AnyMemberCountCondition = MemberCountCondition<number, ConditionOperator>; - -export const DMMemberCountCondition: MemberCountCondition<2> = "2"; - -export function isDmMemberCountCondition(condition: AnyMemberCountCondition): boolean { - return condition === "==2" || condition === "2"; -} - -export enum ConditionKind { - EventMatch = "event_match", - EventPropertyIs = "event_property_is", - EventPropertyContains = "event_property_contains", - ContainsDisplayName = "contains_display_name", - RoomMemberCount = "room_member_count", - SenderNotificationPermission = "sender_notification_permission", - CallStarted = "call_started", - CallStartedPrefix = "org.matrix.msc3914.call_started", -} - -export interface IPushRuleCondition<N extends ConditionKind | string> { - [k: string]: any; // for custom conditions, there can be other fields here - kind: N; -} - -export interface IEventMatchCondition extends IPushRuleCondition<ConditionKind.EventMatch> { - key: string; - pattern?: string; - // Note that value property is an optimization for patterns which do not do - // any globbing and when the key is not "content.body". - value?: string; -} - -export interface IEventPropertyIsCondition extends IPushRuleCondition<ConditionKind.EventPropertyIs> { - key: string; - value: string | boolean | null | number; -} - -export interface IEventPropertyContainsCondition extends IPushRuleCondition<ConditionKind.EventPropertyContains> { - key: string; - value: string | boolean | null | number; -} - -export interface IContainsDisplayNameCondition extends IPushRuleCondition<ConditionKind.ContainsDisplayName> { - // no additional fields -} - -export interface IRoomMemberCountCondition extends IPushRuleCondition<ConditionKind.RoomMemberCount> { - is: AnyMemberCountCondition; -} - -export interface ISenderNotificationPermissionCondition - extends IPushRuleCondition<ConditionKind.SenderNotificationPermission> { - key: string; -} - -export interface ICallStartedCondition extends IPushRuleCondition<ConditionKind.CallStarted> { - // no additional fields -} - -export interface ICallStartedPrefixCondition extends IPushRuleCondition<ConditionKind.CallStartedPrefix> { - // no additional fields -} - -// 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. -export type PushRuleCondition = - | IEventMatchCondition - | IEventPropertyIsCondition - | IEventPropertyContainsCondition - | IContainsDisplayNameCondition - | IRoomMemberCountCondition - | ISenderNotificationPermissionCondition - | ICallStartedCondition - | ICallStartedPrefixCondition; - -export enum PushRuleKind { - Override = "override", - ContentSpecific = "content", - RoomSpecific = "room", - SenderSpecific = "sender", - Underride = "underride", -} - -export enum RuleId { - Master = ".m.rule.master", - IsUserMention = ".org.matrix.msc3952.is_user_mention", - IsRoomMention = ".org.matrix.msc3952.is_room_mention", - ContainsDisplayName = ".m.rule.contains_display_name", - ContainsUserName = ".m.rule.contains_user_name", - AtRoomNotification = ".m.rule.roomnotif", - DM = ".m.rule.room_one_to_one", - EncryptedDM = ".m.rule.encrypted_room_one_to_one", - Message = ".m.rule.message", - EncryptedMessage = ".m.rule.encrypted", - InviteToSelf = ".m.rule.invite_for_me", - MemberEvent = ".m.rule.member_event", - IncomingCall = ".m.rule.call", - SuppressNotices = ".m.rule.suppress_notices", - Tombstone = ".m.rule.tombstone", - PollStart = ".m.rule.poll_start", - PollStartUnstable = ".org.matrix.msc3930.rule.poll_start", - PollEnd = ".m.rule.poll_end", - PollEndUnstable = ".org.matrix.msc3930.rule.poll_end", - PollStartOneToOne = ".m.rule.poll_start_one_to_one", - PollStartOneToOneUnstable = ".org.matrix.msc3930.rule.poll_start_one_to_one", - PollEndOneToOne = ".m.rule.poll_end_one_to_one", - PollEndOneToOneUnstable = ".org.matrix.msc3930.rule.poll_end_one_to_one", -} - -export type PushRuleSet = { - [k in PushRuleKind]?: IPushRule[]; -}; - -export interface IPushRule { - actions: PushRuleAction[]; - conditions?: PushRuleCondition[]; - default: boolean; - enabled: boolean; - pattern?: string; - rule_id: RuleId | string; -} - -export interface IAnnotatedPushRule extends IPushRule { - kind: PushRuleKind; -} - -export interface IPushRules { - global: PushRuleSet; - device?: PushRuleSet; -} - -export interface IPusher { - "app_display_name": string; - "app_id": string; - "data": { - format?: string; - url?: string; // TODO: Required if kind==http - brand?: string; // TODO: For email notifications only? Unspecced field - }; - "device_display_name": string; - "kind": "http" | string; - "lang": string; - "profile_tag"?: string; - "pushkey": string; - "enabled"?: boolean | null; - "org.matrix.msc3881.enabled"?: boolean | null; - "device_id"?: string | null; - "org.matrix.msc3881.device_id"?: string | null; -} - -export interface IPusherRequest extends Omit<IPusher, "device_id" | "org.matrix.msc3881.device_id"> { - append?: boolean; -} - -/* eslint-enable camelcase */ diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/another-json.d.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/another-json.d.ts deleted file mode 100644 index 070332a..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/another-json.d.ts +++ /dev/null @@ -1,19 +0,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. -*/ - -declare module "another-json" { - export function stringify(o: object): string; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/auth.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/auth.ts deleted file mode 100644 index 2b8f5d7..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/auth.ts +++ /dev/null @@ -1,117 +0,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. -*/ - -import { UnstableValue } from "../NamespacedValue"; - -// disable lint because these are wire responses -/* eslint-disable camelcase */ - -/** - * Represents a response to the CSAPI `/refresh` endpoint. - */ -export interface IRefreshTokenResponse { - access_token: string; - expires_in_ms: number; - refresh_token: string; -} - -/* eslint-enable camelcase */ - -/** - * Response to GET login flows as per https://spec.matrix.org/v1.3/client-server-api/#get_matrixclientv3login - */ -export interface ILoginFlowsResponse { - flows: LoginFlow[]; -} - -export type LoginFlow = ISSOFlow | IPasswordFlow | ILoginFlow; - -export interface ILoginFlow { - type: string; -} - -export interface IPasswordFlow extends ILoginFlow { - type: "m.login.password"; -} - -export const DELEGATED_OIDC_COMPATIBILITY = new 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 - */ -export interface ISSOFlow extends ILoginFlow { - type: "m.login.sso" | "m.login.cas"; - // eslint-disable-next-line camelcase - identity_providers?: IIdentityProvider[]; - [DELEGATED_OIDC_COMPATIBILITY.name]?: boolean; - [DELEGATED_OIDC_COMPATIBILITY.altName]?: boolean; -} - -export enum IdentityProviderBrand { - Gitlab = "gitlab", - Github = "github", - Apple = "apple", - Google = "google", - Facebook = "facebook", - Twitter = "twitter", -} - -export interface IIdentityProvider { - id: string; - name: string; - icon?: string; - brand?: IdentityProviderBrand | string; -} - -/** - * Parameters to login request as per https://spec.matrix.org/v1.3/client-server-api/#login - */ -/* eslint-disable camelcase */ -export interface ILoginParams { - identifier?: object; - password?: string; - token?: string; - device_id?: string; - initial_device_display_name?: string; -} -/* eslint-enable camelcase */ - -export enum SSOAction { - /** The user intends to login to an existing account */ - LOGIN = "login", - - /** The user intends to register for a new account */ - REGISTER = "register", -} - -/** - * 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. - */ -export interface LoginTokenPostResponse { - /** - * The token to use with `m.login.token` to authenticate. - */ - login_token: string; - /** - * Expiration in seconds. - */ - expires_in: number; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/beacon.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/beacon.ts deleted file mode 100644 index e6bfb8f..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/beacon.ts +++ /dev/null @@ -1,140 +0,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. -*/ - -import { RelatesToRelationship, REFERENCE_RELATION } from "./extensible_events"; -import { UnstableValue } from "../NamespacedValue"; -import { MAssetEvent, MLocationEvent, MTimestampEvent } from "./location"; - -/** - * 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 - */ -export const M_BEACON_INFO = new UnstableValue("m.beacon_info", "org.matrix.msc3672.beacon_info"); -export const M_BEACON = new UnstableValue("m.beacon", "org.matrix.msc3672.beacon"); - -export type MBeaconInfoContent = { - description?: string; - // how long from the last event until we consider the beacon inactive in milliseconds - timeout: number; - // true when this is a live location beacon - // https://github.com/matrix-org/matrix-spec-proposals/pull/3672 - live?: boolean; -}; - -/** - * 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 - */ -export type MBeaconInfoEventContent = MBeaconInfoContent & - // creation timestamp of the beacon on the client - MTimestampEvent & - // the type of asset being tracked as per MSC3488 - MAssetEvent; - -/** - * 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 - */ -export type MBeaconEventContent = MLocationEvent & - // timestamp when location was taken - MTimestampEvent & - // relates to a beacon_info event - RelatesToRelationship<typeof REFERENCE_RELATION>; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/crypto.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/crypto.ts deleted file mode 100644 index 7711840..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/crypto.ts +++ /dev/null @@ -1,99 +0,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. -*/ - -import type { IClearEvent } from "../models/event"; -import type { ISignatures } from "./signed"; - -export type OlmGroupSessionExtraData = { - untrusted?: boolean; - sharedHistory?: boolean; -}; - -/** - * The result of a (successful) call to {@link Crypto.decryptEvent} - */ -export interface IEventDecryptionResult { - /** - * The plaintext payload for the event (typically containing <tt>type</tt> and <tt>content</tt> fields). - */ - clearEvent: IClearEvent; - /** - * List of curve25519 keys involved in telling us about the senderCurve25519Key and claimedEd25519Key. - * See {@link MatrixEvent#getForwardingCurve25519KeyChain}. - */ - forwardingCurve25519KeyChain?: string[]; - /** - * Key owned by the sender of this event. See {@link MatrixEvent#getSenderKey}. - */ - senderCurve25519Key?: string; - /** - * ed25519 key claimed by the sender of this event. See {@link MatrixEvent#getClaimedEd25519Key}. - */ - claimedEd25519Key?: string; - untrusted?: boolean; - /** - * The sender doesn't authorize the unverified devices to decrypt his messages - */ - encryptedDisabledForUnverifiedDevices?: boolean; -} - -interface Extensible { - [key: string]: any; -} - -/* eslint-disable camelcase */ - -/** The result of a call to {@link MatrixClient.exportRoomKeys} */ -export interface IMegolmSessionData extends Extensible { - /** Sender's Curve25519 device key */ - sender_key: string; - /** Devices which forwarded this session to us (normally empty). */ - forwarding_curve25519_key_chain: string[]; - /** Other keys the sender claims. */ - sender_claimed_keys: Record<string, string>; - /** Room this session is used in */ - room_id: string; - /** Unique id for the session */ - session_id: string; - /** Base64'ed key data */ - session_key: string; - algorithm?: string; - untrusted?: boolean; -} - -/* eslint-enable camelcase */ - -/** the type of the `device_keys` parameter on `/_matrix/client/v3/keys/upload` - * - * @see https://spec.matrix.org/v1.5/client-server-api/#post_matrixclientv3keysupload - */ -export interface IDeviceKeys { - algorithms: Array<string>; - device_id: string; // eslint-disable-line camelcase - user_id: string; // eslint-disable-line camelcase - keys: Record<string, string>; - signatures?: ISignatures; -} - -/** the type of the `one_time_keys` and `fallback_keys` parameters on `/_matrix/client/v3/keys/upload` - * - * @see https://spec.matrix.org/v1.5/client-server-api/#post_matrixclientv3keysupload - */ -export interface IOneTimeKey { - key: string; - fallback?: boolean; - signatures?: ISignatures; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/event.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/event.ts deleted file mode 100644 index 17af8df..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/event.ts +++ /dev/null @@ -1,251 +0,0 @@ -/* -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { UnstableValue } from "../NamespacedValue"; - -export enum EventType { - // Room state events - RoomCanonicalAlias = "m.room.canonical_alias", - RoomCreate = "m.room.create", - RoomJoinRules = "m.room.join_rules", - RoomMember = "m.room.member", - RoomThirdPartyInvite = "m.room.third_party_invite", - RoomPowerLevels = "m.room.power_levels", - RoomName = "m.room.name", - RoomTopic = "m.room.topic", - RoomAvatar = "m.room.avatar", - RoomPinnedEvents = "m.room.pinned_events", - RoomEncryption = "m.room.encryption", - RoomHistoryVisibility = "m.room.history_visibility", - RoomGuestAccess = "m.room.guest_access", - RoomServerAcl = "m.room.server_acl", - RoomTombstone = "m.room.tombstone", - RoomPredecessor = "org.matrix.msc3946.room_predecessor", - - SpaceChild = "m.space.child", - SpaceParent = "m.space.parent", - - // Room timeline events - RoomRedaction = "m.room.redaction", - RoomMessage = "m.room.message", - RoomMessageEncrypted = "m.room.encrypted", - Sticker = "m.sticker", - CallInvite = "m.call.invite", - CallCandidates = "m.call.candidates", - CallAnswer = "m.call.answer", - CallHangup = "m.call.hangup", - CallReject = "m.call.reject", - CallSelectAnswer = "m.call.select_answer", - CallNegotiate = "m.call.negotiate", - CallSDPStreamMetadataChanged = "m.call.sdp_stream_metadata_changed", - CallSDPStreamMetadataChangedPrefix = "org.matrix.call.sdp_stream_metadata_changed", - CallReplaces = "m.call.replaces", - CallAssertedIdentity = "m.call.asserted_identity", - CallAssertedIdentityPrefix = "org.matrix.call.asserted_identity", - KeyVerificationRequest = "m.key.verification.request", - KeyVerificationStart = "m.key.verification.start", - KeyVerificationCancel = "m.key.verification.cancel", - KeyVerificationMac = "m.key.verification.mac", - KeyVerificationDone = "m.key.verification.done", - KeyVerificationKey = "m.key.verification.key", - KeyVerificationAccept = "m.key.verification.accept", - // Not used directly - see READY_TYPE in VerificationRequest. - KeyVerificationReady = "m.key.verification.ready", - // use of this is discouraged https://matrix.org/docs/spec/client_server/r0.6.1#m-room-message-feedback - RoomMessageFeedback = "m.room.message.feedback", - Reaction = "m.reaction", - PollStart = "org.matrix.msc3381.poll.start", - - // Room ephemeral events - Typing = "m.typing", - Receipt = "m.receipt", - Presence = "m.presence", - - // Room account_data events - FullyRead = "m.fully_read", - Tag = "m.tag", - SpaceOrder = "org.matrix.msc3230.space_order", // MSC3230 - - // User account_data events - PushRules = "m.push_rules", - Direct = "m.direct", - IgnoredUserList = "m.ignored_user_list", - - // to_device events - RoomKey = "m.room_key", - RoomKeyRequest = "m.room_key_request", - ForwardedRoomKey = "m.forwarded_room_key", - Dummy = "m.dummy", - - // Group call events - GroupCallPrefix = "org.matrix.msc3401.call", - GroupCallMemberPrefix = "org.matrix.msc3401.call.member", -} - -export enum RelationType { - Annotation = "m.annotation", - Replace = "m.replace", - Reference = "m.reference", - Thread = "m.thread", -} - -export enum MsgType { - Text = "m.text", - Emote = "m.emote", - Notice = "m.notice", - Image = "m.image", - File = "m.file", - Audio = "m.audio", - Location = "m.location", - Video = "m.video", - KeyVerificationRequest = "m.key.verification.request", -} - -export const RoomCreateTypeField = "type"; - -export enum RoomType { - Space = "m.space", - UnstableCall = "org.matrix.msc3417.call", - ElementVideo = "io.element.video", -} - -export 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. - */ -export const UNSTABLE_MSC3088_PURPOSE = new 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. - */ -export const UNSTABLE_MSC3088_ENABLED = new 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. - */ -export const UNSTABLE_MSC3089_TREE_SUBTYPE = new 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. - */ -export const UNSTABLE_MSC3089_LEAF = new 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. - */ -export const UNSTABLE_MSC3089_BRANCH = new 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. - */ -export const UNSTABLE_MSC2716_MARKER = new 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} - */ -export const MSC3912_RELATION_BASED_REDACTIONS_PROP = new 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" - * ] - * } - * ``` - */ -export const UNSTABLE_ELEMENT_FUNCTIONAL_USERS = new 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 - */ -export const EVENT_VISIBILITY_CHANGE_TYPE = new UnstableValue("m.visibility", "org.matrix.msc3531.visibility"); - -/** - * https://github.com/matrix-org/matrix-doc/pull/3881 - * - * @experimental - */ -export const PUSHER_ENABLED = new UnstableValue("enabled", "org.matrix.msc3881.enabled"); - -/** - * https://github.com/matrix-org/matrix-doc/pull/3881 - * - * @experimental - */ -export const PUSHER_DEVICE_ID = new UnstableValue("device_id", "org.matrix.msc3881.device_id"); - -/** - * https://github.com/matrix-org/matrix-doc/pull/3890 - * - * @experimental - */ -export const LOCAL_NOTIFICATION_SETTINGS_PREFIX = new UnstableValue( - "m.local_notification_settings", - "org.matrix.msc3890.local_notification_settings", -); - -export interface IEncryptedFile { - url: string; - mimetype?: string; - key: { - alg: string; - key_ops: string[]; // eslint-disable-line camelcase - kty: string; - k: string; - ext: boolean; - }; - iv: string; - hashes: { [alg: string]: string }; - v: string; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/extensible_events.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/extensible_events.ts deleted file mode 100644 index db9ea18..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/extensible_events.ts +++ /dev/null @@ -1,151 +0,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. -*/ - -import { EitherAnd, NamespacedValue, Optional, UnstableValue } from "matrix-events-sdk"; - -import { isProvided } from "../extensible_events_v1/utilities"; - -// Types and utilities for MSC1767: Extensible events (version 1) in Matrix - -/** - * Represents the stable and unstable values of a given namespace. - */ -export type TSNamespace<N> = N extends NamespacedValue<infer S, infer U> - ? TSNamespaceValue<S> | TSNamespaceValue<U> - : never; - -/** - * 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). - */ -export type TSNamespaceValue<V> = V extends string ? V : never; - -/** - * 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. -export type DefaultNever<T, V> = [T] extends [never] ? V : T; - -/** - * The namespaced value for m.message - */ -export const M_MESSAGE = new UnstableValue("m.message", "org.matrix.msc1767.message"); - -/** - * An m.message event rendering - */ -export interface IMessageRendering { - body: string; - mimetype?: string; -} - -/** - * The content for an m.message event - */ -export type ExtensibleMessageEventContent = EitherAnd< - { [M_MESSAGE.name]: IMessageRendering[] }, - { [M_MESSAGE.altName]: IMessageRendering[] } ->; - -/** - * The namespaced value for m.text - */ -export const M_TEXT = new UnstableValue("m.text", "org.matrix.msc1767.text"); - -/** - * The content for an m.text event - */ -export type TextEventContent = EitherAnd<{ [M_TEXT.name]: string }, { [M_TEXT.altName]: string }>; - -/** - * The namespaced value for m.html - */ -export const M_HTML = new UnstableValue("m.html", "org.matrix.msc1767.html"); - -/** - * The content for an m.html event - */ -export type HtmlEventContent = EitherAnd<{ [M_HTML.name]: string }, { [M_HTML.altName]: string }>; - -/** - * The content for an m.message, m.text, or m.html event - */ -export type ExtensibleAnyMessageEventContent = ExtensibleMessageEventContent | TextEventContent | HtmlEventContent; - -/** - * The namespaced value for an m.reference relation - */ -export const REFERENCE_RELATION = new NamespacedValue("m.reference"); - -/** - * Represents any relation type - */ -export type AnyRelation = TSNamespace<typeof REFERENCE_RELATION> | string; - -/** - * An m.relates_to relationship - */ -export type RelatesToRelationship<R = never, C = never> = { - "m.relates_to": { - // See https://github.com/microsoft/TypeScript/issues/23182#issuecomment-379091887 for array syntax - rel_type: [R] extends [never] ? AnyRelation : TSNamespace<R>; - event_id: string; - } & DefaultNever<C, {}>; -}; - -/** - * Partial types for a Matrix Event. - */ -export interface IPartialEvent<TContent> { - type: string; - content: TContent; -} - -/** - * Represents a potentially namespaced event type. - */ -export type ExtensibleEventType = NamespacedValue<string, string> | string; - -/** - * 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. - */ -export function isEventTypeSame( - given: Optional<ExtensibleEventType>, - expected: Optional<ExtensibleEventType>, -): boolean { - if (typeof given === "string") { - if (typeof expected === "string") { - return expected === given; - } else { - return (expected as NamespacedValue<string, string>).matches(given as string); - } - } else { - if (typeof expected === "string") { - return (given as NamespacedValue<string, string>).matches(expected as string); - } else { - const expectedNs = expected as NamespacedValue<string, string>; - const givenNs = given as NamespacedValue<string, string>; - return ( - expectedNs.matches(givenNs.name) || - (isProvided(givenNs.altName) && expectedNs.matches(givenNs.altName!)) - ); - } - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/global.d.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/global.d.ts deleted file mode 100644 index 749eb7f..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/global.d.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -// this is needed to tell TS about global.Olm -import "@matrix-org/olm"; - -export {}; - -declare global { - // use `number` as the return type in all cases for global.set{Interval,Timeout}, - // so we don't accidentally use the methods on NodeJS.Timeout - they only exist in a subset of environments. - // The overload for clear{Interval,Timeout} is resolved as expected. - // We use `ReturnType<typeof setTimeout>` in the code to be agnostic of if this definition gets loaded. - function setInterval(handler: TimerHandler, timeout: number, ...arguments: any[]): number; - function setTimeout(handler: TimerHandler, timeout: number, ...arguments: any[]): number; - - namespace NodeJS { - interface Global { - localStorage: Storage; - // marker variable used to detect both the browser & node entrypoints being used at once - __js_sdk_entrypoint: unknown; - } - } - - interface Window { - webkitAudioContext: typeof AudioContext; - } - - interface Crypto { - webkitSubtle?: Window["crypto"]["subtle"]; - } - - interface MediaDevices { - // This is experimental and types don't know about it yet - // https://github.com/microsoft/TypeScript/issues/33232 - getDisplayMedia(constraints: MediaStreamConstraints | DesktopCapturerConstraints): Promise<MediaStream>; - getUserMedia(constraints: MediaStreamConstraints | DesktopCapturerConstraints): Promise<MediaStream>; - } - - interface DesktopCapturerConstraints { - audio: - | boolean - | { - mandatory: { - chromeMediaSource: string; - chromeMediaSourceId: string; - }; - }; - video: - | boolean - | { - mandatory: { - chromeMediaSource: string; - chromeMediaSourceId: string; - }; - }; - } - - interface DummyInterfaceWeShouldntBeUsingThis {} - - interface Navigator { - // We check for the webkit-prefixed getUserMedia to detect if we're - // on webkit: we should check if we still need to do this - webkitGetUserMedia: DummyInterfaceWeShouldntBeUsingThis; - } - - export interface ISettledFulfilled<T> { - status: "fulfilled"; - value: T; - } - export interface ISettledRejected { - status: "rejected"; - reason: any; - } - - interface PromiseConstructor { - allSettled<T>(promises: Promise<T>[]): Promise<Array<ISettledFulfilled<T> | ISettledRejected>>; - } - - interface RTCRtpTransceiver { - // This has been removed from TS - // (https://github.com/microsoft/TypeScript-DOM-lib-generator/issues/1029), - // but we still need this for MatrixCall::getRidOfRTXCodecs() - setCodecPreferences(codecs: RTCRtpCodecCapability[]): void; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/local_notifications.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/local_notifications.ts deleted file mode 100644 index b92d986..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/local_notifications.ts +++ /dev/null @@ -1,19 +0,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. -*/ - -export interface LocalNotificationSettings { - is_silenced: boolean; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/location.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/location.ts deleted file mode 100644 index d1a826f..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/location.ts +++ /dev/null @@ -1,92 +0,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 for MSC3488 - m.location: Extending events with location data -import { EitherAnd } from "matrix-events-sdk"; - -import { UnstableValue } from "../NamespacedValue"; -import { M_TEXT } from "./extensible_events"; - -export enum LocationAssetType { - Self = "m.self", - Pin = "m.pin", -} - -export const M_ASSET = new UnstableValue("m.asset", "org.matrix.msc3488.asset"); -export type MAssetContent = { type: LocationAssetType }; -/** - * The event definition for an m.asset event (in content) - */ -export type MAssetEvent = EitherAnd<{ [M_ASSET.name]: MAssetContent }, { [M_ASSET.altName]: MAssetContent }>; - -export const M_TIMESTAMP = new UnstableValue("m.ts", "org.matrix.msc3488.ts"); -/** - * The event definition for an m.ts event (in content) - */ -export type MTimestampEvent = EitherAnd<{ [M_TIMESTAMP.name]: number }, { [M_TIMESTAMP.altName]: number }>; - -export const M_LOCATION = new UnstableValue("m.location", "org.matrix.msc3488.location"); - -export type MLocationContent = { - uri: string; - description?: string | null; -}; - -export type MLocationEvent = EitherAnd< - { [M_LOCATION.name]: MLocationContent }, - { [M_LOCATION.altName]: MLocationContent } ->; - -export type MTextEvent = EitherAnd<{ [M_TEXT.name]: string }, { [M_TEXT.altName]: string }>; - -/* 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, - } -} -*/ -type OptionalTimestampEvent = MTimestampEvent | undefined; -/** - * The content for an m.location event - */ -export type MLocationEventContent = MLocationEvent & MAssetEvent & MTextEvent & OptionalTimestampEvent; - -export type LegacyLocationEventContent = { - body: string; - msgtype: string; - geo_uri: string; -}; - -/** - * Possible content for location events as sent over the wire - */ -export type LocationEventWireContent = Partial<LegacyLocationEventContent & MLocationEventContent>; - -export type ILocationContent = MLocationEventContent & LegacyLocationEventContent; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/partials.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/partials.ts deleted file mode 100644 index 49f92f3..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/partials.ts +++ /dev/null @@ -1,89 +0,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. -*/ - -export interface IImageInfo { - size?: number; - mimetype?: string; - thumbnail_info?: { - // eslint-disable-line camelcase - w?: number; - h?: number; - size?: number; - mimetype?: string; - }; - w?: number; - h?: number; -} - -export enum Visibility { - Public = "public", - Private = "private", -} - -export enum Preset { - PrivateChat = "private_chat", - TrustedPrivateChat = "trusted_private_chat", - PublicChat = "public_chat", -} - -export type ResizeMethod = "crop" | "scale"; - -export type IdServerUnbindResult = "no-support" | "success"; - -// Knock and private are reserved keywords which are not yet implemented. -export enum JoinRule { - Public = "public", - Invite = "invite", - /** - * @deprecated Reserved keyword. Should not be used. Not yet implemented. - */ - Private = "private", - Knock = "knock", - Restricted = "restricted", -} - -export enum RestrictedAllowType { - RoomMembership = "m.room_membership", -} - -export interface IJoinRuleEventContent { - join_rule: JoinRule; // eslint-disable-line camelcase - allow?: { - type: RestrictedAllowType; - room_id: string; // eslint-disable-line camelcase - }[]; -} - -export enum GuestAccess { - CanJoin = "can_join", - Forbidden = "forbidden", -} - -export enum HistoryVisibility { - Invited = "invited", - Joined = "joined", - Shared = "shared", - WorldReadable = "world_readable", -} - -export interface IUsageLimit { - // "hs_disabled" is NOT a specced string, but is used in Synapse - // This is tracked over at https://github.com/matrix-org/synapse/issues/9237 - // eslint-disable-next-line camelcase - limit_type: "monthly_active_user" | "hs_disabled" | string; - // eslint-disable-next-line camelcase - admin_contact?: string; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/polls.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/polls.ts deleted file mode 100644 index 3b06f93..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/polls.ts +++ /dev/null @@ -1,119 +0,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. -*/ - -import { EitherAnd, UnstableValue } from "matrix-events-sdk"; - -import { - ExtensibleAnyMessageEventContent, - REFERENCE_RELATION, - RelatesToRelationship, - TSNamespace, -} from "./extensible_events"; - -/** - * Identifier for a disclosed poll. - */ -export const M_POLL_KIND_DISCLOSED = new UnstableValue("m.poll.disclosed", "org.matrix.msc3381.poll.disclosed"); - -/** - * Identifier for an undisclosed poll. - */ -export const M_POLL_KIND_UNDISCLOSED = new UnstableValue("m.poll.undisclosed", "org.matrix.msc3381.poll.undisclosed"); - -/** - * Any poll kind. - */ -export type PollKind = TSNamespace<typeof M_POLL_KIND_DISCLOSED> | TSNamespace<typeof M_POLL_KIND_UNDISCLOSED> | string; - -/** - * Known poll kind namespaces. - */ -export type KnownPollKind = typeof M_POLL_KIND_DISCLOSED | typeof M_POLL_KIND_UNDISCLOSED; - -/** - * The namespaced value for m.poll.start - */ -export const M_POLL_START = new UnstableValue("m.poll.start", "org.matrix.msc3381.poll.start"); - -/** - * The m.poll.start type within event content - */ -export type PollStartSubtype = { - question: ExtensibleAnyMessageEventContent; - kind: PollKind; - max_selections?: number; // default 1, always positive - answers: PollAnswer[]; -}; - -/** - * A poll answer. - */ -export type PollAnswer = ExtensibleAnyMessageEventContent & { id: string }; - -/** - * The event definition for an m.poll.start event (in content) - */ -export type PollStartEvent = EitherAnd< - { [M_POLL_START.name]: PollStartSubtype }, - { [M_POLL_START.altName]: PollStartSubtype } ->; - -/** - * The content for an m.poll.start event - */ -export type PollStartEventContent = PollStartEvent & ExtensibleAnyMessageEventContent; - -/** - * The namespaced value for m.poll.response - */ -export const M_POLL_RESPONSE = new UnstableValue("m.poll.response", "org.matrix.msc3381.poll.response"); - -/** - * The m.poll.response type within event content - */ -export type PollResponseSubtype = { - answers: string[]; -}; - -/** - * The event definition for an m.poll.response event (in content) - */ -export type PollResponseEvent = EitherAnd< - { [M_POLL_RESPONSE.name]: PollResponseSubtype }, - { [M_POLL_RESPONSE.altName]: PollResponseSubtype } ->; - -/** - * The content for an m.poll.response event - */ -export type PollResponseEventContent = PollResponseEvent & RelatesToRelationship<typeof REFERENCE_RELATION>; - -/** - * The namespaced value for m.poll.end - */ -export const M_POLL_END = new UnstableValue("m.poll.end", "org.matrix.msc3381.poll.end"); - -/** - * The event definition for an m.poll.end event (in content) - */ -export type PollEndEvent = EitherAnd<{ [M_POLL_END.name]: {} }, { [M_POLL_END.altName]: {} }>; - -/** - * The content for an m.poll.end event - */ -export type PollEndEventContent = PollEndEvent & - RelatesToRelationship<typeof REFERENCE_RELATION> & - ExtensibleAnyMessageEventContent; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/read_receipts.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/read_receipts.ts deleted file mode 100644 index 7592403..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/read_receipts.ts +++ /dev/null @@ -1,61 +0,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. -*/ - -export enum ReceiptType { - Read = "m.read", - FullyRead = "m.fully_read", - ReadPrivate = "m.read.private", -} - -export const MAIN_ROOM_TIMELINE = "main"; - -export interface Receipt { - ts: number; - thread_id?: string; -} - -export interface WrappedReceipt { - eventId: string; - data: Receipt; -} - -export interface CachedReceipt { - type: ReceiptType; - userId: string; - data: Receipt; -} - -export type ReceiptCache = Map<string, CachedReceipt[]>; - -export interface ReceiptContent { - [eventId: string]: { - [key in ReceiptType | string]: { - [userId: string]: Receipt; - }; - }; -} - -// 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 -export type Receipts = Map<string, Map<string, [real: WrappedReceipt | null, synthetic: WrappedReceipt | null]>>; - -export type CachedReceiptStructure = { - eventId: string; - receiptType: string | ReceiptType; - userId: string; - receipt: Receipt; - synthetic: boolean; -}; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/requests.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/requests.ts deleted file mode 100644 index 12f4d8e..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/requests.ts +++ /dev/null @@ -1,243 +0,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. -*/ - -import { IContent, IEvent } from "../models/event"; -import { Preset, Visibility } from "./partials"; -import { IEventWithRoomId, SearchKey } from "./search"; -import { IRoomEventFilter } from "../filter"; -import { Direction } from "../models/event-timeline"; -import { PushRuleAction } from "./PushRules"; -import { IRoomEvent } from "../sync-accumulator"; -import { EventType, RelationType, RoomType } from "./event"; - -// allow camelcase as these are things that go onto the wire -/* eslint-disable camelcase */ - -export interface IJoinRoomOpts { - /** - * True to do a room initial sync on the resulting - * room. If false, the <strong>returned Room object will have no current state. - * </strong> Default: true. - */ - syncRoom?: boolean; - - /** - * If the caller has a keypair 3pid invite, the signing URL is passed in this parameter. - */ - inviteSignUrl?: string; - - /** - * The server names to try and join through in addition to those that are automatically chosen. - */ - viaServers?: string[]; -} - -export interface IRedactOpts { - reason?: string; - /** - * Whether events related to the redacted event should be redacted. - * - * If specified, then any events which relate to the event being redacted with - * any of the relationship types listed will also be redacted. - * - * <b>Raises an Error if the server does not support it.</b> - * Check for server-side support before using this param with - * <code>client.canSupport.get(Feature.RelationBasedRedactions)</code>. - * {@link https://github.com/matrix-org/matrix-spec-proposals/pull/3912} - */ - with_relations?: Array<RelationType | string>; -} - -export interface ISendEventResponse { - event_id: string; -} - -export interface IPresenceOpts { - // One of "online", "offline" or "unavailable" - presence: "online" | "offline" | "unavailable"; - // The status message to attach. - status_msg?: string; -} - -export interface IPaginateOpts { - // true to fill backwards, false to go forwards - backwards?: boolean; - // number of events to request - limit?: number; -} - -export interface IGuestAccessOpts { - /** - * True to allow guests to join this room. This - * implicitly gives guests write access. If false or not given, guests are - * explicitly forbidden from joining the room. - */ - allowJoin: boolean; - /** - * True to set history visibility to - * be world_readable. This gives guests read access *from this point forward*. - * If false or not given, history visibility is not modified. - */ - allowRead: boolean; -} - -export interface ISearchOpts { - keys?: SearchKey[]; - query: string; -} - -export interface IEventSearchOpts { - // a JSON filter object to pass in the request - filter?: IRoomEventFilter; - // the term to search for - term: string; -} - -export interface IInvite3PID { - id_server: string; - id_access_token?: string; // this gets injected by the js-sdk - medium: string; - address: string; -} - -export interface ICreateRoomStateEvent { - type: string; - state_key?: string; // defaults to an empty string - content: IContent; -} - -export interface ICreateRoomOpts { - // The alias localpart to assign to this room. - room_alias_name?: string; - // Either 'public' or 'private'. - visibility?: Visibility; - // The name to give this room. - name?: string; - // The topic to give this room. - topic?: string; - preset?: Preset; - power_level_content_override?: { - ban?: number; - events?: Record<EventType | string, number>; - events_default?: number; - invite?: number; - kick?: number; - notifications?: Record<string, number>; - redact?: number; - state_default?: number; - users?: Record<string, number>; - users_default?: number; - }; - creation_content?: object; - initial_state?: ICreateRoomStateEvent[]; - // A list of user IDs to invite to this room. - invite?: string[]; - invite_3pid?: IInvite3PID[]; - is_direct?: boolean; - room_version?: string; -} - -export interface IRoomDirectoryOptions { - server?: string; - limit?: number; - since?: string; - - // Filter parameters - filter?: { - // String to search for - generic_search_term?: string; - room_types?: Array<RoomType | null>; - }; - include_all_networks?: boolean; - third_party_instance_id?: string; -} - -export interface IAddThreePidOnlyBody { - auth?: { - type: string; - session?: string; - }; - client_secret: string; - sid: string; -} - -export interface IBindThreePidBody { - client_secret: string; - id_server: string; - id_access_token: string; - sid: string; -} - -export interface IRelationsRequestOpts { - from?: string; - to?: string; - limit?: number; - dir?: Direction; -} - -export interface IRelationsResponse { - chunk: IEvent[]; - next_batch?: string; - prev_batch?: string; -} - -export interface IContextResponse { - end: string; - start: string; - state: IEventWithRoomId[]; - events_before: IEventWithRoomId[]; - events_after: IEventWithRoomId[]; - event: IEventWithRoomId; -} - -export interface IEventsResponse { - chunk: IEventWithRoomId[]; - end: string; - start: string; -} - -export interface INotification { - actions: PushRuleAction[]; - event: IRoomEvent; - profile_tag?: string; - read: boolean; - room_id: string; - ts: number; -} - -export interface INotificationsResponse { - next_token: string; - notifications: INotification[]; -} - -export interface IFilterResponse { - filter_id: string; -} - -export interface ITagsResponse { - tags: { - [tagId: string]: { - order: number; - }; - }; -} - -export interface IStatusResponse extends IPresenceOpts { - currently_active?: boolean; - last_active_ago?: number; -} - -/* eslint-enable camelcase */ diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/search.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/search.ts deleted file mode 100644 index 3a6d4fd..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/search.ts +++ /dev/null @@ -1,119 +0,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 - -import { IRoomEvent, IStateEvent } from "../sync-accumulator"; -import { IRoomEventFilter } from "../filter"; -import { SearchResult } from "../models/search-result"; - -/* eslint-disable camelcase */ -export interface IEventWithRoomId extends IRoomEvent { - room_id: string; -} - -export interface IStateEventWithRoomId extends IStateEvent { - room_id: string; -} - -export interface IMatrixProfile { - avatar_url?: string; - displayname?: string; -} - -export interface IResultContext { - events_before: IEventWithRoomId[]; - events_after: IEventWithRoomId[]; - profile_info: Record<string, IMatrixProfile>; - start?: string; - end?: string; -} - -export interface ISearchResult { - rank: number; - result: IEventWithRoomId; - context: IResultContext; -} - -enum GroupKey { - RoomId = "room_id", - Sender = "sender", -} - -export interface IResultRoomEvents { - count: number; - highlights: string[]; - results: ISearchResult[]; - state?: { [roomId: string]: IStateEventWithRoomId[] }; - groups?: { - [groupKey in GroupKey]: { - [value: string]: { - next_batch?: string; - order: number; - results: string[]; - }; - }; - }; - next_batch?: string; -} - -interface IResultCategories { - room_events: IResultRoomEvents; -} - -export type SearchKey = "content.body" | "content.name" | "content.topic"; - -export enum SearchOrderBy { - Recent = "recent", - Rank = "rank", -} - -export interface ISearchRequestBody { - search_categories: { - room_events: { - search_term: string; - keys?: SearchKey[]; - filter?: IRoomEventFilter; - order_by?: SearchOrderBy; - event_context?: { - before_limit?: number; - after_limit?: number; - include_profile?: boolean; - }; - include_state?: boolean; - groupings?: { - group_by: { - key: GroupKey; - }[]; - }; - }; - }; -} - -export interface ISearchResponse { - search_categories: IResultCategories; -} - -export interface ISearchResults { - _query?: ISearchRequestBody; - results: SearchResult[]; - highlights: string[]; - count?: number; - next_batch?: string; - pendingRequest?: Promise<ISearchResults>; - abortSignal?: AbortSignal; -} -/* eslint-enable camelcase */ diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/signed.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/signed.ts deleted file mode 100644 index a209f37..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/signed.ts +++ /dev/null @@ -1,25 +0,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. -*/ - -export interface ISignatures { - [entity: string]: { - [keyId: string]: string; - }; -} - -export interface ISigned { - signatures?: ISignatures; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/spaces.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/spaces.ts deleted file mode 100644 index 9edab27..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/spaces.ts +++ /dev/null @@ -1,37 +0,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. -*/ - -import { IPublicRoomsChunkRoom } from "../client"; -import { RoomType } from "./event"; -import { IStrippedState } from "../sync-accumulator"; - -// Types relating to Rooms of type `m.space` and related APIs - -/* eslint-disable camelcase */ -export interface IHierarchyRelation extends IStrippedState { - origin_server_ts: number; - content: { - order?: string; - suggested?: boolean; - via?: string[]; - }; -} - -export interface IHierarchyRoom extends IPublicRoomsChunkRoom { - room_type?: RoomType | string; - children_state: IHierarchyRelation[]; -} -/* eslint-enable camelcase */ diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/synapse.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/synapse.ts deleted file mode 100644 index 1d4ce41..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/synapse.ts +++ /dev/null @@ -1,40 +0,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. -*/ - -import { IdServerUnbindResult } from "./partials"; - -// Types relating to Synapse Admin APIs - -/* eslint-disable camelcase */ -export interface ISynapseAdminWhoisResponse { - user_id: string; - devices: { - [deviceId: string]: { - sessions: { - connections: { - ip: string; - last_seen: number; // millis since epoch - user_agent: string; - }[]; - }[]; - }; - }; -} - -export interface ISynapseAdminDeactivateResponse { - id_server_unbind_result: IdServerUnbindResult; -} -/* eslint-enable camelcase */ diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/sync.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/sync.ts deleted file mode 100644 index d9a2a6f..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/sync.ts +++ /dev/null @@ -1,27 +0,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. -*/ - -import { ServerControlledNamespacedValue } from "../NamespacedValue"; - -/** - * https://github.com/matrix-org/matrix-doc/pull/3773 - * - * @experimental - */ -export const UNREAD_THREAD_NOTIFICATIONS = new ServerControlledNamespacedValue( - "unread_thread_notifications", - "org.matrix.msc3773.unread_thread_notifications", -); diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/threepids.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/threepids.ts deleted file mode 100644 index c28ffc3..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/threepids.ts +++ /dev/null @@ -1,29 +0,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. -*/ - -export enum ThreepidMedium { - Email = "email", - Phone = "msisdn", -} - -// TODO: Are these types universal, or specific to just /account/3pid? -export interface IThreepid { - medium: ThreepidMedium; - address: string; - validated_at: number; // eslint-disable-line camelcase - added_at: number; // eslint-disable-line camelcase - bound?: boolean; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/topic.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/topic.ts deleted file mode 100644 index 04d1464..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/topic.ts +++ /dev/null @@ -1,63 +0,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. -*/ - -import { EitherAnd } from "matrix-events-sdk"; - -import { UnstableValue } from "../NamespacedValue"; -import { IMessageRendering } from "./extensible_events"; - -/** - * 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) - */ -export const M_TOPIC = new UnstableValue("m.topic", "org.matrix.msc3765.topic"); - -/** - * The event content for an m.topic event (in content) - */ -export type MTopicContent = IMessageRendering[]; - -/** - * The event definition for an m.topic event (in content) - */ -export type MTopicEvent = EitherAnd<{ [M_TOPIC.name]: MTopicContent }, { [M_TOPIC.altName]: MTopicContent }>; - -/** - * The event content for an m.room.topic event - */ -export type MRoomTopicEventContent = { topic: string } & MTopicEvent; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/uia.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/uia.ts deleted file mode 100644 index e611420..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/uia.ts +++ /dev/null @@ -1,29 +0,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. -*/ - -import { IAuthData } from "../interactive-auth"; - -/** - * Helper type to represent HTTP request body for a UIA enabled endpoint - */ -export type UIARequest<T> = T & { - auth?: IAuthData; -}; - -/** - * Helper type to represent HTTP response body for a UIA enabled endpoint - */ -export type UIAResponse<T> = T | IAuthData; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/NamespacedValue.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/NamespacedValue.ts deleted file mode 100644 index a1a7e5d..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/NamespacedValue.ts +++ /dev/null @@ -1,120 +0,0 @@ -/* -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. -*/ - -import { Optional } from "matrix-events-sdk/lib/types"; - -/** - * 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. - */ -export class NamespacedValue<S extends string, U extends string> { - // 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). - public constructor(stable: S, unstable: U); - public constructor(stable: S, unstable?: U); - public constructor(stable: null | undefined, unstable: U); - public constructor(public readonly stable?: S | null, public readonly unstable?: U) { - if (!this.unstable && !this.stable) { - throw new Error("One of stable or unstable values must be supplied"); - } - } - - public get name(): U | S { - if (this.stable) { - return this.stable; - } - return this.unstable!; - } - - public get altName(): U | S | null | undefined { - if (!this.stable) { - return null; - } - return this.unstable; - } - - public get names(): (U | S)[] { - const names = [this.name]; - const altName = this.altName; - if (altName) names.push(altName); - return names; - } - - public matches(val: string): boolean { - 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. - public findIn<T>(obj: any): Optional<T> { - let val: T | undefined = undefined; - if (this.name) { - val = obj?.[this.name]; - } - if (!val && this.altName) { - val = obj?.[this.altName]; - } - return val; - } - - public includedIn(arr: any[]): boolean { - let included = false; - if (this.name) { - included = arr.includes(this.name); - } - if (!included && this.altName) { - included = arr.includes(this.altName); - } - return included; - } -} - -export class ServerControlledNamespacedValue<S extends string, U extends string> extends NamespacedValue<S, U> { - private preferUnstable = false; - - public setPreferUnstable(preferUnstable: boolean): void { - this.preferUnstable = preferUnstable; - } - - public get name(): U | S { - if (this.stable && !this.preferUnstable) { - return this.stable; - } - return this.unstable!; - } -} - -/** - * Represents a namespaced value which prioritizes the unstable value over the stable - * value. - */ -export class UnstableValue<S extends string, U extends string> extends NamespacedValue<S, U> { - // Note: Constructor difference is that `unstable` is *required*. - public constructor(stable: S, unstable: U) { - super(stable, unstable); - if (!this.unstable) { - throw new Error("Unstable value must be supplied"); - } - } - - public get name(): U { - return this.unstable!; - } - - public get altName(): S { - return this.stable!; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/ReEmitter.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/ReEmitter.ts deleted file mode 100644 index 565e8ea..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/ReEmitter.ts +++ /dev/null @@ -1,91 +0,0 @@ -/* -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 -import { EventEmitter } from "events"; - -import { ListenerMap, TypedEventEmitter } from "./models/typed-event-emitter"; - -export class ReEmitter { - public constructor(private readonly target: EventEmitter) {} - - // Map from emitter to event name to re-emitter - private reEmitters = new Map<EventEmitter, Map<string, (...args: any[]) => void>>(); - - public reEmit(source: EventEmitter, eventNames: string[]): void { - let reEmittersByEvent = this.reEmitters.get(source); - if (!reEmittersByEvent) { - reEmittersByEvent = new Map(); - this.reEmitters.set(source, reEmittersByEvent); - } - - for (const eventName of eventNames) { - // We include the source as the last argument for event handlers which may need it, - // such as read receipt listeners on the client class which won't have the context - // of the room. - const forSource = (...args: any[]): void => { - // 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); - } - } - - public stopReEmitting(source: EventEmitter, eventNames: string[]): void { - 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); - } -} - -export class TypedReEmitter<Events extends string, Arguments extends ListenerMap<Events>> extends ReEmitter { - public constructor(target: TypedEventEmitter<Events, Arguments>) { - super(target); - } - - public reEmit<ReEmittedEvents extends string, T extends Events & ReEmittedEvents>( - source: TypedEventEmitter<ReEmittedEvents, any>, - eventNames: T[], - ): void { - super.reEmit(source, eventNames); - } - - public stopReEmitting<ReEmittedEvents extends string, T extends Events & ReEmittedEvents>( - source: TypedEventEmitter<ReEmittedEvents, any>, - eventNames: T[], - ): void { - super.stopReEmitting(source, eventNames); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/ToDeviceMessageQueue.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/ToDeviceMessageQueue.ts deleted file mode 100644 index 59eada4..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/ToDeviceMessageQueue.ts +++ /dev/null @@ -1,148 +0,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. -*/ - -import { ToDeviceMessageId } from "./@types/event"; -import { logger } from "./logger"; -import { MatrixClient, ClientEvent } from "./client"; -import { MatrixError } from "./http-api"; -import { IndexedToDeviceBatch, ToDeviceBatch, ToDeviceBatchWithTxnId, ToDevicePayload } from "./models/ToDeviceMessage"; -import { MatrixScheduler } from "./scheduler"; -import { SyncState } from "./sync"; -import { MapWithDefault } from "./utils"; - -const MAX_BATCH_SIZE = 20; - -/** - * Maintains a queue of outgoing to-device messages, sending them - * as soon as the homeserver is reachable. - */ -export class ToDeviceMessageQueue { - private sending = false; - private running = true; - private retryTimeout: ReturnType<typeof setTimeout> | null = null; - private retryAttempts = 0; - - public constructor(private client: MatrixClient) {} - - public start(): void { - this.running = true; - this.sendQueue(); - this.client.on(ClientEvent.Sync, this.onResumedSync); - } - - public stop(): void { - this.running = false; - if (this.retryTimeout !== null) clearTimeout(this.retryTimeout); - this.retryTimeout = null; - this.client.removeListener(ClientEvent.Sync, this.onResumedSync); - } - - public async queueBatch(batch: ToDeviceBatch): Promise<void> { - const batches: ToDeviceBatchWithTxnId[] = []; - 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[ToDeviceMessageId]})`, - ); - logger.info( - `Enqueuing batch of to-device messages. type=${batch.eventType} txnid=${batchWithTxnId.txnId}`, - msgmap, - ); - } - - await this.client.store.saveToDeviceBatches(batches); - this.sendQueue(); - } - - public sendQueue = async (): Promise<void> => { - if (this.retryTimeout !== null) clearTimeout(this.retryTimeout); - this.retryTimeout = null; - - if (this.sending || !this.running) return; - - logger.debug("Attempting to send queued to-device messages"); - - this.sending = true; - let headBatch: IndexedToDeviceBatch | null; - 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.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 = MatrixScheduler.RETRY_BACKOFF_RATELIMIT(null, this.retryAttempts, <MatrixError>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((<MatrixError>e).httpStatus! / 100) === 4) { - logger.error("Fatal error when sending to-device message - dropping to-device batch!", e); - await this.client.store.removeToDeviceBatch(headBatch!.id); - } else { - logger.info("Automatic retry limit reached for to-device messages."); - } - return; - } - - 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; - } - }; - - /** - * Attempts to send a batch of to-device messages. - */ - private async sendBatch(batch: IndexedToDeviceBatch): Promise<void> { - const contentMap: MapWithDefault<string, Map<string, ToDevicePayload>> = new MapWithDefault(() => new Map()); - for (const item of batch.batch) { - contentMap.getOrCreate(item.userId).set(item.deviceId, item.payload); - } - - 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); - } - - /** - * Listen to sync state changes and automatically resend any pending events - * once syncing is resumed - */ - private onResumedSync = (state: SyncState | null, oldState: SyncState | null): void => { - if (state === SyncState.Syncing && oldState !== SyncState.Syncing) { - logger.info(`Resuming queue after resumed sync`); - this.sendQueue(); - } - }; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/autodiscovery.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/autodiscovery.ts deleted file mode 100644 index f4a3415..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/autodiscovery.ts +++ /dev/null @@ -1,472 +0,0 @@ -/* -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { IClientWellKnown, IWellKnownConfig } from "./client"; -import { logger } from "./logger"; -import { MatrixError, Method, timeoutSignal } from "./http-api"; - -// Dev note: Auto discovery is part of the spec. -// See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery - -export enum AutoDiscoveryAction { - SUCCESS = "SUCCESS", - IGNORE = "IGNORE", - PROMPT = "PROMPT", - FAIL_PROMPT = "FAIL_PROMPT", - FAIL_ERROR = "FAIL_ERROR", -} - -enum AutoDiscoveryError { - Invalid = "Invalid homeserver discovery response", - GenericFailure = "Failed to get autodiscovery configuration from server", - InvalidHsBaseUrl = "Invalid base_url for m.homeserver", - InvalidHomeserver = "Homeserver URL does not appear to be a valid Matrix homeserver", - InvalidIsBaseUrl = "Invalid base_url for m.identity_server", - InvalidIdentityServer = "Identity server URL does not appear to be a valid identity server", - InvalidIs = "Invalid identity server discovery response", - MissingWellknown = "No .well-known JSON file found", - InvalidJson = "Invalid JSON", -} - -interface WellKnownConfig extends Omit<IWellKnownConfig, "error"> { - state: AutoDiscoveryAction; - error?: IWellKnownConfig["error"] | null; -} - -export interface ClientConfig extends Omit<IClientWellKnown, "m.homeserver" | "m.identity_server"> { - "m.homeserver": WellKnownConfig; - "m.identity_server": WellKnownConfig; -} - -/** - * Utilities for automatically discovery resources, such as homeservers - * for users to log in to. - */ -export class AutoDiscovery { - // Dev note: the constants defined here are related to but not - // exactly the same as those in the spec. This is to hopefully - // translate the meaning of the states in the spec, but also - // support our own if needed. - - public static readonly ERROR_INVALID = AutoDiscoveryError.Invalid; - - public static readonly ERROR_GENERIC_FAILURE = AutoDiscoveryError.GenericFailure; - - public static readonly ERROR_INVALID_HS_BASE_URL = AutoDiscoveryError.InvalidHsBaseUrl; - - public static readonly ERROR_INVALID_HOMESERVER = AutoDiscoveryError.InvalidHomeserver; - - public static readonly ERROR_INVALID_IS_BASE_URL = AutoDiscoveryError.InvalidIsBaseUrl; - - public static readonly ERROR_INVALID_IDENTITY_SERVER = AutoDiscoveryError.InvalidIdentityServer; - - public static readonly ERROR_INVALID_IS = AutoDiscoveryError.InvalidIs; - - public static readonly ERROR_MISSING_WELLKNOWN = AutoDiscoveryError.MissingWellknown; - - public static readonly ERROR_INVALID_JSON = AutoDiscoveryError.InvalidJson; - - public static readonly ALL_ERRORS = Object.keys(AutoDiscoveryError); - - /** - * The auto discovery failed. The client is expected to communicate - * the error to the user and refuse logging in. - */ - public static readonly 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. - */ - public static readonly 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. - */ - public static readonly PROMPT = AutoDiscoveryAction.PROMPT; - - /** - * The auto discovery was successful. - */ - public static readonly SUCCESS = AutoDiscoveryAction.SUCCESS; - - /** - * Validates and verifies client configuration information for purposes - * of logging in. Such information includes the homeserver URL - * and identity server URL the client would want. Additional details - * may also be included, and will be transparently brought into the - * response object unaltered. - * @param 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. - */ - public static async fromDiscoveryConfig(wellknown: IClientWellKnown): Promise<ClientConfig> { - // 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: ClientConfig = { - "m.homeserver": { - state: AutoDiscovery.FAIL_ERROR, - error: AutoDiscovery.ERROR_INVALID, - base_url: null, - }, - "m.identity_server": { - // Technically, we don't have a problem with the identity server - // config at this point. - state: AutoDiscovery.PROMPT, - error: null, - base_url: null, - }, - }; - - if (!wellknown || !wellknown["m.homeserver"]) { - logger.error("No m.homeserver key in config"); - - clientConfig["m.homeserver"].state = AutoDiscovery.FAIL_PROMPT; - clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID; - - return Promise.resolve(clientConfig); - } - - if (!wellknown["m.homeserver"]["base_url"]) { - logger.error("No m.homeserver base_url in config"); - - clientConfig["m.homeserver"].state = AutoDiscovery.FAIL_PROMPT; - clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HS_BASE_URL; - - return Promise.resolve(clientConfig); - } - - // Step 2: Make sure the homeserver URL is valid *looking*. We'll make - // sure it points to a homeserver in Step 3. - const hsUrl = this.sanitizeWellKnownUrl(wellknown["m.homeserver"]["base_url"]); - if (!hsUrl) { - logger.error("Invalid base_url for m.homeserver"); - clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HS_BASE_URL; - return Promise.resolve(clientConfig); - } - - // Step 3: Make sure the homeserver URL points to a homeserver. - const hsVersions = await this.fetchWellKnownObject(`${hsUrl}/_matrix/client/versions`); - if (!hsVersions || !hsVersions.raw?.["versions"]) { - logger.error("Invalid /versions response"); - clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HOMESERVER; - - // Supply the base_url to the caller because they may be ignoring liveliness - // errors, like this one. - clientConfig["m.homeserver"].base_url = hsUrl; - - return Promise.resolve(clientConfig); - } - - // Step 4: Now that the homeserver looks valid, update our client config. - clientConfig["m.homeserver"] = { - state: AutoDiscovery.SUCCESS, - error: null, - base_url: hsUrl, - }; - - // Step 5: Try to pull out the identity server configuration - let isUrl: string | boolean = ""; - if (wellknown["m.identity_server"]) { - // We prepare a failing identity server response to save lines later - // in this branch. - const failingClientConfig: ClientConfig = { - "m.homeserver": clientConfig["m.homeserver"], - "m.identity_server": { - state: AutoDiscovery.FAIL_PROMPT, - error: AutoDiscovery.ERROR_INVALID_IS, - base_url: null, - }, - }; - - // Step 5a: Make sure the URL is valid *looking*. We'll make sure it - // points to an identity server in Step 5b. - isUrl = this.sanitizeWellKnownUrl(wellknown["m.identity_server"]["base_url"]); - if (!isUrl) { - logger.error("Invalid base_url for m.identity_server"); - failingClientConfig["m.identity_server"].error = AutoDiscovery.ERROR_INVALID_IS_BASE_URL; - return Promise.resolve(failingClientConfig); - } - - // Step 5b: Verify there is an identity server listening on the provided - // URL. - const isResponse = await this.fetchWellKnownObject(`${isUrl}/_matrix/identity/v2`); - if (!isResponse?.raw || isResponse.action !== AutoDiscoveryAction.SUCCESS) { - 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: keyof IClientWellKnown) => { - 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; - type Prop = Exclude<keyof IWellKnownConfig, "error" | "state" | "base_url">; - // @ts-ignore - ts gets unhappy as we're mixing types here - clientConfig[k][prop as Prop] = wellknown[k]![prop as 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. - */ - public static async findClientConfig(domain: string): Promise<ClientConfig> { - 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: 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.error("No response or error when parsing .well-known"); - if (wellknown.reason) 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. - */ - public static async getRawClientConfig(domain?: string): Promise<IClientWellKnown> { - 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 - */ - private static sanitizeWellKnownUrl(url?: string | null): string | false { - if (!url) return false; - - try { - let parsed: URL | undefined; - try { - parsed = new URL(url); - } catch (e) { - 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.error(e); - return false; - } - } - - private static fetch(resource: URL | string, options?: RequestInit): ReturnType<typeof global.fetch> { - if (this.fetchFn) { - return this.fetchFn(resource, options); - } - return global.fetch(resource, options); - } - - private static fetchFn?: typeof global.fetch; - - public static setFetchFn(fetchFn: typeof global.fetch): void { - 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 - */ - private static async fetchWellKnownObject(url: string): Promise<IWellKnownConfig> { - let response: Response; - - try { - response = await AutoDiscovery.fetch(url, { - method: Method.Get, - signal: 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 as AutoDiscoveryError | string | undefined; - let reason = ""; - if (typeof error === "object") { - reason = (<Error>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 as Error; - return { - error, - raw: {}, - action: AutoDiscoveryAction.FAIL_PROMPT, - reason: - (error as MatrixError)?.name === "SyntaxError" - ? AutoDiscovery.ERROR_INVALID_JSON - : AutoDiscovery.ERROR_INVALID, - }; - } - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/browser-index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/browser-index.ts deleted file mode 100644 index 200b2a3..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/browser-index.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import * as matrixcs from "./matrix"; - -type BrowserMatrix = typeof matrixcs; -declare global { - /* eslint-disable no-var, camelcase */ - var __js_sdk_entrypoint: boolean; - var matrixcs: BrowserMatrix; - /* eslint-enable no-var */ -} - -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: IDBFactory | undefined; -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. -export * from "./matrix"; -export default matrixcs; // keep export for browserify package deps -global.matrixcs = matrixcs; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/client.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/client.ts deleted file mode 100644 index 0e47ff6..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/client.ts +++ /dev/null @@ -1,9680 +0,0 @@ -/* -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. - */ - -import { Optional } from "matrix-events-sdk"; - -import type { IDeviceKeys, IMegolmSessionData, IOneTimeKey } from "./@types/crypto"; -import { ISyncStateData, SyncApi, SyncApiOptions, SyncState } from "./sync"; -import { - EventStatus, - IContent, - IDecryptOptions, - IEvent, - MatrixEvent, - MatrixEventEvent, - MatrixEventHandlerMap, -} from "./models/event"; -import { StubStore } from "./store/stub"; -import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall, supportsMatrixCall } from "./webrtc/call"; -import { Filter, IFilterDefinition, IRoomEventFilter } from "./filter"; -import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from "./webrtc/callEventHandler"; -import { GroupCallEventHandlerEvent, GroupCallEventHandlerEventHandlerMap } from "./webrtc/groupCallEventHandler"; -import * as utils from "./utils"; -import { replaceParam, QueryDict, sleep, noUnsafeEventProps } from "./utils"; -import { Direction, EventTimeline } from "./models/event-timeline"; -import { IActionsObject, PushProcessor } from "./pushprocessor"; -import { AutoDiscovery, AutoDiscoveryAction } from "./autodiscovery"; -import * as olmlib from "./crypto/olmlib"; -import { decodeBase64, encodeBase64 } from "./crypto/olmlib"; -import { IExportedDevice as IExportedOlmDevice } from "./crypto/OlmDevice"; -import { IOlmDevice } from "./crypto/algorithms/megolm"; -import { TypedReEmitter } from "./ReEmitter"; -import { IRoomEncryption, RoomList } from "./crypto/RoomList"; -import { logger } from "./logger"; -import { SERVICE_TYPES } from "./service-types"; -import { - HttpApiEvent, - HttpApiEventHandlerMap, - Upload, - UploadOpts, - MatrixError, - MatrixHttpApi, - Method, - retryNetworkOperation, - ClientPrefix, - MediaPrefix, - IdentityPrefix, - IHttpOpts, - FileType, - UploadResponse, - HTTPError, - IRequestOpts, -} from "./http-api"; -import { - Crypto, - CryptoEvent, - CryptoEventHandlerMap, - fixBackupKey, - ICryptoCallbacks, - IBootstrapCrossSigningOpts, - ICheckOwnCrossSigningTrustOpts, - isCryptoAvailable, - VerificationMethod, - IRoomKeyRequestBody, -} from "./crypto"; -import { DeviceInfo } from "./crypto/deviceinfo"; -import { decodeRecoveryKey } from "./crypto/recoverykey"; -import { keyFromAuthData } from "./crypto/key_passphrase"; -import { User, UserEvent, UserEventHandlerMap } from "./models/user"; -import { getHttpUriForMxc } from "./content-repo"; -import { SearchResult } from "./models/search-result"; -import { DEHYDRATION_ALGORITHM, IDehydratedDevice, IDehydratedDeviceKeyInfo } from "./crypto/dehydration"; -import { - IKeyBackupInfo, - IKeyBackupPrepareOpts, - IKeyBackupRestoreOpts, - IKeyBackupRestoreResult, - IKeyBackupRoomSessions, - IKeyBackupSession, -} from "./crypto/keybackup"; -import { IIdentityServerProvider } from "./@types/IIdentityServerProvider"; -import { MatrixScheduler } from "./scheduler"; -import { BeaconEvent, BeaconEventHandlerMap } from "./models/beacon"; -import { IAuthData, IAuthDict } from "./interactive-auth"; -import { IMinimalEvent, IRoomEvent, IStateEvent } from "./sync-accumulator"; -import { - CrossSigningKey, - IAddSecretStorageKeyOpts, - ICreateSecretStorageOpts, - IEncryptedEventInfo, - IImportRoomKeysOpts, - IRecoveryKey, -} from "./crypto/api"; -import { EventTimelineSet } from "./models/event-timeline-set"; -import { VerificationRequest } from "./crypto/verification/request/VerificationRequest"; -import { VerificationBase as Verification } from "./crypto/verification/Base"; -import * as ContentHelpers from "./content-helpers"; -import { CrossSigningInfo, DeviceTrustLevel, ICacheCallbacks, UserTrustLevel } from "./crypto/CrossSigning"; -import { Room, NotificationCountType, RoomEvent, RoomEventHandlerMap, RoomNameState } from "./models/room"; -import { RoomMemberEvent, RoomMemberEventHandlerMap } from "./models/room-member"; -import { RoomStateEvent, RoomStateEventHandlerMap } from "./models/room-state"; -import { - IAddThreePidOnlyBody, - IBindThreePidBody, - IContextResponse, - ICreateRoomOpts, - IEventSearchOpts, - IGuestAccessOpts, - IJoinRoomOpts, - IPaginateOpts, - IPresenceOpts, - IRedactOpts, - IRelationsRequestOpts, - IRelationsResponse, - IRoomDirectoryOptions, - ISearchOpts, - ISendEventResponse, - INotificationsResponse, - IFilterResponse, - ITagsResponse, - IStatusResponse, -} from "./@types/requests"; -import { - EventType, - LOCAL_NOTIFICATION_SETTINGS_PREFIX, - MsgType, - PUSHER_ENABLED, - RelationType, - RoomCreateTypeField, - RoomType, - UNSTABLE_MSC3088_ENABLED, - UNSTABLE_MSC3088_PURPOSE, - UNSTABLE_MSC3089_TREE_SUBTYPE, - MSC3912_RELATION_BASED_REDACTIONS_PROP, -} from "./@types/event"; -import { IdServerUnbindResult, IImageInfo, Preset, Visibility } from "./@types/partials"; -import { EventMapper, eventMapperFor, MapperOpts } from "./event-mapper"; -import { randomString } from "./randomstring"; -import { BackupManager, IKeyBackup, IKeyBackupCheck, IPreparedKeyBackupVersion, TrustInfo } from "./crypto/backup"; -import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE, MSC3089TreeSpace } from "./models/MSC3089TreeSpace"; -import { ISignatures } from "./@types/signed"; -import { IStore } from "./store"; -import { ISecretRequest } from "./crypto/SecretStorage"; -import { - IEventWithRoomId, - ISearchRequestBody, - ISearchResponse, - ISearchResults, - IStateEventWithRoomId, - SearchOrderBy, -} from "./@types/search"; -import { ISynapseAdminDeactivateResponse, ISynapseAdminWhoisResponse } from "./@types/synapse"; -import { IHierarchyRoom } from "./@types/spaces"; -import { - IPusher, - IPusherRequest, - IPushRule, - IPushRules, - PushRuleAction, - PushRuleActionName, - PushRuleKind, - RuleId, -} from "./@types/PushRules"; -import { IThreepid } from "./@types/threepids"; -import { CryptoStore, OutgoingRoomKeyRequest } from "./crypto/store/base"; -import { GroupCall, IGroupCallDataChannelOptions, GroupCallIntent, GroupCallType } from "./webrtc/groupCall"; -import { MediaHandler } from "./webrtc/mediaHandler"; -import { GroupCallEventHandler } from "./webrtc/groupCallEventHandler"; -import { LoginTokenPostResponse, ILoginFlowsResponse, IRefreshTokenResponse, SSOAction } from "./@types/auth"; -import { TypedEventEmitter } from "./models/typed-event-emitter"; -import { MAIN_ROOM_TIMELINE, ReceiptType } from "./@types/read_receipts"; -import { MSC3575SlidingSyncRequest, MSC3575SlidingSyncResponse, SlidingSync } from "./sliding-sync"; -import { SlidingSyncSdk } from "./sliding-sync-sdk"; -import { - FeatureSupport, - Thread, - THREAD_RELATION_TYPE, - determineFeatureSupport, - ThreadFilterType, - threadFilterTypeToFilter, -} from "./models/thread"; -import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon"; -import { UnstableValue } from "./NamespacedValue"; -import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue"; -import { ToDeviceBatch } from "./models/ToDeviceMessage"; -import { IgnoredInvites } from "./models/invites-ignorer"; -import { UIARequest, UIAResponse } from "./@types/uia"; -import { LocalNotificationSettings } from "./@types/local_notifications"; -import { buildFeatureSupportMap, Feature, ServerSupport } from "./feature"; -import { CryptoBackend } from "./common-crypto/CryptoBackend"; -import { RUST_SDK_STORE_PREFIX } from "./rust-crypto/constants"; -import { CryptoApi } from "./crypto-api"; -import { DeviceInfoMap } from "./crypto/DeviceList"; -import { SecretStorageKeyDescription } from "./secret-storage"; - -export type Store = IStore; - -export type ResetTimelineCallback = (roomId: string) => boolean; - -const SCROLLBACK_DELAY_MS = 3000; -export const CRYPTO_ENABLED: boolean = isCryptoAvailable(); -const CAPABILITIES_CACHE_MS = 21600000; // 6 hours - an arbitrary value -const TURN_CHECK_INTERVAL = 10 * 60 * 1000; // poll for turn credentials every 10 minutes - -export const UNSTABLE_MSC3852_LAST_SEEN_UA = new UnstableValue( - "last_seen_user_agent", - "org.matrix.msc3852.last_seen_user_agent", -); - -interface IExportedDevice { - olmDevice: IExportedOlmDevice; - userId: string; - deviceId: string; -} - -export interface IKeysUploadResponse { - one_time_key_counts: { - // eslint-disable-line camelcase - [algorithm: string]: number; - }; -} - -export interface ICreateClientOpts { - baseUrl: string; - - idBaseUrl?: string; - - /** - * The data store used for sync data from the homeserver. If not specified, - * this client will not store any HTTP responses. The `createClient` helper - * will create a default store if needed. - */ - store?: Store; - - /** - * A store to be used for end-to-end crypto session data. If not specified, - * end-to-end crypto will be disabled. The `createClient` helper will create - * a default store if needed. Calls the factory supplied to - * {@link setCryptoStoreFactory} if unspecified; or if no factory has been - * specified, uses a default implementation (indexeddb in the browser, - * in-memory otherwise). - */ - cryptoStore?: CryptoStore; - - /** - * The scheduler to use. If not - * specified, this client will not retry requests on failure. This client - * will supply its own processing function to - * {@link MatrixScheduler#setProcessFunction}. - */ - scheduler?: MatrixScheduler; - - /** - * The function to invoke for HTTP requests. - * Most supported environments have a global `fetch` registered to which this will fall back. - */ - fetchFn?: typeof global.fetch; - - userId?: string; - - /** - * A unique identifier for this device; used for tracking things like crypto - * keys and access tokens. If not specified, end-to-end encryption will be - * disabled. - */ - deviceId?: string; - - accessToken?: string; - - /** - * Identity server provider to retrieve the user's access token when accessing - * the identity server. See also https://github.com/vector-im/element-web/issues/10615 - * which seeks to replace the previous approach of manual access tokens params - * with this callback throughout the SDK. - */ - identityServer?: IIdentityServerProvider; - - /** - * The default maximum amount of - * time to wait before timing out HTTP requests. If not specified, there is no timeout. - */ - localTimeoutMs?: number; - - /** - * Set to true to use - * Authorization header instead of query param to send the access token to the server. - * - * Default false. - */ - useAuthorizationHeader?: boolean; - - /** - * Set to true to enable - * improved timeline support, see {@link MatrixClient#getEventTimeline}. - * It is disabled by default for compatibility with older clients - in particular to - * maintain support for back-paginating the live timeline after a '/sync' - * result with a gap. - */ - timelineSupport?: boolean; - - /** - * Extra query parameters to append - * to all requests with this client. Useful for application services which require - * `?user_id=`. - */ - queryParams?: Record<string, string>; - - /** - * Device data exported with - * "exportDevice" method that must be imported to recreate this device. - * Should only be useful for devices with end-to-end crypto enabled. - * If provided, deviceId and userId should **NOT** be provided at the top - * level (they are present in the exported data). - */ - deviceToImport?: IExportedDevice; - - /** - * Key used to pickle olm objects or other sensitive data. - */ - pickleKey?: string; - - verificationMethods?: Array<VerificationMethod>; - - /** - * Whether relaying calls through a TURN server should be forced. Default false. - */ - forceTURN?: boolean; - - /** - * Up to this many ICE candidates will be gathered when an incoming call arrives. - * Gathering does not send data to the caller, but will communicate with the configured TURN - * server. Default 0. - */ - iceCandidatePoolSize?: number; - - /** - * True to advertise support for call transfers to other parties on Matrix calls. Default false. - */ - supportsCallTransfer?: boolean; - - /** - * 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. - */ - fallbackICEServerAllowed?: boolean; - - /** - * If true, to-device signalling for group calls will be encrypted - * with Olm. Default: true. - */ - useE2eForGroupCall?: boolean; - - cryptoCallbacks?: ICryptoCallbacks; - - /** - * Method to generate room names for empty rooms and rooms names based on membership. - * Defaults to a built-in English handler with basic pluralisation. - */ - roomNameGenerator?: (roomId: string, state: RoomNameState) => string | null; - - /** - * If true, participant can join group call without video and audio this has to be allowed. By default, a local - * media stream is needed to establish a group call. - * Default: false. - */ - isVoipWithNoMediaAllowed?: boolean; -} - -export interface IMatrixClientCreateOpts extends ICreateClientOpts { - /** - * Whether to allow sending messages to encrypted rooms when encryption - * is not available internally within this SDK. This is useful if you are using an external - * E2E proxy, for example. Defaults to false. - */ - usingExternalCrypto?: boolean; -} - -export enum PendingEventOrdering { - Chronological = "chronological", - Detached = "detached", -} - -export interface IStartClientOpts { - /** - * The event `limit=` to apply to initial sync. Default: 8. - */ - initialSyncLimit?: number; - - /** - * True to put `archived=true</code> on the <code>/initialSync` request. Default: false. - */ - includeArchivedRooms?: boolean; - - /** - * True to do /profile requests on every invite event if the displayname/avatar_url is not known for this user ID. Default: false. - */ - resolveInvitesToProfiles?: boolean; - - /** - * Controls where pending messages appear in a room's timeline. If "<b>chronological</b>", messages will - * appear in the timeline when the call to `sendEvent` was made. If "<b>detached</b>", - * pending messages will appear in a separate list, accessbile via {@link Room#getPendingEvents}. - * Default: "chronological". - */ - pendingEventOrdering?: PendingEventOrdering; - - /** - * The number of milliseconds to wait on /sync. Default: 30000 (30 seconds). - */ - pollTimeout?: number; - - /** - * The filter to apply to /sync calls. - */ - filter?: Filter; - - /** - * True to perform syncing without automatically updating presence. - */ - disablePresence?: boolean; - - /** - * True to not load all membership events during initial sync but fetch them when needed by calling - * `loadOutOfBandMembers` This will override the filter option at this moment. - */ - lazyLoadMembers?: boolean; - - /** - * The number of seconds between polls to /.well-known/matrix/client, undefined to disable. - * This should be in the order of hours. Default: undefined. - */ - clientWellKnownPollPeriod?: number; - - /** - * @deprecated use `threadSupport` instead - */ - experimentalThreadSupport?: boolean; - - /** - * Will organises events in threaded conversations when - * a thread relation is encountered - */ - threadSupport?: boolean; - - /** - * @experimental - */ - slidingSync?: SlidingSync; - - /** - * @experimental - */ - intentionalMentions?: boolean; -} - -export interface IStoredClientOpts extends IStartClientOpts {} - -export enum RoomVersionStability { - Stable = "stable", - Unstable = "unstable", -} - -export interface IRoomVersionsCapability { - default: string; - available: Record<string, RoomVersionStability>; -} - -export interface ICapability { - enabled: boolean; -} - -export interface IChangePasswordCapability extends ICapability {} - -export interface IThreadsCapability extends ICapability {} - -interface ICapabilities { - [key: string]: any; - "m.change_password"?: IChangePasswordCapability; - "m.room_versions"?: IRoomVersionsCapability; - "io.element.thread"?: IThreadsCapability; -} - -/* eslint-disable camelcase */ -export interface ICrossSigningKey { - keys: { [algorithm: string]: string }; - signatures?: ISignatures; - usage: string[]; - user_id: string; -} - -enum CrossSigningKeyType { - MasterKey = "master_key", - SelfSigningKey = "self_signing_key", - UserSigningKey = "user_signing_key", -} - -export type CrossSigningKeys = Record<CrossSigningKeyType, ICrossSigningKey>; - -export type SendToDeviceContentMap = Map<string, Map<string, Record<string, any>>>; - -export interface ISignedKey { - keys: Record<string, string>; - signatures: ISignatures; - user_id: string; - algorithms: string[]; - device_id: string; -} - -export type KeySignatures = Record<string, Record<string, ICrossSigningKey | ISignedKey>>; -export interface IUploadKeySignaturesResponse { - failures: Record< - string, - Record< - string, - { - errcode: string; - error: string; - } - > - >; -} - -export interface IPreviewUrlResponse { - [key: string]: undefined | string | number; - "og:title": string; - "og:type": string; - "og:url": string; - "og:image"?: string; - "og:image:type"?: string; - "og:image:height"?: number; - "og:image:width"?: number; - "og:description"?: string; - "matrix:image:size"?: number; -} - -export interface ITurnServerResponse { - uris: string[]; - username: string; - password: string; - ttl: number; -} - -export interface ITurnServer { - urls: string[]; - username: string; - credential: string; -} - -export interface IServerVersions { - versions: string[]; - unstable_features: Record<string, boolean>; -} - -export const M_AUTHENTICATION = new UnstableValue("m.authentication", "org.matrix.msc2965.authentication"); - -export interface IClientWellKnown { - [key: string]: any; - "m.homeserver"?: IWellKnownConfig; - "m.identity_server"?: IWellKnownConfig; - [M_AUTHENTICATION.name]?: IDelegatedAuthConfig; // MSC2965 -} - -export interface IWellKnownConfig { - raw?: IClientWellKnown; - action?: AutoDiscoveryAction; - reason?: string; - error?: Error | string; - // eslint-disable-next-line - base_url?: string | null; - // XXX: this is undocumented - server_name?: string; -} - -export interface IDelegatedAuthConfig { - // MSC2965 - /** The OIDC Provider/issuer the client should use */ - issuer: string; - /** The optional URL of the web UI where the user can manage their account */ - account?: string; -} - -interface IKeyBackupPath { - path: string; - queryData?: { - version: string; - }; -} - -interface IMediaConfig { - [key: string]: any; // extensible - "m.upload.size"?: number; -} - -interface IThirdPartySigned { - sender: string; - mxid: string; - token: string; - signatures: ISignatures; -} - -interface IJoinRequestBody { - third_party_signed?: IThirdPartySigned; -} - -interface ITagMetadata { - [key: string]: any; - order: number; -} - -interface IMessagesResponse { - start?: string; - end?: string; - chunk: IRoomEvent[]; - state?: IStateEvent[]; -} - -interface IThreadedMessagesResponse { - prev_batch: string; - next_batch: string; - chunk: IRoomEvent[]; - state: IStateEvent[]; -} - -export interface IRequestTokenResponse { - sid: string; - submit_url?: string; -} - -export interface IRequestMsisdnTokenResponse extends IRequestTokenResponse { - msisdn: string; - success: boolean; - intl_fmt: string; -} - -export interface IUploadKeysRequest { - "device_keys"?: Required<IDeviceKeys>; - "one_time_keys"?: Record<string, IOneTimeKey>; - "org.matrix.msc2732.fallback_keys"?: Record<string, IOneTimeKey>; -} - -export interface IQueryKeysRequest { - device_keys: { [userId: string]: string[] }; - timeout?: number; - token?: string; -} - -export interface IClaimKeysRequest { - one_time_keys: { [userId: string]: { [deviceId: string]: string } }; - timeout?: number; -} - -export interface IOpenIDToken { - access_token: string; - token_type: "Bearer" | string; - matrix_server_name: string; - expires_in: number; -} - -interface IRoomInitialSyncResponse { - room_id: string; - membership: "invite" | "join" | "leave" | "ban"; - messages?: { - start?: string; - end?: string; - chunk: IEventWithRoomId[]; - }; - state?: IStateEventWithRoomId[]; - visibility: Visibility; - account_data?: IMinimalEvent[]; - presence: Partial<IEvent>; // legacy and undocumented, api is deprecated so this won't get attention -} - -interface IJoinedRoomsResponse { - joined_rooms: string[]; -} - -interface IJoinedMembersResponse { - joined: { - [userId: string]: { - display_name: string; - avatar_url: string; - }; - }; -} - -export interface IRegisterRequestParams { - auth?: IAuthData; - username?: string; - password?: string; - refresh_token?: boolean; - guest_access_token?: string; - x_show_msisdn?: boolean; - bind_msisdn?: boolean; - bind_email?: boolean; - inhibit_login?: boolean; - initial_device_display_name?: string; -} - -export interface IPublicRoomsChunkRoom { - room_id: string; - name?: string; - avatar_url?: string; - topic?: string; - canonical_alias?: string; - aliases?: string[]; - world_readable: boolean; - guest_can_join: boolean; - num_joined_members: number; - room_type?: RoomType | string; // Added by MSC3827 -} - -interface IPublicRoomsResponse { - chunk: IPublicRoomsChunkRoom[]; - next_batch?: string; - prev_batch?: string; - total_room_count_estimate?: number; -} - -interface IUserDirectoryResponse { - results: { - user_id: string; - display_name?: string; - avatar_url?: string; - }[]; - limited: boolean; -} - -export interface IMyDevice { - "device_id": string; - "display_name"?: string; - "last_seen_ip"?: string; - "last_seen_ts"?: number; - // UNSTABLE_MSC3852_LAST_SEEN_UA - "last_seen_user_agent"?: string; - "org.matrix.msc3852.last_seen_user_agent"?: string; -} - -export interface Keys { - keys: { [keyId: string]: string }; - usage: string[]; - user_id: string; -} - -export interface SigningKeys extends Keys { - signatures: ISignatures; -} - -export interface DeviceKeys { - [deviceId: string]: IDeviceKeys & { - unsigned?: { - device_display_name: string; - }; - }; -} - -export interface IDownloadKeyResult { - failures: { [serverName: string]: object }; - device_keys: { [userId: string]: DeviceKeys }; - // the following three fields were added in 1.1 - master_keys?: { [userId: string]: Keys }; - self_signing_keys?: { [userId: string]: SigningKeys }; - user_signing_keys?: { [userId: string]: SigningKeys }; -} - -export interface IClaimOTKsResult { - failures: { [serverName: string]: object }; - one_time_keys: { - [userId: string]: { - [deviceId: string]: { - [keyId: string]: { - key: string; - signatures: ISignatures; - }; - }; - }; - }; -} - -export interface IFieldType { - regexp: string; - placeholder: string; -} - -export interface IInstance { - desc: string; - icon?: string; - fields: object; - network_id: string; - // XXX: this is undocumented but we rely on it: https://github.com/matrix-org/matrix-doc/issues/3203 - instance_id: string; -} - -export interface IProtocol { - user_fields: string[]; - location_fields: string[]; - icon: string; - field_types: Record<string, IFieldType>; - instances: IInstance[]; -} - -interface IThirdPartyLocation { - alias: string; - protocol: string; - fields: object; -} - -interface IThirdPartyUser { - userid: string; - protocol: string; - fields: object; -} - -interface IRoomSummary extends Omit<IPublicRoomsChunkRoom, "canonical_alias" | "aliases"> { - room_type?: RoomType; - membership?: string; - is_encrypted: boolean; -} - -interface IRoomKeysResponse { - sessions: IKeyBackupRoomSessions; -} - -interface IRoomsKeysResponse { - rooms: Record<string, IRoomKeysResponse>; -} - -interface IRoomHierarchy { - rooms: IHierarchyRoom[]; - next_batch?: string; -} - -export interface TimestampToEventResponse { - event_id: string; - origin_server_ts: string; -} - -interface IWhoamiResponse { - user_id: string; - device_id?: string; -} -/* 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 = "$"; - -export enum ClientEvent { - Sync = "sync", - Event = "event", - ToDeviceEvent = "toDeviceEvent", - AccountData = "accountData", - Room = "Room", - DeleteRoom = "deleteRoom", - SyncUnexpectedError = "sync.unexpectedError", - ClientWellKnown = "WellKnown.client", - ReceivedVoipEvent = "received_voip_event", - UndecryptableToDeviceEvent = "toDeviceEvent.undecryptable", - TurnServers = "turnServers", - TurnServersError = "turnServers.error", -} - -type RoomEvents = - | RoomEvent.Name - | RoomEvent.Redaction - | RoomEvent.RedactionCancelled - | RoomEvent.Receipt - | RoomEvent.Tags - | RoomEvent.LocalEchoUpdated - | RoomEvent.HistoryImportedWithinTimeline - | RoomEvent.AccountData - | RoomEvent.MyMembership - | RoomEvent.Timeline - | RoomEvent.TimelineReset; - -type RoomStateEvents = - | RoomStateEvent.Events - | RoomStateEvent.Members - | RoomStateEvent.NewMember - | RoomStateEvent.Update - | RoomStateEvent.Marker; - -type CryptoEvents = - | CryptoEvent.KeySignatureUploadFailure - | CryptoEvent.KeyBackupStatus - | CryptoEvent.KeyBackupFailed - | CryptoEvent.KeyBackupSessionsRemaining - | CryptoEvent.RoomKeyRequest - | CryptoEvent.RoomKeyRequestCancellation - | CryptoEvent.VerificationRequest - | CryptoEvent.DeviceVerificationChanged - | CryptoEvent.UserTrustStatusChanged - | CryptoEvent.KeysChanged - | CryptoEvent.Warning - | CryptoEvent.DevicesUpdated - | CryptoEvent.WillUpdateDevices; - -type MatrixEventEvents = MatrixEventEvent.Decrypted | MatrixEventEvent.Replaced | MatrixEventEvent.VisibilityChange; - -type RoomMemberEvents = - | RoomMemberEvent.Name - | RoomMemberEvent.Typing - | RoomMemberEvent.PowerLevel - | RoomMemberEvent.Membership; - -type UserEvents = - | UserEvent.AvatarUrl - | UserEvent.DisplayName - | UserEvent.Presence - | UserEvent.CurrentlyActive - | UserEvent.LastPresenceTs; - -export type EmittedEvents = - | ClientEvent - | RoomEvents - | RoomStateEvents - | CryptoEvents - | MatrixEventEvents - | RoomMemberEvents - | UserEvents - | CallEvent // re-emitted by call.ts using Object.values - | CallEventHandlerEvent.Incoming - | GroupCallEventHandlerEvent.Incoming - | GroupCallEventHandlerEvent.Outgoing - | GroupCallEventHandlerEvent.Ended - | GroupCallEventHandlerEvent.Participants - | HttpApiEvent.SessionLoggedOut - | HttpApiEvent.NoConsent - | BeaconEvent; - -export type ClientEventHandlerMap = { - /** - * Fires whenever the SDK's syncing state is updated. The state can be one of: - * <ul> - * - * <li>PREPARED: The client has synced with the server at least once and is - * ready for methods to be called on it. This will be immediately followed by - * a state of SYNCING. <i>This is the equivalent of "syncComplete" in the - * previous API.</i></li> - * - * <li>CATCHUP: The client has detected the connection to the server might be - * available again and will now try to do a sync again. As this sync might take - * a long time (depending how long ago was last synced, and general server - * performance) the client is put in this mode so the UI can reflect trying - * to catch up with the server after losing connection.</li> - * - * <li>SYNCING : The client is currently polling for new events from the server. - * This will be called <i>after</i> processing latest events from a sync.</li> - * - * <li>ERROR : The client has had a problem syncing with the server. If this is - * called <i>before</i> PREPARED then there was a problem performing the initial - * sync. If this is called <i>after</i> PREPARED then there was a problem polling - * the server for updates. This may be called multiple times even if the state is - * already ERROR. <i>This is the equivalent of "syncError" in the previous - * API.</i></li> - * - * <li>RECONNECTING: The sync connection has dropped, but not (yet) in a way that - * should be considered erroneous. - * </li> - * - * <li>STOPPED: The client has stopped syncing with server due to stopClient - * being called. - * </li> - * </ul> - * State transition diagram: - * ``` - * +---->STOPPED - * | - * +----->PREPARED -------> SYNCING <--+ - * | ^ | ^ | - * | CATCHUP ----------+ | | | - * | ^ V | | - * null ------+ | +------- RECONNECTING | - * | V V | - * +------->ERROR ---------------------+ - * - * NB: 'null' will never be emitted by this event. - * - * ``` - * Transitions: - * <ul> - * - * <li>`null -> PREPARED` : Occurs when the initial sync is completed - * first time. This involves setting up filters and obtaining push rules. - * - * <li>`null -> ERROR` : Occurs when the initial sync failed first time. - * - * <li>`ERROR -> PREPARED` : Occurs when the initial sync succeeds - * after previously failing. - * - * <li>`PREPARED -> SYNCING` : Occurs immediately after transitioning - * to PREPARED. Starts listening for live updates rather than catching up. - * - * <li>`SYNCING -> RECONNECTING` : Occurs when the live update fails. - * - * <li>`RECONNECTING -> RECONNECTING` : Can occur if the update calls - * continue to fail, but the keepalive calls (to /versions) succeed. - * - * <li>`RECONNECTING -> ERROR` : Occurs when the keepalive call also fails - * - * <li>`ERROR -> SYNCING` : Occurs when the client has performed a - * live update after having previously failed. - * - * <li>`ERROR -> ERROR` : Occurs when the client has failed to keepalive - * for a second time or more.</li> - * - * <li>`SYNCING -> SYNCING` : Occurs when the client has performed a live - * update. This is called <i>after</i> processing.</li> - * - * <li>`* -> STOPPED` : Occurs once the client has stopped syncing or - * trying to sync after stopClient has been called.</li> - * </ul> - * - * @param state - An enum representing the syncing state. One of "PREPARED", - * "SYNCING", "ERROR", "STOPPED". - * - * @param prevState - An enum representing the previous syncing state. - * One of "PREPARED", "SYNCING", "ERROR", "STOPPED" <b>or null</b>. - * - * @param data - Data about this transition. - * - * @example - * ``` - * matrixClient.on("sync", function(state, prevState, data) { - * switch (state) { - * case "ERROR": - * // update UI to say "Connection Lost" - * break; - * case "SYNCING": - * // update UI to remove any "Connection Lost" message - * break; - * case "PREPARED": - * // the client instance is ready to be queried. - * var rooms = matrixClient.getRooms(); - * break; - * } - * }); - * ``` - */ - [ClientEvent.Sync]: (state: SyncState, lastState: SyncState | null, data?: ISyncStateData) => void; - /** - * Fires whenever the SDK receives a new event. - * <p> - * This is only fired for live events received via /sync - it is not fired for - * events received over context, search, or pagination APIs. - * - * @param event - The matrix event which caused this event to fire. - * @example - * ``` - * matrixClient.on("event", function(event){ - * var sender = event.getSender(); - * }); - * ``` - */ - [ClientEvent.Event]: (event: MatrixEvent) => void; - /** - * Fires whenever the SDK receives a new to-device event. - * @param event - The matrix event which caused this event to fire. - * @example - * ``` - * matrixClient.on("toDeviceEvent", function(event){ - * var sender = event.getSender(); - * }); - * ``` - */ - [ClientEvent.ToDeviceEvent]: (event: MatrixEvent) => void; - /** - * Fires if a to-device event is received that cannot be decrypted. - * Encrypted to-device events will (generally) use plain Olm encryption, - * in which case decryption failures are fatal: the event will never be - * decryptable, unlike Megolm encrypted events where the key may simply - * arrive later. - * - * An undecryptable to-device event is therefore likley to indicate problems. - * - * @param event - The undecyptable to-device event - */ - [ClientEvent.UndecryptableToDeviceEvent]: (event: MatrixEvent) => void; - /** - * Fires whenever new user-scoped account_data is added. - * @param event - The event describing the account_data just added - * @param event - The previous account data, if known. - * @example - * ``` - * matrixClient.on("accountData", function(event, oldEvent){ - * myAccountData[event.type] = event.content; - * }); - * ``` - */ - [ClientEvent.AccountData]: (event: MatrixEvent, lastEvent?: MatrixEvent) => void; - /** - * Fires whenever a new Room is added. This will fire when you are invited to a - * room, as well as when you join a room. <strong>This event is experimental and - * may change.</strong> - * @param room - The newly created, fully populated room. - * @example - * ``` - * matrixClient.on("Room", function(room){ - * var roomId = room.roomId; - * }); - * ``` - */ - [ClientEvent.Room]: (room: Room) => void; - /** - * Fires whenever a Room is removed. This will fire when you forget a room. - * <strong>This event is experimental and may change.</strong> - * @param roomId - The deleted room ID. - * @example - * ``` - * matrixClient.on("deleteRoom", function(roomId){ - * // update UI from getRooms() - * }); - * ``` - */ - [ClientEvent.DeleteRoom]: (roomId: string) => void; - [ClientEvent.SyncUnexpectedError]: (error: Error) => void; - /** - * Fires when the client .well-known info is fetched. - * - * @param data - The JSON object returned by the server - */ - [ClientEvent.ClientWellKnown]: (data: IClientWellKnown) => void; - [ClientEvent.ReceivedVoipEvent]: (event: MatrixEvent) => void; - [ClientEvent.TurnServers]: (servers: ITurnServer[]) => void; - [ClientEvent.TurnServersError]: (error: Error, fatal: boolean) => void; -} & RoomEventHandlerMap & - RoomStateEventHandlerMap & - CryptoEventHandlerMap & - MatrixEventHandlerMap & - RoomMemberEventHandlerMap & - UserEventHandlerMap & - CallEventHandlerEventHandlerMap & - GroupCallEventHandlerEventHandlerMap & - CallEventHandlerMap & - HttpApiEventHandlerMap & - BeaconEventHandlerMap; - -const SSO_ACTION_PARAM = new 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. - */ -export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHandlerMap> { - public static readonly RESTORE_BACKUP_ERROR_BAD_KEY = "RESTORE_BACKUP_ERROR_BAD_KEY"; - - public reEmitter = new TypedReEmitter<EmittedEvents, ClientEventHandlerMap>(this); - public olmVersion: [number, number, number] | null = null; // populated after initCrypto - public usingExternalCrypto = false; - public store: Store; - public deviceId: string | null; - public credentials: { userId: string | null }; - public pickleKey?: string; - public scheduler?: MatrixScheduler; - public clientRunning = false; - public timelineSupport = false; - public urlPreviewCache: { [key: string]: Promise<IPreviewUrlResponse> } = {}; - public identityServer?: IIdentityServerProvider; - public http: MatrixHttpApi<IHttpOpts & { onlyData: true }>; // 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}. - */ - public crypto?: Crypto; // XXX: Intended private, used in code. Being replaced by cryptoBackend - - private cryptoBackend?: CryptoBackend; // one of crypto or rustCrypto - public cryptoCallbacks: ICryptoCallbacks; // XXX: Intended private, used in code. - public callEventHandler?: CallEventHandler; // XXX: Intended private, used in code. - public groupCallEventHandler?: GroupCallEventHandler; - public supportsCallTransfer = false; // XXX: Intended private, used in code. - public forceTURN = false; // XXX: Intended private, used in code. - public iceCandidatePoolSize = 0; // XXX: Intended private, used in code. - public idBaseUrl?: string; - public baseUrl: string; - public readonly isVoipWithNoMediaAllowed; - - // 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. - - protected canSupportVoip = false; - protected peekSync: SyncApi | null = null; - protected isGuestAccount = false; - protected ongoingScrollbacks: { [roomId: string]: { promise?: Promise<Room>; errorTs?: number } } = {}; - protected notifTimelineSet: EventTimelineSet | null = null; - protected cryptoStore?: CryptoStore; - protected verificationMethods?: VerificationMethod[]; - protected fallbackICEServerAllowed = false; - protected roomList: RoomList; - protected syncApi?: SlidingSyncSdk | SyncApi; - public roomNameGenerator?: ICreateClientOpts["roomNameGenerator"]; - public pushRules?: IPushRules; - protected syncLeftRoomsPromise?: Promise<Room[]>; - protected syncedLeftRooms = false; - protected clientOpts?: IStoredClientOpts; - protected clientWellKnownIntervalID?: ReturnType<typeof setInterval>; - protected canResetTimelineCallback?: ResetTimelineCallback; - - public canSupport = new Map<Feature, ServerSupport>(); - - // The pushprocessor caches useful things, so keep one and re-use it - protected pushProcessor = new PushProcessor(this); - - // Promise to a response of the server's /versions response - // TODO: This should expire: https://github.com/matrix-org/matrix-js-sdk/issues/1020 - protected serverVersionsPromise?: Promise<IServerVersions>; - - public cachedCapabilities?: { - capabilities: ICapabilities; - expiration: number; - }; - protected clientWellKnown?: IClientWellKnown; - protected clientWellKnownPromise?: Promise<IClientWellKnown>; - protected turnServers: ITurnServer[] = []; - protected turnServersExpiry = 0; - protected checkTurnServersIntervalID?: ReturnType<typeof setInterval>; - protected exportedOlmDeviceToImport?: IExportedOlmDevice; - protected txnCtr = 0; - protected mediaHandler = new MediaHandler(this); - protected sessionId: string; - protected pendingEventEncryption = new Map<string, Promise<void>>(); - - private useE2eForGroupCall = true; - private toDeviceMessageQueue: ToDeviceMessageQueue; - - // A manager for determining which invites should be ignored. - public readonly ignoredInvites: IgnoredInvites; - - public constructor(opts: IMatrixClientCreateOpts) { - super(); - - 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 StubStore(); - this.deviceId = opts.deviceId || null; - this.sessionId = randomString(10); - - const userId = opts.userId || null; - this.credentials = { userId }; - - this.http = new MatrixHttpApi(this as ConstructorParameters<typeof MatrixHttpApi>[0], { - fetchFn: opts.fetchFn, - baseUrl: opts.baseUrl, - idBaseUrl: opts.idBaseUrl, - accessToken: opts.accessToken, - prefix: ClientPrefix.R0, - onlyData: true, - extraParams: opts.queryParams, - localTimeoutMs: opts.localTimeoutMs, - useAuthorizationHeader: opts.useAuthorizationHeader, - }); - - if (opts.deviceToImport) { - if (this.deviceId) { - logger.warn( - "not importing device because device ID is provided to " + - "constructor independently of exported data", - ); - } else if (this.credentials.userId) { - logger.warn( - "not importing device because user ID is provided to " + - "constructor independently of exported data", - ); - } else if (!opts.deviceToImport.deviceId) { - logger.warn("not importing device because no device ID in exported data"); - } else { - this.deviceId = opts.deviceToImport.deviceId; - this.credentials.userId = opts.deviceToImport.userId; - // will be used during async initialization of the crypto - this.exportedOlmDeviceToImport = opts.deviceToImport.olmDevice; - } - } else if (opts.pickleKey) { - this.pickleKey = opts.pickleKey; - } - - this.scheduler = opts.scheduler; - if (this.scheduler) { - this.scheduler.setProcessFunction(async (eventToSend: MatrixEvent) => { - const room = this.getRoom(eventToSend.getRoomId()); - if (eventToSend.status !== EventStatus.SENDING) { - this.updatePendingEventStatus(room, eventToSend, 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, EventStatus.SENT, res.event_id); - } - return res; - }); - } - - if (supportsMatrixCall()) { - this.callEventHandler = new CallEventHandler(this); - this.groupCallEventHandler = new 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(this.cryptoStore); - this.roomNameGenerator = opts.roomNameGenerator; - - this.toDeviceMessageQueue = new 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(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(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(NotificationCountType.Highlight, highlightCount); - } - }); - - this.ignoredInvites = new IgnoredInvites(this); - } - - /** - * 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. - */ - public async startClient(opts?: IStartClientOpts): Promise<void> { - if (this.clientRunning) { - // client is already running. - return; - } - this.clientRunning = true; - // backwards compat for when 'opts' was 'historyLen'. - if (typeof opts === "number") { - opts = { - initialSyncLimit: opts, - }; - } - - // Create our own user object artificially (instead of waiting for sync) - // so it's always available, even if the user is not in any rooms etc. - const userId = this.getUserId(); - if (userId) { - this.store.storeUser(new User(userId)); - } - - // 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.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.setServerSideSupport(threads); - Thread.setServerSideListSupport(list); - Thread.setServerSideFwdPaginationSupport(fwdPagination); - } catch (e) { - 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( - this.clientOpts.slidingSync, - this, - this.clientOpts, - this.buildSyncApiOptions(), - ); - } else { - this.syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions()); - } - - if (this.clientOpts.hasOwnProperty("experimentalThreadSupport")) { - 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 - */ - protected buildSyncApiOptions(): SyncApiOptions { - return { - crypto: this.crypto, - cryptoCallbacks: this.cryptoBackend, - canResetEntireTimeline: (roomId: string): boolean => { - if (!this.canResetTimelineCallback) { - return false; - } - return this.canResetTimelineCallback(roomId); - }, - }; - } - - /** - * High level helper method to stop the client from polling and allow a - * clean shutdown. - */ - public stopClient(): void { - this.cryptoBackend?.stop(); // crypto might have been initialised even if the client wasn't fully started - - if (!this.clientRunning) return; // already stopped - - 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. - */ - public async rehydrateDevice(): Promise<string | undefined> { - 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.info("no dehydrated device found"); - return; - } - - const account = new global.Olm.Account(); - try { - const deviceData = getDeviceResult.device_data; - if (deviceData.algorithm !== DEHYDRATION_ALGORITHM) { - logger.warn("Wrong algorithm for dehydrated device"); - return; - } - logger.log("unpickling dehydrated device"); - const key = await this.cryptoCallbacks.getDehydrationKey(deviceData, (k) => { - // copy the key so that it doesn't get clobbered - account.unpickle(new Uint8Array(k), deviceData.account); - }); - account.unpickle(key, deviceData.account); - logger.log("unpickled device"); - - const rehydrateResult = await this.http.authedRequest<{ success: boolean }>( - 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.info("using dehydrated device"); - const pickleKey = this.pickleKey || "DEFAULT_KEY"; - this.exportedOlmDeviceToImport = { - pickledAccount: account.pickle(pickleKey), - sessions: [], - pickleKey: pickleKey, - }; - account.free(); - return this.deviceId; - } else { - account.free(); - logger.info("not using dehydrated device"); - return; - } - } catch (e) { - account.free(); - logger.warn("could not unpickle", e); - } - } - - /** - * Get the current dehydrated device, if any - * @returns A promise of an object containing the dehydrated device - */ - public async getDehydratedDevice(): Promise<IDehydratedDevice | undefined> { - try { - return await this.http.authedRequest<IDehydratedDevice>( - Method.Get, - "/dehydrated_device", - undefined, - undefined, - { - prefix: "/_matrix/client/unstable/org.matrix.msc2697.v2", - }, - ); - } catch (e) { - 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. - */ - public async setDehydrationKey( - key: Uint8Array, - keyInfo: IDehydratedDeviceKeyInfo, - deviceDisplayName?: string, - ): Promise<void> { - if (!this.crypto) { - 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 - */ - public async createDehydratedDevice( - key: Uint8Array, - keyInfo: IDehydratedDeviceKeyInfo, - deviceDisplayName?: string, - ): Promise<string | undefined> { - if (!this.crypto) { - logger.warn("not dehydrating device if crypto is not enabled"); - return; - } - await this.crypto.dehydrationManager.setKey(key, keyInfo, deviceDisplayName); - return this.crypto.dehydrationManager.dehydrateDevice(); - } - - public async exportDevice(): Promise<IExportedDevice | undefined> { - if (!this.crypto) { - 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. - */ - public clearStores(): Promise<void> { - if (this.clientRunning) { - throw new Error("Cannot clear stores while client is running"); - } - - const promises: Promise<void>[] = []; - - 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 (): Promise<void> => { - let indexedDB: IDBFactory; - try { - indexedDB = global.indexedDB; - } catch (e) { - // No indexeddb support - return; - } - for (const dbname of [ - `${RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto`, - `${RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto-meta`, - ]) { - const prom = new Promise((resolve, reject) => { - logger.info(`Removing IndexedDB instance ${dbname}`); - const req = indexedDB.deleteDatabase(dbname); - req.onsuccess = (_): void => { - logger.info(`Removed IndexedDB instance ${dbname}`); - resolve(0); - }; - req.onerror = (e): void => { - // 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.warn(`Failed to remove IndexedDB instance ${dbname}:`, e); - resolve(0); - }; - req.onblocked = (e): void => { - 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 - */ - public getUserId(): string | null { - 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 - */ - public getSafeUserId(): string { - 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 - */ - public getDomain(): string | null { - 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. - */ - public getUserIdLocalpart(): string | null { - 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 - */ - public getDeviceId(): string | null { - return this.deviceId; - } - - /** - * Get the session ID of this client - * @returns session ID - */ - public getSessionId(): string { - return this.sessionId; - } - - /** - * Check if the runtime environment supports VoIP calling. - * @returns True if VoIP is supported. - */ - public supportsVoip(): boolean { - return this.canSupportVoip; - } - - /** - * @returns - */ - public getMediaHandler(): MediaHandler { - 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 - */ - public setForceTURN(force: boolean): void { - 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 - */ - public setSupportsCallTransfer(support: boolean): void { - 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 - */ - public getUseE2eForGroupCall(): boolean { - 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. - */ - public createCall(roomId: string): MatrixCall | null { - return 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. - */ - public async createGroupCall( - roomId: string, - type: GroupCallType, - isPtt: boolean, - intent: GroupCallIntent, - dataChannelsEnabled?: boolean, - dataChannelOptions?: IGroupCallDataChannelOptions, - ): Promise<GroupCall> { - 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( - 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. - */ - public waitUntilRoomReadyForGroupCalls(roomId: string): Promise<void> { - 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. - */ - public getGroupCallForRoom(roomId: string): GroupCall | null { - return this.groupCallEventHandler!.groupCalls.get(roomId) || null; - } - - /** - * Get the current sync state. - * @returns the sync state, which may be null. - * @see MatrixClient#event:"sync" - */ - public getSyncState(): SyncState | null { - 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. - */ - public getSyncStateData(): ISyncStateData | null { - if (!this.syncApi) { - return null; - } - return this.syncApi.getSyncStateData(); - } - - /** - * Whether the initial sync has completed. - * @returns True if at least one sync has happened. - */ - public isInitialSyncComplete(): boolean { - const state = this.getSyncState(); - if (!state) { - return false; - } - return state === SyncState.Prepared || state === 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). - */ - public isGuest(): boolean { - 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. - */ - public setGuest(guest: boolean): void { - // 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 - */ - public getScheduler(): MatrixScheduler | undefined { - 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. - */ - public retryImmediately(): boolean { - // 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 - */ - public getNotifTimelineSet(): EventTimelineSet | null { - return this.notifTimelineSet; - } - - /** - * Set the global notification EventTimelineSet - * - */ - public setNotifTimelineSet(set: EventTimelineSet): void { - 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. - */ - public getCapabilities(fresh = false): Promise<ICapabilities> { - const now = new Date().getTime(); - - if (this.cachedCapabilities && !fresh) { - if (now < this.cachedCapabilities.expiration) { - logger.log("Returning cached capabilities"); - return Promise.resolve(this.cachedCapabilities.capabilities); - } - } - - type Response = { - capabilities?: ICapabilities; - }; - return this.http - .authedRequest<Response>(Method.Get, "/capabilities") - .catch((e: Error): Response => { - // We swallow errors because we need a default object anyhow - 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.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. - */ - public async initCrypto(): Promise<void> { - if (!isCryptoAvailable()) { - throw new Error( - `End-to-end encryption not supported in this js-sdk build: did ` + - `you remember to load the olm library?`, - ); - } - - if (this.cryptoBackend) { - 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.log("Crypto: Starting up crypto store..."); - await this.cryptoStore.startup(); - - // initialise the list of encrypted rooms (whether or not crypto is enabled) - logger.log("Crypto: initialising roomlist..."); - await this.roomList.init(); - - const userId = this.getUserId(); - if (userId === null) { - throw new Error( - `Cannot enable encryption on MatrixClient with unknown userId: ` + - `ensure userId is passed in createClient().`, - ); - } - if (this.deviceId === null) { - throw new Error( - `Cannot enable encryption on MatrixClient with unknown deviceId: ` + - `ensure deviceId is passed in createClient().`, - ); - } - - const crypto = new Crypto( - this, - userId, - this.deviceId, - this.store, - this.cryptoStore, - this.roomList, - this.verificationMethods!, - ); - - this.reEmitter.reEmit(crypto, [ - CryptoEvent.KeyBackupFailed, - CryptoEvent.KeyBackupSessionsRemaining, - CryptoEvent.RoomKeyRequest, - CryptoEvent.RoomKeyRequestCancellation, - CryptoEvent.Warning, - CryptoEvent.DevicesUpdated, - CryptoEvent.WillUpdateDevices, - CryptoEvent.DeviceVerificationChanged, - CryptoEvent.UserTrustStatusChanged, - CryptoEvent.KeysChanged, - ]); - - logger.log("Crypto: initialising crypto object..."); - await crypto.init({ - exportedOlmDevice: this.exportedOlmDeviceToImport, - pickleKey: this.pickleKey, - }); - delete this.exportedOlmDeviceToImport; - - this.olmVersion = Crypto.getOlmVersion(); - - // if crypto initialisation was successful, tell it to attach its event handlers. - crypto.registerEventHandlers(this as Parameters<Crypto["registerEventHandlers"]>[0]); - 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.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. - */ - public async initRustCrypto(): Promise<void> { - if (this.cryptoBackend) { - 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 import("./rust-crypto"); - const rustCrypto = await RustCrypto.initRustCrypto(this.http, userId, deviceId); - this.cryptoBackend = rustCrypto; - - // attach the event listeners needed by RustCrypto - this.on(RoomMemberEvent.Membership, rustCrypto.onRoomMembership.bind(rustCrypto)); - } - - /** - * 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`. - */ - public getCrypto(): CryptoApi | undefined { - return this.cryptoBackend; - } - - /** - * Is end-to-end crypto enabled for this client. - * @returns True if end-to-end is enabled. - * @deprecated prefer {@link getCrypto} - */ - public isCryptoEnabled(): boolean { - return !!this.cryptoBackend; - } - - /** - * Get the Ed25519 key for this device - * - * @returns base64-encoded ed25519 key. Null if crypto is - * disabled. - */ - public getDeviceEd25519Key(): string | null { - return this.crypto?.getDeviceEd25519Key() ?? null; - } - - /** - * Get the Curve25519 key for this device - * - * @returns base64-encoded curve25519 key. Null if crypto is - * disabled. - */ - public getDeviceCurve25519Key(): string | null { - return this.crypto?.getDeviceCurve25519Key() ?? null; - } - - /** - * @deprecated Does nothing. - */ - public async uploadKeys(): Promise<void> { - 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-\>{@link DeviceInfo} - */ - public downloadKeys(userIds: string[], forceDownload?: boolean): Promise<DeviceInfoMap> { - 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 - */ - public getStoredDevicesForUser(userId: string): DeviceInfo[] { - 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 - */ - public getStoredDevice(userId: string, deviceId: string): DeviceInfo | null { - 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} - */ - public setDeviceVerified(userId: string, deviceId: string, verified = true): Promise<void> { - 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} - */ - public setDeviceBlocked(userId: string, deviceId: string, blocked = true): Promise<void> { - 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} - */ - public setDeviceKnown(userId: string, deviceId: string, known = true): Promise<void> { - return this.setDeviceVerification(userId, deviceId, null, null, known); - } - - private async setDeviceVerification( - userId: string, - deviceId: string, - verified?: boolean | null, - blocked?: boolean | null, - known?: boolean | null, - ): Promise<void> { - 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. - */ - public requestVerificationDM(userId: string, roomId: string): Promise<VerificationRequest> { - 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 - */ - public findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.findVerificationRequestDMInProgress(roomId); - } - - /** - * Returns all to-device verification requests that are already in progress for the given user id - * - * @param userId - the ID of the user to query - * - * @returns the VerificationRequests that are in progress - */ - public getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[] { - 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. - */ - public requestVerification(userId: string, devices?: string[]): Promise<VerificationRequest> { - 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. - */ - public beginKeyVerification(method: string, userId: string, deviceId: string): Verification<any, any> { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.beginKeyVerification(method, userId, deviceId); - } - - public checkSecretStorageKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise<boolean> { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.checkSecretStorageKey(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; - * ``` - */ - public setGlobalBlacklistUnverifiedDevices(value: boolean): boolean { - 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; - * ``` - */ - public getGlobalBlacklistUnverifiedDevices(): boolean { - 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; - * ``` - */ - public setGlobalErrorOnUnknownDevices(value: boolean): void { - 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. - */ - public getGlobalErrorOnUnknownDevices(): boolean { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - return this.cryptoBackend.globalErrorOnUnknownDevices; - } - - /** - * Get the user's cross-signing key ID. - * - * The cross-signing API is currently UNSTABLE and may change without notice. - * - * @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 - */ - public getCrossSigningId(type: CrossSigningKey | string = CrossSigningKey.Master): string | null { - 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. - */ - public getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.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. - */ - public checkUserTrust(userId: string): UserTrustLevel { - 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 - */ - public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - return this.cryptoBackend.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 - */ - public checkIfOwnDeviceCrossSigned(deviceId: string): boolean { - 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 - */ - public checkOwnCrossSigningTrust(opts?: ICheckOwnCrossSigningTrustOpts): Promise<void> { - 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 - */ - public checkCrossSigningPrivateKey(privateKey: Uint8Array, expectedPublicKey: string): boolean { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.checkCrossSigningPrivateKey(privateKey, expectedPublicKey); - } - - // deprecated: use requestVerification instead - public legacyDeviceVerification( - userId: string, - deviceId: string, - method: VerificationMethod, - ): Promise<VerificationRequest> { - 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); - * ``` - */ - public prepareToEncrypt(room: Room): void { - 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(); - * ``` - */ - public userHasCrossSigningKeys(): Promise<boolean> { - 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 - */ - public isCrossSigningReady(): Promise<boolean> { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.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 setup) - * - * The cross-signing API is currently UNSTABLE and may change without notice. - */ - public bootstrapCrossSigning(opts: IBootstrapCrossSigningOpts): Promise<void> { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.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 - */ - public getCryptoTrustCrossSignedDevices(): boolean { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.getCryptoTrustCrossSignedDevices(); - } - - /** - * See getCryptoTrustCrossSignedDevices - * - * @param val - True to trust cross-signed devices - */ - public setCryptoTrustCrossSignedDevices(val: boolean): void { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - this.crypto.setCryptoTrustCrossSignedDevices(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 - */ - public countSessionsNeedingBackup(): Promise<number> { - 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. - */ - public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo { - 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. - */ - public createRecoveryKeyFromPassphrase(password?: string): Promise<IRecoveryKey> { - 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. - * - * 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 - */ - public isSecretStorageReady(): Promise<boolean> { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.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 - * - */ - public bootstrapSecretStorage(opts: ICreateSecretStorageOpts): Promise<void> { - 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) - */ - public addSecretStorageKey( - algorithm: string, - opts: IAddSecretStorageKeyOpts, - keyName?: string, - ): Promise<{ keyId: string; keyInfo: SecretStorageKeyDescription }> { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.addSecretStorageKey(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. - */ - public hasSecretStorageKey(keyId?: string): Promise<boolean> { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.hasSecretStorageKey(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). - */ - public storeSecret(name: string, secret: string, keys?: string[]): Promise<void> { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.storeSecret(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 - */ - public getSecret(name: string): Promise<string | undefined> { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.getSecret(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 - */ - public isSecretStored(name: string): Promise<Record<string, SecretStorageKeyDescription> | null> { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.isSecretStored(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 - */ - public requestSecret(name: string, devices: string[]): ISecretRequest { - 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 - */ - public getDefaultSecretStorageKeyId(): Promise<string | null> { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.getDefaultSecretStorageKeyId(); - } - - /** - * 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 - */ - public setDefaultSecretStorageKeyId(keyId: string): Promise<void> { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.setDefaultSecretStorageKeyId(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 - */ - public checkSecretStoragePrivateKey(privateKey: Uint8Array, expectedPublicKey: string): boolean { - 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 - */ - public async getEventSenderDeviceInfo(event: MatrixEvent): Promise<DeviceInfo | null> { - 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}. - */ - public async isEventSenderVerified(event: MatrixEvent): Promise<boolean> { - 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 - */ - public getOutgoingRoomKeyRequest(event: MatrixEvent): Promise<OutgoingRoomKeyRequest | null> { - if (!this.crypto) { - throw new Error("End-to-End encryption disabled"); - } - const wireContent = event.getWireContent(); - const requestBody: IRoomKeyRequestBody = { - 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 - */ - public cancelAndResendEventRoomKeyRequest(event: MatrixEvent): Promise<void> { - 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. - */ - public setRoomEncryption(roomId: string, config: IRoomEncryption): Promise<void> { - 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. - */ - public isRoomEncrypted(roomId: string): boolean { - 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(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. - */ - public encryptAndSendToDevices(userDeviceInfoArr: IOlmDevice<DeviceInfo>[], payload: object): Promise<void> { - 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`}: - * - */ - public forceDiscardSession(roomId: string): void { - 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(); - * ``` - */ - public exportRoomKeys(): Promise<IMegolmSessionData[]> { - 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 - */ - public importRoomKeys(keys: IMegolmSessionData[], opts?: IImportRoomKeysOpts): Promise<void> { - 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. - */ - public checkKeyBackup(): Promise<IKeyBackupCheck | null> { - 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 - */ - public async getKeyBackupVersion(): Promise<IKeyBackupInfo | null> { - let res: IKeyBackupInfo; - try { - res = await this.http.authedRequest<IKeyBackupInfo>( - Method.Get, - "/room_keys/version", - undefined, - undefined, - { prefix: ClientPrefix.V3 }, - ); - } catch (e) { - if ((<MatrixError>e).errcode === "M_NOT_FOUND") { - return null; - } else { - throw e; - } - } - BackupManager.checkBackupVersion(res); - return res; - } - - /** - * @param info - key backup info dict from getKeyBackupVersion() - */ - public isKeyBackupTrusted(info: IKeyBackupInfo): Promise<TrustInfo> { - 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. - */ - public getKeyBackupEnabled(): boolean | null { - 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. - */ - public enableKeyBackup(info: IKeyBackupInfo): Promise<void> { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - - return this.crypto.backupManager.enableKeyBackup(info); - } - - /** - * Disable backing up of keys. - */ - public disableKeyBackup(): void { - 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. - */ - public async prepareKeyBackupVersion( - password?: string | Uint8Array | null, - opts: IKeyBackupPrepareOpts = { secureSecretStorage: false }, - ): Promise<Pick<IPreparedKeyBackupVersion, "algorithm" | "auth_data" | "recovery_key">> { - 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.storeSecret("m.megolm_backup.v1", encodeBase64(privateKey)); - 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 - */ - public isKeyBackupKeyStored(): Promise<Record<string, SecretStorageKeyDescription> | null> { - return Promise.resolve(this.isSecretStored("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 - */ - public async createKeyBackupVersion(info: IKeyBackupInfo): Promise<IKeyBackupInfo> { - 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<IKeyBackupInfo>(Method.Post, "/room_keys/version", undefined, data, { - prefix: 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.error("Key backup not usable even though we just created it"); - } - - return res; - } - - public async deleteKeyBackupVersion(version: string): Promise<void> { - 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(Method.Delete, path, undefined, undefined, { prefix: ClientPrefix.V3 }); - } - - private makeKeyBackupPath(roomId: undefined, sessionId: undefined, version?: string): IKeyBackupPath; - private makeKeyBackupPath(roomId: string, sessionId: undefined, version?: string): IKeyBackupPath; - private makeKeyBackupPath(roomId: string, sessionId: string, version?: string): IKeyBackupPath; - private makeKeyBackupPath(roomId?: string, sessionId?: string, version?: string): IKeyBackupPath { - let path: string; - 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 - */ - public sendKeyBackup( - roomId: undefined, - sessionId: undefined, - version: string | undefined, - data: IKeyBackup, - ): Promise<void>; - public sendKeyBackup( - roomId: string, - sessionId: undefined, - version: string | undefined, - data: IKeyBackup, - ): Promise<void>; - public sendKeyBackup( - roomId: string, - sessionId: string, - version: string | undefined, - data: IKeyBackup, - ): Promise<void>; - public async sendKeyBackup( - roomId: string | undefined, - sessionId: string | undefined, - version: string | undefined, - data: IKeyBackup, - ): Promise<void> { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - - const path = this.makeKeyBackupPath(roomId!, sessionId!, version); - await this.http.authedRequest(Method.Put, path.path, path.queryData, data, { prefix: ClientPrefix.V3 }); - } - - /** - * Marks all group sessions as needing to be backed up and schedules them to - * upload in the background as soon as possible. - */ - public async scheduleAllGroupSessionsForBackup(): Promise<void> { - 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. - */ - public flagAllGroupSessionsForBackup(): Promise<number> { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - - return this.crypto.backupManager.flagAllGroupSessionsForBackup(); - } - - public isValidRecoveryKey(recoveryKey: string): boolean { - try { - decodeRecoveryKey(recoveryKey); - return true; - } catch (e) { - return false; - } - } - - /** - * Get the raw key for a key backup from the password - * Used when migrating key backups into SSSS - * - * The cross-signing API is currently UNSTABLE and may change without notice. - * - * @param password - Passphrase - * @param backupInfo - Backup metadata from `checkKeyBackup` - * @returns key backup key - */ - public keyBackupKeyFromPassword(password: string, backupInfo: IKeyBackupInfo): Promise<Uint8Array> { - return keyFromAuthData(backupInfo.auth_data, password); - } - - /** - * Get the raw key for a key backup from the recovery key - * Used when migrating key backups into SSSS - * - * The cross-signing API is currently UNSTABLE and may change without notice. - * - * @param recoveryKey - The recovery key - * @returns key backup key - */ - public keyBackupKeyFromRecoveryKey(recoveryKey: string): Uint8Array { - return 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. - */ - public async restoreKeyBackupWithPassword( - password: string, - targetRoomId: undefined, - targetSessionId: undefined, - backupInfo: IKeyBackupInfo, - opts: IKeyBackupRestoreOpts, - ): Promise<IKeyBackupRestoreResult>; - public async restoreKeyBackupWithPassword( - password: string, - targetRoomId: string, - targetSessionId: undefined, - backupInfo: IKeyBackupInfo, - opts: IKeyBackupRestoreOpts, - ): Promise<IKeyBackupRestoreResult>; - public async restoreKeyBackupWithPassword( - password: string, - targetRoomId: string, - targetSessionId: string, - backupInfo: IKeyBackupInfo, - opts: IKeyBackupRestoreOpts, - ): Promise<IKeyBackupRestoreResult>; - public async restoreKeyBackupWithPassword( - password: string, - targetRoomId: string | undefined, - targetSessionId: string | undefined, - backupInfo: IKeyBackupInfo, - opts: IKeyBackupRestoreOpts, - ): Promise<IKeyBackupRestoreResult> { - const privKey = await keyFromAuthData(backupInfo.auth_data, password); - return this.restoreKeyBackup(privKey, targetRoomId!, targetSessionId!, backupInfo, opts); - } - - /** - * Restore from an existing key backup via a private key stored in secret - * storage. - * - * @param 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. - */ - public async restoreKeyBackupWithSecretStorage( - backupInfo: IKeyBackupInfo, - targetRoomId?: string, - targetSessionId?: string, - opts?: IKeyBackupRestoreOpts, - ): Promise<IKeyBackupRestoreResult> { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - const storedKey = await this.getSecret("m.megolm_backup.v1"); - - // ensure that the key is in the right format. If not, fix the key and - // store the fixed version - const fixedKey = fixBackupKey(storedKey); - if (fixedKey) { - const keys = await this.crypto.getSecretStorageKey(); - await this.storeSecret("m.megolm_backup.v1", fixedKey, [keys![0]]); - } - - const privKey = decodeBase64(fixedKey || storedKey!); - return this.restoreKeyBackup(privKey, targetRoomId!, targetSessionId!, backupInfo, opts); - } - - /** - * Restore from an existing key backup via an encoded recovery key. - * - * @param 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. - */ - public restoreKeyBackupWithRecoveryKey( - recoveryKey: string, - targetRoomId: undefined, - targetSessionId: undefined, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise<IKeyBackupRestoreResult>; - public restoreKeyBackupWithRecoveryKey( - recoveryKey: string, - targetRoomId: string, - targetSessionId: undefined, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise<IKeyBackupRestoreResult>; - public restoreKeyBackupWithRecoveryKey( - recoveryKey: string, - targetRoomId: string, - targetSessionId: string, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise<IKeyBackupRestoreResult>; - public restoreKeyBackupWithRecoveryKey( - recoveryKey: string, - targetRoomId: string | undefined, - targetSessionId: string | undefined, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise<IKeyBackupRestoreResult> { - const privKey = decodeRecoveryKey(recoveryKey); - return this.restoreKeyBackup(privKey, targetRoomId!, targetSessionId!, backupInfo, opts); - } - - public async restoreKeyBackupWithCache( - targetRoomId: undefined, - targetSessionId: undefined, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise<IKeyBackupRestoreResult>; - public async restoreKeyBackupWithCache( - targetRoomId: string, - targetSessionId: undefined, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise<IKeyBackupRestoreResult>; - public async restoreKeyBackupWithCache( - targetRoomId: string, - targetSessionId: string, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise<IKeyBackupRestoreResult>; - public async restoreKeyBackupWithCache( - targetRoomId: string | undefined, - targetSessionId: string | undefined, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise<IKeyBackupRestoreResult> { - 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); - } - - private async restoreKeyBackup( - privKey: ArrayLike<number>, - targetRoomId: undefined, - targetSessionId: undefined, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise<IKeyBackupRestoreResult>; - private async restoreKeyBackup( - privKey: ArrayLike<number>, - targetRoomId: string, - targetSessionId: undefined, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise<IKeyBackupRestoreResult>; - private async restoreKeyBackup( - privKey: ArrayLike<number>, - targetRoomId: string, - targetSessionId: string, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise<IKeyBackupRestoreResult>; - private async restoreKeyBackup( - privKey: ArrayLike<number>, - targetRoomId: string | undefined, - targetSessionId: string | undefined, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise<IKeyBackupRestoreResult> { - const cacheCompleteCallback = opts?.cacheCompleteCallback; - const progressCallback = opts?.progressCallback; - - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - - let totalKeyCount = 0; - let keys: IMegolmSessionData[] = []; - - const path = this.makeKeyBackupPath(targetRoomId!, targetSessionId!, backupInfo.version); - - const algorithm = await 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 MatrixError({ errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY })); - } - - // Cache the key, if possible. - // This is async. - this.crypto - .storeSessionBackupPrivateKey(privKey) - .catch((e) => { - logger.warn("Error caching session backup key:", e); - }) - .then(cacheCompleteCallback); - - if (progressCallback) { - progressCallback({ - stage: "fetch", - }); - } - - const res = await this.http.authedRequest<IRoomsKeysResponse | IRoomKeysResponse | IKeyBackupSession>( - Method.Get, - path.path, - path.queryData, - undefined, - { prefix: ClientPrefix.V3 }, - ); - - if ((res as IRoomsKeysResponse).rooms) { - const rooms = (res as IRoomsKeysResponse).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 as IRoomKeysResponse).sessions) { - const sessions = (res as IRoomKeysResponse).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 as IKeyBackupSession, - }); - key.room_id = targetRoomId!; - key.session_id = targetSessionId!; - keys.push(key); - } catch (e) { - 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 }; - } - - public deleteKeysFromBackup(roomId: undefined, sessionId: undefined, version?: string): Promise<void>; - public deleteKeysFromBackup(roomId: string, sessionId: undefined, version?: string): Promise<void>; - public deleteKeysFromBackup(roomId: string, sessionId: string, version?: string): Promise<void>; - public async deleteKeysFromBackup(roomId?: string, sessionId?: string, version?: string): Promise<void> { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - - const path = this.makeKeyBackupPath(roomId!, sessionId!, version); - await this.http.authedRequest(Method.Delete, path.path, path.queryData, undefined, { prefix: 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. - */ - public async sendSharedHistoryKeys(roomId: string, userIds: string[]): Promise<void> { - 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.error("Unknown room. Not sharing decryption keys"); - return; - } - - const deviceInfos = await this.crypto.downloadKeys(userIds); - const devicesByUser: Map<string, DeviceInfo[]> = 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.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. - */ - public getMediaConfig(): Promise<IMediaConfig> { - return this.http.authedRequest(Method.Get, "/config", undefined, undefined, { - prefix: 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. - */ - public getRoom(roomId: string | undefined): Room | null { - 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. - */ - public getRooms(): Room[] { - 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. - */ - public getVisibleRooms(msc3946ProcessDynamicPredecessor = false): Room[] { - 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(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. - */ - public getUser(userId: string): User | null { - return this.store.getUser(userId); - } - - /** - * Retrieve all known users. - * @returns A list of users, or an empty list if there is no data store. - */ - public getUsers(): User[] { - 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. - */ - public setAccountData(eventType: EventType | string, content: IContent): Promise<{}> { - const path = utils.encodeUri("/user/$userId/account_data/$type", { - $userId: this.credentials.userId!, - $type: eventType, - }); - return retryNetworkOperation(5, () => { - return this.http.authedRequest(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 - */ - public getAccountData(eventType: string): MatrixEvent | undefined { - 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. - */ - public async getAccountDataFromServer<T extends { [k: string]: any }>(eventType: string): Promise<T | null> { - 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<T>(); - } - const path = utils.encodeUri("/user/$userId/account_data/$type", { - $userId: this.credentials.userId!, - $type: eventType, - }); - try { - return await this.http.authedRequest(Method.Get, path); - } catch (e) { - if ((<MatrixError>e).data?.errcode === "M_NOT_FOUND") { - return null; - } - throw e; - } - } - - public async deleteAccountData(eventType: string): Promise<void> { - const msc3391DeleteAccountDataServerSupport = this.canSupport.get(Feature.AccountDataDeletion); - // if deletion is not supported overwrite with empty content - if (msc3391DeleteAccountDataServerSupport === ServerSupport.Unsupported) { - await this.setAccountData(eventType, {}); - return; - } - const path = utils.encodeUri("/user/$userId/account_data/$type", { - $userId: this.getSafeUserId(), - $type: eventType, - }); - const options = - msc3391DeleteAccountDataServerSupport === ServerSupport.Unstable - ? { prefix: "/_matrix/client/unstable/org.matrix.msc3391" } - : undefined; - return await this.http.authedRequest(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) - */ - public getIgnoredUsers(): string[] { - 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. - */ - public setIgnoredUsers(userIds: string[]): Promise<{}> { - const content = { ignored_users: {} as Record<string, object> }; - 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 - */ - public isUserIgnored(userId: string): boolean { - 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. - */ - public async joinRoom(roomIdOrAlias: string, opts: IJoinRoomOpts = {}): Promise<Room> { - 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<IThirdPartySigned | void> = Promise.resolve(); - - if (opts.inviteSignUrl) { - const url = new URL(opts.inviteSignUrl); - url.searchParams.set("mxid", this.credentials.userId!); - signPromise = this.http.requestOtherUrl<IThirdPartySigned>(Method.Post, url); - } - - const queryString: Record<string, string | string[]> = {}; - if (opts.viaServers) { - queryString["server_name"] = opts.viaServers; - } - - try { - const data: IJoinRequestBody = {}; - 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<{ room_id: string }>(Method.Post, path, queryString, data); - - const roomId = res.room_id; - const syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions()); - const room = syncApi.createRoom(roomId); - if (opts.syncRoom) { - // v2 will do this for us - // return syncApi.syncRoom(room); - } - return room; - } catch (e) { - throw e; // rethrow for reject - } - } - - /** - * 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. - */ - public resendEvent(event: MatrixEvent, room: Room): Promise<ISendEventResponse> { - // also kick the to-device queue to retry - this.toDeviceMessageQueue.sendQueue(); - - this.updatePendingEventStatus(room, 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 - */ - public cancelPendingEvent(event: MatrixEvent): void { - if (![EventStatus.QUEUED, EventStatus.NOT_SENT, 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 === EventStatus.ENCRYPTING) { - this.pendingEventEncryption.delete(event.getId()!); - } else if (this.scheduler && event.status === 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, EventStatus.CANCELLED); - } - - /** - * @returns Promise which resolves: TODO - * @returns Rejects: with an error response. - */ - public setRoomName(roomId: string, name: string): Promise<ISendEventResponse> { - return this.sendStateEvent(roomId, EventType.RoomName, { name: name }); - } - - /** - * @param htmlTopic - Optional. - * @returns Promise which resolves: TODO - * @returns Rejects: with an error response. - */ - public setRoomTopic(roomId: string, topic: string, htmlTopic?: string): Promise<ISendEventResponse> { - const content = ContentHelpers.makeTopicContent(topic, htmlTopic); - return this.sendStateEvent(roomId, 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. - */ - public getRoomTags(roomId: string): Promise<ITagsResponse> { - const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags", { - $userId: this.credentials.userId!, - $roomId: roomId, - }); - return this.http.authedRequest(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. - */ - public setRoomTag(roomId: string, tagName: string, metadata: ITagMetadata): Promise<{}> { - const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", { - $userId: this.credentials.userId!, - $roomId: roomId, - $tag: tagName, - }); - return this.http.authedRequest(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. - */ - public deleteRoomTag(roomId: string, tagName: string): Promise<{}> { - const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", { - $userId: this.credentials.userId!, - $roomId: roomId, - $tag: tagName, - }); - return this.http.authedRequest(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. - */ - public setRoomAccountData(roomId: string, eventType: string, content: Record<string, any>): Promise<{}> { - const path = utils.encodeUri("/user/$userId/rooms/$roomId/account_data/$type", { - $userId: this.credentials.userId!, - $roomId: roomId, - $type: eventType, - }); - return this.http.authedRequest(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. - */ - public setPowerLevel( - roomId: string, - userId: string | string[], - powerLevel: number | undefined, - event: MatrixEvent | null, - ): Promise<ISendEventResponse> { - let content = { - users: {} as Record<string, number>, - }; - if (event?.getType() === 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(Method.Put, path, undefined, content); - } - - /** - * Create an m.beacon_info event - * @returns - */ - // eslint-disable-next-line @typescript-eslint/naming-convention - public async unstable_createLiveBeacon( - roomId: Room["roomId"], - beaconInfoContent: MBeaconInfoEventContent, - ): Promise<ISendEventResponse> { - 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 - public async unstable_setLiveBeacon( - roomId: string, - beaconInfoContent: MBeaconInfoEventContent, - ): Promise<ISendEventResponse> { - return this.sendStateEvent(roomId, M_BEACON_INFO.name, beaconInfoContent, this.getUserId()!); - } - - public sendEvent(roomId: string, eventType: string, content: IContent, txnId?: string): Promise<ISendEventResponse>; - public sendEvent( - roomId: string, - threadId: string | null, - eventType: string, - content: IContent, - txnId?: string, - ): Promise<ISendEventResponse>; - public sendEvent( - roomId: string, - threadIdOrEventType: string | null, - eventTypeOrContent: string | IContent, - contentOrTxnId?: IContent | string, - txnIdOrVoid?: string, - ): Promise<ISendEventResponse> { - let threadId: string | null; - let eventType: string; - let content: IContent; - let txnId: string | undefined; - if (!threadIdOrEventType?.startsWith(EVENT_ID_PREFIX) && threadIdOrEventType !== null) { - txnId = contentOrTxnId as string; - content = eventTypeOrContent as IContent; - eventType = threadIdOrEventType; - threadId = null; - } else { - txnId = txnIdOrVoid; - content = contentOrTxnId as IContent; - eventType = eventTypeOrContent as string; - 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"] = { - ...content!["m.relates_to"], - rel_type: 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: MatrixEvent) => { - return ev.isRelation(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. - */ - private sendCompleteEvent( - roomId: string, - threadId: string | null, - eventObject: any, - txnId?: string, - ): Promise<ISendEventResponse> { - if (!txnId) { - txnId = this.makeTxnId(); - } - - // We always construct a MatrixEvent when sending because the store and scheduler use them. - // We'll extract the params back out if it turns out the client has no scheduler or store. - const localEvent = new MatrixEvent( - Object.assign(eventObject, { - event_id: "~" + roomId + ":" + txnId, - user_id: this.credentials.userId, - sender: this.credentials.userId, - room_id: roomId, - origin_server_ts: new Date().getTime(), - }), - ); - - const room = this.getRoom(roomId); - 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, [MatrixEventEvent.Replaced, MatrixEventEvent.VisibilityChange]); - room?.reEmitter.reEmit(localEvent, [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(MatrixEventEvent.LocalEventIdReplaced, () => { - localEvent.updateAssociatedId(target.getId()!); - }); - } - - const type = localEvent.getType(); - logger.log(`sendEvent of type ${type} in ${roomId} with txnId ${txnId}`); - - localEvent.setTxnId(txnId); - localEvent.setStatus(EventStatus.SENDING); - - // add this event immediately to the local store as 'sending'. - room?.addPendingEvent(localEvent, txnId); - - // addPendingEvent can change the state to NOT_SENT if it believes - // that there's other events that have failed. We won't bother to - // try sending the event if the state has changed as such. - if (localEvent.status === EventStatus.NOT_SENT) { - return Promise.reject(new Error("Event blocked by other events not yet sent")); - } - - return 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 - */ - protected encryptAndSendEvent(room: Room | null, event: MatrixEvent): Promise<ISendEventResponse> { - 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, EventStatus.ENCRYPTING); - return encryptionPromise.then(() => { - if (!this.pendingEventEncryption.has(event.getId()!)) { - // cancelled via MatrixClient::cancelPendingEvent - cancelled = true; - return; - } - this.updatePendingEventStatus(room, event, EventStatus.SENDING); - }); - }) - .then(() => { - if (cancelled) return {} as ISendEventResponse; - let promise: Promise<ISendEventResponse> | null = 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, EventStatus.QUEUED); - } - } - - if (!promise) { - promise = this.sendEventHttpRequest(event); - if (room) { - promise = promise.then((res) => { - room.updatePendingEvent(event, EventStatus.SENT, res["event_id"]); - return res; - }); - } - } - - return promise; - }) - .catch((err) => { - 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, EventStatus.NOT_SENT); - } catch (e) { - logger.error("Exception in error handler!", (<Error>e).stack || err); - } - if (err instanceof MatrixError) { - err.event = event; - } - throw err; - }); - } - - private encryptEventIfNeeded(event: MatrixEvent, room?: Room): Promise<void> | null { - 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() === 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 - */ - private getEncryptedIfNeededEventType( - roomId: string, - eventType?: EventType | string | null, - ): EventType | string | null | undefined { - if (eventType === EventType.Reaction) return eventType; - return this.isRoomEncrypted(roomId) ? EventType.RoomMessageEncrypted : eventType; - } - - protected updatePendingEventStatus(room: Room | null, event: MatrixEvent, newStatus: EventStatus): void { - if (room) { - room.updatePendingEvent(event, newStatus); - } else { - event.setStatus(newStatus); - } - } - - private sendEventHttpRequest(event: MatrixEvent): Promise<ISendEventResponse> { - 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: string; - - 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, { - $redactsEventId: event.event.redacts!, - ...pathParams, - }); - } else { - path = utils.encodeUri("/rooms/$roomId/send/$eventType/$txnId", pathParams); - } - - return this.http - .authedRequest<ISendEventResponse>(Method.Put, path, undefined, event.getWireContent()) - .then((res) => { - 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`. - */ - public redactEvent( - roomId: string, - eventId: string, - txnId?: string | undefined, - opts?: IRedactOpts, - ): Promise<ISendEventResponse>; - public redactEvent( - roomId: string, - threadId: string | null, - eventId: string, - txnId?: string | undefined, - opts?: IRedactOpts, - ): Promise<ISendEventResponse>; - public redactEvent( - roomId: string, - threadId: string | null, - eventId?: string, - txnId?: string | IRedactOpts, - opts?: IRedactOpts, - ): Promise<ISendEventResponse> { - if (!eventId?.startsWith(EVENT_ID_PREFIX)) { - opts = txnId as IRedactOpts; - txnId = eventId; - eventId = threadId!; - threadId = null; - } - const reason = opts?.reason; - - if ( - opts?.with_relations && - this.canSupport.get(Feature.RelationBasedRedactions) === 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.RelationBasedRedactions) === ServerSupport.Stable - ? MSC3912_RELATION_BASED_REDACTIONS_PROP.stable! - : MSC3912_RELATION_BASED_REDACTIONS_PROP.unstable!]: opts?.with_relations, - } - : {}; - - return this.sendCompleteEvent( - roomId, - threadId, - { - type: EventType.RoomRedaction, - content: { - ...withRelations, - reason, - }, - redacts: eventId, - }, - txnId as string, - ); - } - - /** - * @param txnId - Optional. - * @returns Promise which resolves: to an ISendEventResponse object - * @returns Rejects: with an error response. - */ - public sendMessage(roomId: string, content: IContent, txnId?: string): Promise<ISendEventResponse>; - public sendMessage( - roomId: string, - threadId: string | null, - content: IContent, - txnId?: string, - ): Promise<ISendEventResponse>; - public sendMessage( - roomId: string, - threadId: string | null | IContent, - content?: IContent | string, - txnId?: string, - ): Promise<ISendEventResponse> { - if (typeof threadId !== "string" && threadId !== null) { - txnId = content as string; - content = threadId as IContent; - threadId = null; - } - - const eventType: string = EventType.RoomMessage; - const sendContent: IContent = content as IContent; - - return this.sendEvent(roomId, threadId as string | null, eventType, sendContent, txnId); - } - - /** - * @param txnId - Optional. - * @returns - * @returns Rejects: with an error response. - */ - public sendTextMessage(roomId: string, body: string, txnId?: string): Promise<ISendEventResponse>; - public sendTextMessage( - roomId: string, - threadId: string | null, - body: string, - txnId?: string, - ): Promise<ISendEventResponse>; - public sendTextMessage( - roomId: string, - threadId: string | null, - body: string, - txnId?: string, - ): Promise<ISendEventResponse> { - 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. - */ - public sendNotice(roomId: string, body: string, txnId?: string): Promise<ISendEventResponse>; - public sendNotice( - roomId: string, - threadId: string | null, - body: string, - txnId?: string, - ): Promise<ISendEventResponse>; - public sendNotice( - roomId: string, - threadId: string | null, - body: string, - txnId?: string, - ): Promise<ISendEventResponse> { - 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. - */ - public sendEmoteMessage(roomId: string, body: string, txnId?: string): Promise<ISendEventResponse>; - public sendEmoteMessage( - roomId: string, - threadId: string | null, - body: string, - txnId?: string, - ): Promise<ISendEventResponse>; - public sendEmoteMessage( - roomId: string, - threadId: string | null, - body: string, - txnId?: string, - ): Promise<ISendEventResponse> { - 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. - */ - public sendImageMessage(roomId: string, url: string, info?: IImageInfo, text?: string): Promise<ISendEventResponse>; - public sendImageMessage( - roomId: string, - threadId: string | null, - url: string, - info?: IImageInfo, - text?: string, - ): Promise<ISendEventResponse>; - public sendImageMessage( - roomId: string, - threadId: string | null, - url?: string | IImageInfo, - info?: IImageInfo | string, - text = "Image", - ): Promise<ISendEventResponse> { - if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - text = (info as string) || "Image"; - info = url as IImageInfo; - url = threadId as string; - threadId = null; - } - const content = { - msgtype: 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. - */ - public sendStickerMessage( - roomId: string, - url: string, - info?: IImageInfo, - text?: string, - ): Promise<ISendEventResponse>; - public sendStickerMessage( - roomId: string, - threadId: string | null, - url: string, - info?: IImageInfo, - text?: string, - ): Promise<ISendEventResponse>; - public sendStickerMessage( - roomId: string, - threadId: string | null, - url?: string | IImageInfo, - info?: IImageInfo | string, - text = "Sticker", - ): Promise<ISendEventResponse> { - if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - text = (info as string) || "Sticker"; - info = url as IImageInfo; - url = threadId as string; - threadId = null; - } - const content = { - url: url, - info: info, - body: text, - }; - - return this.sendEvent(roomId, threadId, EventType.Sticker, content); - } - - /** - * @returns Promise which resolves: to a ISendEventResponse object - * @returns Rejects: with an error response. - */ - public sendHtmlMessage(roomId: string, body: string, htmlBody: string): Promise<ISendEventResponse>; - public sendHtmlMessage( - roomId: string, - threadId: string | null, - body: string, - htmlBody: string, - ): Promise<ISendEventResponse>; - public sendHtmlMessage( - roomId: string, - threadId: string | null, - body: string, - htmlBody?: string, - ): Promise<ISendEventResponse> { - if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - htmlBody = body as string; - 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. - */ - public sendHtmlNotice(roomId: string, body: string, htmlBody: string): Promise<ISendEventResponse>; - public sendHtmlNotice( - roomId: string, - threadId: string | null, - body: string, - htmlBody: string, - ): Promise<ISendEventResponse>; - public sendHtmlNotice( - roomId: string, - threadId: string | null, - body: string, - htmlBody?: string, - ): Promise<ISendEventResponse> { - if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - htmlBody = body as string; - 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. - */ - public sendHtmlEmote(roomId: string, body: string, htmlBody: string): Promise<ISendEventResponse>; - public sendHtmlEmote( - roomId: string, - threadId: string | null, - body: string, - htmlBody: string, - ): Promise<ISendEventResponse>; - public sendHtmlEmote( - roomId: string, - threadId: string | null, - body: string, - htmlBody?: string, - ): Promise<ISendEventResponse> { - if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - htmlBody = body as string; - 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. - */ - public async sendReceipt(event: MatrixEvent, receiptType: ReceiptType, body: any, unthreaded = false): Promise<{}> { - 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 = { - ...body, - thread_id: isThread ? event.threadRootId : MAIN_ROOM_TIMELINE, - }; - } - - const promise = this.http.authedRequest<{}>(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. - */ - public async sendReadReceipt( - event: MatrixEvent | null, - receiptType = ReceiptType.Read, - unthreaded = false, - ): Promise<{} | undefined> { - 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, `{}`. - */ - public async setRoomReadMarkers( - roomId: string, - rmEventId: string, - rrEvent?: MatrixEvent, - rpEvent?: MatrixEvent, - ): Promise<{}> { - const room = this.getRoom(roomId); - if (room && room.hasPendingEvent(rmEventId)) { - throw new Error(`Cannot set read marker to a pending event (${rmEventId})`); - } - - // Add the optional RR update, do local echo like `sendReceipt` - let rrEventId: string | undefined; - 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, ReceiptType.Read); - } - - // Add the optional private RR update, do local echo like `sendReceipt` - let rpEventId: string | undefined; - 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, 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. - */ - public getUrlPreview(url: string, ts: number): Promise<IPreviewUrlResponse> { - // 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. - const cachedPreview = this.urlPreviewCache[key]; - if (cachedPreview) { - return cachedPreview; - } - - const resp = this.http.authedRequest<IPreviewUrlResponse>( - Method.Get, - "/preview_url", - { - url, - ts: ts.toString(), - }, - undefined, - { - prefix: 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. - */ - public sendTyping(roomId: string, isTyping: boolean, timeoutMs: number): Promise<{}> { - 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: any = { - typing: isTyping, - }; - if (isTyping) { - data.timeout = timeoutMs ? timeoutMs : 20000; - } - return this.http.authedRequest(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. - */ - public getRoomUpgradeHistory( - roomId: string, - verifyLinks = false, - msc3946ProcessDynamicPredecessor = false, - ): Room[] { - 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]; - } - - private findPredecessorRooms(room: Room, verifyLinks: boolean, msc3946ProcessDynamicPredecessor: boolean): Room[] { - const ret: Room[] = []; - - // 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(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; - } - - private findSuccessorRooms(room: Room, verifyLinks: boolean, msc3946ProcessDynamicPredecessor: boolean): Room[] { - const ret: Room[] = []; - - // Work forwards, looking at tombstone events - let tombstoneEvent = room.currentState.getStateEvents(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(EventType.RoomTombstone, ""); - } - return ret; - } - - /** - * @param reason - Optional. - * @returns Promise which resolves: `{}` an empty object. - * @returns Rejects: with an error response. - */ - public invite(roomId: string, userId: string, reason?: string): Promise<{}> { - 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. - */ - public inviteByEmail(roomId: string, email: string): Promise<{}> { - 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. - */ - public async inviteByThreePid(roomId: string, medium: string, address: string): Promise<{}> { - const path = utils.encodeUri("/rooms/$roomId/invite", { $roomId: roomId }); - - const identityServerUrl = this.getIdentityServerUrl(true); - if (!identityServerUrl) { - return Promise.reject( - new MatrixError({ - error: "No supplied identity server URL", - errcode: "ORG.MATRIX.JSSDK_MISSING_PARAM", - }), - ); - } - const params: Record<string, string> = { - 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(Method.Post, path, undefined, params); - } - - /** - * @returns Promise which resolves: `{}` an empty object. - * @returns Rejects: with an error response. - */ - public leave(roomId: string): Promise<{}> { - 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. - */ - public leaveRoomChain( - roomId: string, - includeFuture = true, - ): Promise<{ [roomId: string]: Error | MatrixError | null }> { - const upgradeHistory = this.getRoomUpgradeHistory(roomId); - - let eligibleToLeave = upgradeHistory; - if (!includeFuture) { - eligibleToLeave = []; - for (const room of upgradeHistory) { - eligibleToLeave.push(room); - if (room.roomId === roomId) { - break; - } - } - } - - const populationResults: { [roomId: string]: Error } = {}; - const promises: Promise<any>[] = []; - - const doLeave = (roomId: string): Promise<void> => { - 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. - */ - public ban(roomId: string, userId: string, reason?: string): Promise<{}> { - 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. - */ - public forget(roomId: string, deleteRoom = true): Promise<{}> { - 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. - */ - public unban(roomId: string, userId: string): Promise<{}> { - // 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(Method.Post, path, undefined, data); - } - - /** - * @param reason - Optional. - * @returns Promise which resolves: `{}` an empty object. - * @returns Rejects: with an error response. - */ - public kick(roomId: string, userId: string, reason?: string): Promise<{}> { - const path = utils.encodeUri("/rooms/$roomId/kick", { - $roomId: roomId, - }); - const data = { - user_id: userId, - reason: reason, - }; - return this.http.authedRequest(Method.Post, path, undefined, data); - } - - private membershipChange( - roomId: string, - userId: string | undefined, - membership: string, - reason?: string, - ): Promise<{}> { - // API returns an empty object - const path = utils.encodeUri("/rooms/$room_id/$membership", { - $room_id: roomId, - $membership: membership, - }); - return this.http.authedRequest(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. - */ - public getPushActionsForEvent(event: MatrixEvent, forceRecalculate = false): IActionsObject | null { - if (!event.getPushActions() || forceRecalculate) { - event.setPushActions(this.pushProcessor.actionsForEvent(event)); - } - return event.getPushActions(); - } - - /** - * @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 - public setProfileInfo(info: "avatar_url", data: { avatar_url: string }): Promise<{}>; - public setProfileInfo(info: "displayname", data: { displayname: string }): Promise<{}>; - public setProfileInfo(info: "avatar_url" | "displayname", data: object): Promise<{}> { - const path = utils.encodeUri("/profile/$userId/$info", { - $userId: this.credentials.userId!, - $info: info, - }); - return this.http.authedRequest(Method.Put, path, undefined, data); - } - - /** - * @returns Promise which resolves: `{}` an empty object. - * @returns Rejects: with an error response. - */ - public async setDisplayName(name: string): Promise<{}> { - 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(UserEvent.DisplayName, user.events.presence, user); - } - return prom; - } - - /** - * @returns Promise which resolves: `{}` an empty object. - * @returns Rejects: with an error response. - */ - public async setAvatarUrl(url: string): Promise<{}> { - 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(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. - */ - public mxcUrlToHttp( - mxcUrl: string, - width?: number, - height?: number, - resizeMethod?: string, - allowDirectLinks?: boolean, - ): string | null { - return 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. - */ - public async setPresence(opts: IPresenceOpts): Promise<void> { - 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(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. - */ - public getPresence(userId: string): Promise<IStatusResponse> { - const path = utils.encodeUri("/presence/$userId/status", { - $userId: userId, - }); - - return this.http.authedRequest(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. - */ - public scrollback(room: Room, limit = 30): Promise<Room> { - 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<Room>((resolve, reject) => { - // wait for a time before doing this request - // (which may be 0 in order not to special case the code paths) - sleep(timeToWaitMs) - .then(() => { - return this.createMessagesRequest( - room.roomId, - room.oldState.paginationToken, - limit, - Direction.Backward, - ); - }) - .then((res: IMessagesResponse) => { - const matrixEvents = res.chunk.map(this.getEventMapper()); - if (res.state) { - const stateEvents = res.state.map(this.getEventMapper()); - room.currentState.setUnknownStateEvents(stateEvents); - } - - const [timelineEvents, threadedEvents] = room.partitionThreadedEvents(matrixEvents); - - this.processAggregatedTimelineEvents(room, timelineEvents); - room.addEventsToTimeline(timelineEvents, true, room.getLiveTimeline()); - this.processThreadEvents(room, threadedEvents, true); - - 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; - } - - public getEventMapper(options?: MapperOpts): EventMapper { - return 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 - */ - public async getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise<Optional<EventTimeline>> { - // 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: Record<string, string | string[]> | undefined = undefined; - if (this.clientOpts?.lazyLoadMembers) { - params = { filter: JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER) }; - } - - // TODO: we should implement a backoff (as per scrollback()) to deal more nicely with HTTP errors. - const res = await this.http.authedRequest<IContextResponse>(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_RELATION_TYPE.name)) { - 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.BACKWARDS)!.setUnknownStateEvents(res.state.map(mapper)); - } else { - timeline = timelineSet.addTimeline(); - timeline.initialiseState(res.state.map(mapper)); - timeline.getState(EventTimeline.FORWARDS)!.paginationToken = res.end; - } - - const [timelineEvents, threadedEvents] = 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); - - // 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 - ); - } - - public async getThreadTimeline(timelineSet: EventTimelineSet, eventId: string): Promise<EventTimeline | undefined> { - 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: Record<string, string | string[]> = { - limit: "0", - }; - if (this.clientOpts?.lazyLoadMembers) { - params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER); - } - - // TODO: we should implement a backoff (as per scrollback()) to deal more nicely with HTTP errors. - const res = await this.http.authedRequest<IContextResponse>(Method.Get, path, params); - const mapper = this.getEventMapper(); - const event = mapper(res.event); - - if (!timelineSet.canContain(event)) { - return undefined; - } - - if (Thread.hasServerSideSupport) { - if (Thread.hasServerSideFwdPaginationSupport) { - if (!timelineSet.thread) { - throw new Error("could not get thread timeline: not a thread timeline"); - } - - const thread = timelineSet.thread; - const resOlder: IRelationsResponse = await this.fetchRelations( - timelineSet.room.roomId, - thread.id, - THREAD_RELATION_TYPE.name, - null, - { dir: Direction.Backward, from: res.start }, - ); - const resNewer: IRelationsResponse = await this.fetchRelations( - timelineSet.room.roomId, - thread.id, - THREAD_RELATION_TYPE.name, - null, - { dir: Direction.Forward, from: res.end }, - ); - 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.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, Direction.Backward); - timeline.setPaginationToken(resNewer.next_batch ?? null, 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_RELATION_TYPE.name, - null, - { dir: Direction.Backward, from: res.start }, - ); - const eventsNewer: IEvent[] = []; - let nextBatch: Optional<string> = res.end; - while (nextBatch) { - const resNewer: IRelationsResponse = await this.fetchRelations( - timelineSet.room.roomId, - thread.id, - THREAD_RELATION_TYPE.name, - null, - { dir: Direction.Forward, from: nextBatch }, - ); - 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.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, Direction.Backward); - timeline.setPaginationToken(null, 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 - */ - public async getLatestTimeline(timelineSet: EventTimelineSet): Promise<Optional<EventTimeline>> { - // 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, - Direction.Backward, - timelineSet.threadListType, - timelineSet.getFilter(), - ); - event = res.chunk?.[0]; - } else if (timelineSet.thread && Thread.hasServerSideSupport) { - const res = await this.fetchRelations( - timelineSet.room.roomId, - timelineSet.thread.id, - THREAD_RELATION_TYPE.name, - null, - { dir: Direction.Backward, limit: 1 }, - ); - event = res.chunk?.[0]; - } else { - const messagesPath = utils.encodeUri("/rooms/$roomId/messages", { - $roomId: timelineSet.room.roomId, - }); - - const params: Record<string, string | string[]> = { - dir: "b", - }; - if (this.clientOpts?.lazyLoadMembers) { - params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER); - } - - const res = await this.http.authedRequest<IMessagesResponse>(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. - public createMessagesRequest( - roomId: string, - fromToken: string | null, - limit = 30, - dir: Direction, - timelineFilter?: Filter, - ): Promise<IMessagesResponse> { - const path = utils.encodeUri("/rooms/$roomId/messages", { $roomId: roomId }); - - const params: Record<string, string> = { - limit: limit.toString(), - dir: dir, - }; - - if (fromToken) { - params.from = fromToken; - } - - let filter: IRoomEventFilter | null = null; - if (this.clientOpts?.lazyLoadMembers) { - // create a shallow copy of LAZY_LOADING_MESSAGES_FILTER, - // so the timelineFilter doesn't get written into it below - filter = Object.assign({}, Filter.LAZY_LOADING_MESSAGES_FILTER); - } - if (timelineFilter) { - // XXX: it's horrific that /messages' filter parameter doesn't match - // /sync's one - see https://matrix.org/jira/browse/SPEC-451 - filter = filter || {}; - Object.assign(filter, timelineFilter.getRoomTimelineFilterComponent()?.toJSON()); - } - if (filter) { - params.filter = JSON.stringify(filter); - } - return this.http.authedRequest(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 - public createThreadListMessagesRequest( - roomId: string, - fromToken: string | null, - limit = 30, - dir = Direction.Backward, - threadListType: ThreadFilterType | null = ThreadFilterType.All, - timelineFilter?: Filter, - ): Promise<IMessagesResponse> { - const path = utils.encodeUri("/rooms/$roomId/threads", { $roomId: roomId }); - - const params: Record<string, string> = { - limit: limit.toString(), - dir: dir, - include: threadFilterTypeToFilter(threadListType), - }; - - if (fromToken) { - params.from = fromToken; - } - - let filter: IRoomEventFilter = {}; - if (this.clientOpts?.lazyLoadMembers) { - // create a shallow copy of LAZY_LOADING_MESSAGES_FILTER, - // so the timelineFilter doesn't get written into it below - 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, - ...timelineFilter.getRoomTimelineFilterComponent()?.toJSON(), - }; - } - if (Object.keys(filter).length) { - params.filter = JSON.stringify(filter); - } - - const opts = { - prefix: - Thread.hasServerSideListSupport === FeatureSupport.Stable - ? "/_matrix/client/v1" - : "/_matrix/client/unstable/org.matrix.msc3856", - }; - - return this.http - .authedRequest<IThreadedMessagesResponse>(Method.Get, path, params, undefined, opts) - .then((res) => ({ - ...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. - */ - public paginateEventTimeline(eventTimeline: EventTimeline, opts: IPaginateOpts): Promise<boolean> { - 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.BACKWARDS : 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: string; - let params: Record<string, string>; - let promise: Promise<boolean>; - - if (isNotifTimeline) { - path = "/notifications"; - params = { - limit: (opts.limit ?? 30).toString(), - only: "highlight", - }; - - if (token && token !== "end") { - params.from = token; - } - - promise = this.http - .authedRequest<INotificationsResponse>(Method.Get, path, params) - .then(async (res) => { - const token = res.next_token; - const matrixEvents: MatrixEvent[] = []; - - res.notifications = res.notifications.filter(noUnsafeEventProps); - - for (let i = 0; i < res.notifications.length; i++) { - const notification = res.notifications[i]; - const event = this.getEventMapper()(notification.event); - event.setPushActions(PushProcessor.actionListToActionsObject(notification.actions)); - 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.hasServerSideFwdPaginationSupport && dir === 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(noUnsafeEventProps).map(this.getEventMapper()); - roomState.setUnknownStateEvents(stateEvents); - } - const token = res.end; - const matrixEvents = res.chunk.filter(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()); - } - - promise = this.fetchRelations(eventTimeline.getRoomId() ?? "", thread.id, THREAD_RELATION_TYPE.name, null, { - dir, - limit: opts.limit, - from: token ?? undefined, - }) - .then(async (res) => { - const mapper = this.getEventMapper(); - const matrixEvents = res.chunk.filter(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, 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(noUnsafeEventProps).map(this.getEventMapper()); - roomState.setUnknownStateEvents(stateEvents); - } - const token = res.end; - const matrixEvents = res.chunk.filter(noUnsafeEventProps).map(this.getEventMapper()); - - const timelineSet = eventTimeline.getTimelineSet(); - const [timelineEvents] = room.partitionThreadedEvents(matrixEvents); - timelineSet.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token); - this.processAggregatedTimelineEvents(room, timelineEvents); - this.processThreadRoots( - room, - timelineEvents.filter((it) => it.getServerAggregatedRelation(THREAD_RELATION_TYPE.name)), - false, - ); - - 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. - */ - public resetNotifTimelineSet(): void { - 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. - */ - public peekInRoom(roomId: string): Promise<Room> { - this.peekSync?.stopPeeking(); - this.peekSync = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions()); - return this.peekSync.peek(roomId); - } - - /** - * Stop any ongoing room peeking. - */ - public stopPeeking(): void { - 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. - */ - public setGuestAccess(roomId: string, opts: IGuestAccessOpts): Promise<void> { - const writePromise = this.sendStateEvent( - roomId, - EventType.RoomGuestAccess, - { - guest_access: opts.allowJoin ? "can_join" : "forbidden", - }, - "", - ); - - let readPromise: Promise<any> = Promise.resolve<any>(undefined); - if (opts.allowRead) { - readPromise = this.sendStateEvent( - roomId, - 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 - */ - public requestRegisterEmailToken( - email: string, - clientSecret: string, - sendAttempt: number, - nextLink?: string, - ): Promise<IRequestTokenResponse> { - 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 - */ - public requestRegisterMsisdnToken( - phoneCountry: string, - phoneNumber: string, - clientSecret: string, - sendAttempt: number, - nextLink?: string, - ): Promise<IRequestMsisdnTokenResponse> { - 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 - */ - public requestAdd3pidEmailToken( - email: string, - clientSecret: string, - sendAttempt: number, - nextLink?: string, - ): Promise<IRequestTokenResponse> { - 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 - */ - public requestAdd3pidMsisdnToken( - phoneCountry: string, - phoneNumber: string, - clientSecret: string, - sendAttempt: number, - nextLink?: string, - ): Promise<IRequestMsisdnTokenResponse> { - 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 - */ - public requestPasswordEmailToken( - email: string, - clientSecret: string, - sendAttempt: number, - nextLink?: string, - ): Promise<IRequestTokenResponse> { - 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 - */ - public requestPasswordMsisdnToken( - phoneCountry: string, - phoneNumber: string, - clientSecret: string, - sendAttempt: number, - nextLink: string, - ): Promise<IRequestMsisdnTokenResponse> { - 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 - */ - private async requestTokenFromEndpoint<T extends IRequestTokenResponse>( - endpoint: string, - params: Record<string, any>, - ): Promise<T> { - 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(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. - */ - public getRoomPushRule(scope: "global" | "device", roomId: string): IPushRule | undefined { - // 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. - */ - public setRoomMutePushRule(scope: "global" | "device", roomId: string, mute: boolean): Promise<void> | undefined { - let promise: Promise<unknown> | undefined; - let hasDontNotifyRule = false; - - // Get the existing room-kind push rule if any - const roomPushRule = this.getRoomPushRule(scope, roomId); - if (roomPushRule?.actions.includes(PushRuleActionName.DontNotify)) { - hasDontNotifyRule = true; - } - - if (!mute) { - // Remove the rule only if it is a muting rule - if (hasDontNotifyRule) { - promise = this.deletePushRule(scope, PushRuleKind.RoomSpecific, roomPushRule!.rule_id); - } - } else { - if (!roomPushRule) { - promise = this.addPushRule(scope, PushRuleKind.RoomSpecific, roomId, { - actions: [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, PushRuleKind.RoomSpecific, roomPushRule.rule_id) - .then(() => { - this.addPushRule(scope, PushRuleKind.RoomSpecific, roomId, { - actions: [PushRuleActionName.DontNotify], - }) - .then(() => { - deferred.resolve(); - }) - .catch((err) => { - deferred.reject(err); - }); - }) - .catch((err) => { - deferred.reject(err); - }); - - promise = deferred.promise; - } - } - - if (promise) { - return new Promise<void>((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: Error) => { - // 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); - }); - }); - }); - } - } - - public searchMessageText(opts: ISearchOpts): Promise<ISearchResponse> { - const roomEvents: ISearchRequestBody["search_categories"]["room_events"] = { - 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. - */ - public searchRoomEvents(opts: IEventSearchOpts): Promise<ISearchResults> { - // TODO: support search groups - - const body = { - search_categories: { - room_events: { - search_term: opts.term, - filter: opts.filter, - order_by: SearchOrderBy.Recent, - event_context: { - before_limit: 1, - after_limit: 1, - include_profile: true, - }, - }, - }, - }; - - const searchResults: ISearchResults = { - _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. - */ - public backPaginateRoomEventsSearch<T extends ISearchResults>(searchResults: T): Promise<T> { - // 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 as Promise<T>; - } - - 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 - public processRoomEventsSearch<T extends ISearchResults>(searchResults: T, response: ISearchResponse): T { - 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<string>(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.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. - */ - public syncLeftRooms(): Promise<Room[]> { - // 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 SyncApi(this, this.clientOpts, this.buildSyncApiOptions()); - this.syncLeftRoomsPromise = syncApi.syncLeftRooms(); - - // cleanup locks - this.syncLeftRoomsPromise - .then(() => { - 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. - */ - public createFilter(content: IFilterDefinition): Promise<Filter> { - const path = utils.encodeUri("/user/$userId/filter", { - $userId: this.credentials.userId!, - }); - return this.http.authedRequest<IFilterResponse>(Method.Post, path, undefined, content).then((response) => { - // persist the filter - const 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. - */ - public getFilter(userId: string, filterId: string, allowCached: boolean): Promise<Filter> { - 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<IFilterDefinition>(Method.Get, path).then((response) => { - // persist the filter - const filter = Filter.fromJson(userId, filterId, response); - this.store.storeFilter(filter); - return filter; - }); - } - - /** - * @returns Filter ID - */ - public async getOrCreateFilter(filterName: string, filter: Filter): Promise<string> { - const filterId = this.store.getFilterIdByName(filterName); - let existingId: string | undefined; - - if (filterId) { - // check that the existing filter matches our expectations - try { - const existingFilter = await this.getFilter(this.credentials.userId!, filterId, true); - if (existingFilter) { - const oldDef = existingFilter.getDefinition(); - const newDef = filter.getDefinition(); - - if (utils.deepCompare(oldDef, newDef)) { - // super, just use that. - // debuglog("Using existing filter ID %s: %s", filterId, - // JSON.stringify(oldDef)); - existingId = filterId; - } - } - } catch (error) { - // Synapse currently returns the following when the filter cannot be found: - // { - // errcode: "M_UNKNOWN", - // name: "M_UNKNOWN", - // message: "No row found", - // } - if ((<MatrixError>error).errcode !== "M_UNKNOWN" && (<MatrixError>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. - */ - public getOpenIdToken(): Promise<IOpenIDToken> { - const path = utils.encodeUri("/user/$userId/openid/request_token", { - $userId: this.credentials.userId!, - }); - - return this.http.authedRequest(Method.Post, path, undefined, {}); - } - - private startCallEventHandler = (): void => { - 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. - */ - private fixupRoomNotifications = (): void => { - if (this.isInitialSyncComplete()) { - const unreadRooms = (this.getRooms() ?? []).filter((room) => { - return room.getUnreadNotificationCount(NotificationCountType.Total) > 0; - }); - - for (const room of unreadRooms) { - const currentUserId = this.getSafeUserId(); - room.fixupNotifications(currentUserId); - } - - this.off(ClientEvent.Sync, this.fixupRoomNotifications); - } - }; - - /** - * @returns Promise which resolves: ITurnServerResponse object - * @returns Rejects: with an error response. - */ - public turnServer(): Promise<ITurnServerResponse> { - return this.http.authedRequest(Method.Get, "/voip/turnServer"); - } - - /** - * Get the TURN servers for this homeserver. - * @returns The servers or an empty list. - */ - public getTurnServers(): ITurnServer[] { - return this.turnServers || []; - } - - /** - * Get the unix timestamp (in milliseconds) at which the current - * TURN credentials (from getTurnServers) expire - * @returns The expiry timestamp in milliseconds - */ - public getTurnServersExpiry(): number { - return this.turnServersExpiry; - } - - public get pollingTurnServers(): boolean { - return this.checkTurnServersIntervalID !== undefined; - } - - // XXX: Intended private, used in code. - public async checkTurnServers(): Promise<boolean | undefined> { - if (!this.canSupportVoip) { - return; - } - - let credentialsGood = false; - const remainingTime = this.turnServersExpiry - Date.now(); - if (remainingTime > TURN_CHECK_INTERVAL) { - logger.debug("TURN creds are valid for another " + remainingTime + " ms: not fetching new ones."); - credentialsGood = true; - } else { - logger.debug("Fetching new TURN credentials"); - try { - const res = await this.turnServer(); - if (res.uris) { - logger.log("Got TURN URIs: " + res.uris + " refresh in " + res.ttl + " secs"); - // map the response to a format that can be fed to RTCPeerConnection - const servers: ITurnServer = { - 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.error("Failed to get TURN URIs", err); - if ((<HTTPError>err).httpStatus === 403) { - // We got a 403, so there's no point in looping forever. - 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, <HTTPError>err, true); // fatal - } else { - // otherwise, if we failed for whatever reason, try again the next time we're called. - this.emit(ClientEvent.TurnServersError, <Error>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. - * - */ - public setFallbackICEServerAllowed(allow: boolean): void { - 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 - */ - public isFallbackICEServerAllowed(): boolean { - 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. - */ - public isSynapseAdministrator(): Promise<boolean> { - const path = utils.encodeUri("/_synapse/admin/v1/users/$userId/admin", { $userId: this.getUserId()! }); - return this.http - .authedRequest<{ admin: boolean }>(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. - */ - public whoisSynapseUser(userId: string): Promise<ISynapseAdminWhoisResponse> { - const path = utils.encodeUri("/_synapse/admin/v1/whois/$userId", { $userId: userId }); - return this.http.authedRequest(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. - */ - public deactivateSynapseUser(userId: string): Promise<ISynapseAdminDeactivateResponse> { - const path = utils.encodeUri("/_synapse/admin/v1/deactivate/$userId", { $userId: userId }); - return this.http.authedRequest(Method.Post, path, undefined, undefined, { prefix: "" }); - } - - private async fetchClientWellKnown(): Promise<void> { - // `getRawClientConfig` does not throw or reject on network errors, instead - // it absorbs errors and returns `{}`. - this.clientWellKnownPromise = AutoDiscovery.getRawClientConfig(this.getDomain() ?? undefined); - this.clientWellKnown = await this.clientWellKnownPromise; - this.emit(ClientEvent.ClientWellKnown, this.clientWellKnown); - } - - public getClientWellKnown(): IClientWellKnown | undefined { - return this.clientWellKnown; - } - - public waitForClientWellKnown(): Promise<IClientWellKnown> { - 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 - */ - public storeClientOptions(): Promise<void> { - // 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<Record<string, any>>((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 - public async _unstable_getSharedRooms(userId: string): Promise<string[]> { - 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<{ joined: string[] }>(Method.Get, path, undefined, undefined, { - prefix: 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 - */ - public async getVersions(): Promise<IServerVersions> { - if (this.serverVersionsPromise) { - return this.serverVersionsPromise; - } - - this.serverVersionsPromise = this.http - .request<IServerVersions>( - 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 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 - */ - public async isVersionSupported(version: string): Promise<boolean> { - 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 - */ - public async doesServerSupportLazyLoading(): Promise<boolean> { - 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 - */ - public async doesServerRequireIdServerParam(): Promise<boolean> { - 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 - */ - public async doesServerAcceptIdentityAccessToken(): Promise<boolean> { - 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 - */ - public async doesServerSupportSeparateAddAndBind(): Promise<boolean> { - 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 - */ - public async doesServerSupportUnstableFeature(feature: string): Promise<boolean> { - 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. - */ - public async doesServerForceEncryptionForPreset(presetName: Preset): Promise<boolean> { - 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}`]; - } - - public async doesServerSupportThread(): Promise<{ - threads: FeatureSupport; - list: FeatureSupport; - fwdPagination: FeatureSupport; - }> { - if (await this.isVersionSupported("v1.4")) { - return { - threads: FeatureSupport.Stable, - list: FeatureSupport.Stable, - fwdPagination: 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: determineFeatureSupport(threadStable, threadUnstable), - list: determineFeatureSupport(listStable, listUnstable), - fwdPagination: determineFeatureSupport(fwdPaginationStable, fwdPaginationUnstable), - }; - } catch (e) { - return { - threads: FeatureSupport.None, - list: FeatureSupport.None, - fwdPagination: 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 - */ - public doesServerSupportLogoutDevices(): Promise<boolean> { - 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 - */ - public hasLazyLoadMembersEnabled(): boolean { - 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. - */ - public setCanResetTimelineCallback(cb: ResetTimelineCallback): void { - this.canResetTimelineCallback = cb; - } - - /** - * Get the callback set via `setCanResetTimelineCallback`. - * @returns The callback or null - */ - public getCanResetTimelineCallback(): ResetTimelineCallback | undefined { - 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. - */ - public async relations( - roomId: string, - eventId: string, - relationType?: RelationType | string | null, - eventType?: EventType | string | null, - opts: IRelationsRequestOpts = { dir: Direction.Backward }, - ): Promise<{ - originalEvent?: MatrixEvent | null; - events: MatrixEvent[]; - nextBatch?: string | null; - prevBatch?: string | null; - }> { - 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 === 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 === 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. - */ - public getCrossSigningCacheCallbacks(): ICacheCallbacks | undefined { - // 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 - */ - public generateClientSecret(): string { - return randomString(32); - } - - /** - * Attempts to decrypt an event - * @param event - The event to decrypt - * @returns A decryption promise - */ - public decryptEventIfNeeded(event: MatrixEvent, options?: IDecryptOptions): Promise<void> { - if (event.shouldAttemptDecryption() && this.isCryptoEnabled()) { - event.attemptDecryption(this.cryptoBackend!, options); - } - - if (event.isBeingDecrypted()) { - return event.getDecryptionPromise()!; - } else { - return Promise.resolve(); - } - } - - private termsUrlForService(serviceType: SERVICE_TYPES, baseUrl: string): URL { - switch (serviceType) { - case SERVICE_TYPES.IS: - return this.http.getUrl("/terms", undefined, IdentityPrefix.V2, baseUrl); - case 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 - */ - public getHomeserverUrl(): string { - 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 - */ - public getIdentityServerUrl(stripProto = false): string | undefined { - 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 - */ - public setIdentityServerUrl(url: string): void { - this.idBaseUrl = utils.ensureNoTrailingSlash(url); - this.http.setIdBaseUrl(this.idBaseUrl); - } - - /** - * Get the access token associated with this account. - * @returns The access_token or null - */ - public getAccessToken(): string | null { - return this.http.opts.accessToken || null; - } - - /** - * Set the access token associated with this account. - * @param token - The new access token. - */ - public setAccessToken(token: string): void { - this.http.opts.accessToken = token; - } - - /** - * @returns true if there is a valid access_token for this client. - */ - public isLoggedIn(): boolean { - return this.http.opts.accessToken !== undefined; - } - - /** - * Make up a new transaction id - * - * @returns a new, unique, transaction id - */ - public makeTxnId(): string { - 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. - */ - public isUsernameAvailable(username: string): Promise<boolean> { - return this.http - .authedRequest<{ available: true }>(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. - */ - public register( - username: string, - password: string, - sessionId: string | null, - auth: { session?: string; type: string }, - bindThreepids?: boolean | null | { email?: boolean; msisdn?: boolean }, - guestAccessToken?: string, - inhibitLogin?: boolean, - ): Promise<IAuthData> { - // backwards compat - if (bindThreepids === true) { - bindThreepids = { email: true }; - } else if (bindThreepids === null || bindThreepids === undefined || bindThreepids === false) { - bindThreepids = {}; - } - if (sessionId) { - auth.session = sessionId; - } - - const params: IRegisterRequestParams = { - 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. - */ - public registerGuest({ body }: { body?: any } = {}): Promise<any> { - // 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. - */ - public registerRequest(data: IRegisterRequestParams, kind?: string): Promise<IAuthData> { - const params: { kind?: string } = {}; - if (kind) { - params.kind = kind; - } - - return this.http.request(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. - */ - public refreshToken(refreshToken: string): Promise<IRefreshTokenResponse> { - return this.http.authedRequest( - Method.Post, - "/refresh", - undefined, - { refresh_token: refreshToken }, - { - prefix: 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. - */ - public loginFlows(): Promise<ILoginFlowsResponse> { - return this.http.request(Method.Get, "/login"); - } - - /** - * @returns Promise which resolves: TODO - * @returns Rejects: with an error response. - */ - public login(loginType: string, data: any): Promise<any> { - // TODO: Types - const loginData = { - type: loginType, - }; - - // merge data into loginData - Object.assign(loginData, data); - - return this.http - .authedRequest<{ - access_token?: string; - user_id?: string; - }>(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. - */ - public loginWithPassword(user: string, password: string): Promise<any> { - // 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. - */ - public loginWithSAML2(relayState: string): Promise<any> { - // 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. - */ - public getCasLoginUrl(redirectUrl: string): string { - 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. - */ - public getSsoLoginUrl(redirectUrl: string, loginType = "sso", idpId?: string, action?: SSOAction): string { - let url = "/login/" + loginType + "/redirect"; - if (idpId) { - url += "/" + idpId; - } - - const params = { - redirectUrl, - [SSO_ACTION_PARAM.unstable!]: action, - }; - - return this.http.getUrl(url, params, ClientPrefix.R0).href; - } - - /** - * @param token - Login token previously received from homeserver - * @returns Promise which resolves: TODO - * @returns Rejects: with an error response. - */ - public loginWithToken(token: string): Promise<any> { - // 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 `{}` - */ - public async logout(stopClient = false): Promise<{}> { - if (this.crypto?.backupManager?.getKeyBackupEnabled()) { - try { - while ((await this.crypto.backupManager.backupPendingKeys(200)) > 0); - } catch (err) { - 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(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 - */ - public deactivateAccount(auth?: any, erase?: boolean): Promise<{}> { - const body: any = {}; - if (auth) { - body.auth = auth; - } - if (erase !== undefined) { - body.erase = erase; - } - - return this.http.authedRequest(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. - */ - public requestLoginToken(auth?: IAuthData): Promise<UIAResponse<LoginTokenPostResponse>> { - const body: UIARequest<{}> = { auth }; - return this.http.authedRequest( - Method.Post, - "/org.matrix.msc3882/login/token", - undefined, // no query params - body, - { prefix: ClientPrefix.Unstable }, - ); - } - - /** - * 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 - */ - public getFallbackAuthUrl(loginType: string, authSessionId: string): string { - const path = utils.encodeUri("/auth/$loginType/fallback/web", { - $loginType: loginType, - }); - - return this.http.getUrl( - path, - { - session: authSessionId, - }, - 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. - */ - public async createRoom(options: ICreateRoomOpts): Promise<{ room_id: string }> { - // 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(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. - */ - public fetchRelations( - roomId: string, - eventId: string, - relationType?: RelationType | string | null, - eventType?: EventType | string | null, - opts: IRelationsRequestOpts = { dir: Direction.Backward }, - ): Promise<IRelationsResponse> { - let params = opts as QueryDict; - if (Thread.hasServerSideFwdPaginationSupport === FeatureSupport.Experimental) { - params = replaceParam("dir", "org.matrix.msc3715.dir", 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.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(Method.Get, path, undefined, undefined, { - prefix: ClientPrefix.V1, - }); - } - - /** - * @returns Promise which resolves: TODO - * @returns Rejects: with an error response. - */ - public roomState(roomId: string): Promise<IStateEventWithRoomId[]> { - const path = utils.encodeUri("/rooms/$roomId/state", { $roomId: roomId }); - return this.http.authedRequest(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. - */ - public fetchRoomEvent(roomId: string, eventId: string): Promise<Partial<IEvent>> { - const path = utils.encodeUri("/rooms/$roomId/event/$eventId", { - $roomId: roomId, - $eventId: eventId, - }); - return this.http.authedRequest(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. - */ - public members( - roomId: string, - includeMembership?: string, - excludeMembership?: string, - atEventId?: string, - ): Promise<{ [userId: string]: IStateEventWithRoomId[] }> { - const queryParams: Record<string, string> = {}; - 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(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. - */ - public upgradeRoom(roomId: string, newVersion: string): Promise<{ replacement_room: string }> { - // eslint-disable-line camelcase - const path = utils.encodeUri("/rooms/$roomId/upgrade", { $roomId: roomId }); - return this.http.authedRequest(Method.Post, path, undefined, { new_version: newVersion }); - } - - /** - * Retrieve a state event. - * @returns Promise which resolves: TODO - * @returns Rejects: with an error response. - */ - public getStateEvent(roomId: string, eventType: string, stateKey: string): Promise<Record<string, any>> { - 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(Method.Get, path); - } - - /** - * @param opts - Options for the request function. - * @returns Promise which resolves: TODO - * @returns Rejects: with an error response. - */ - public sendStateEvent( - roomId: string, - eventType: string, - content: any, - stateKey = "", - opts: IRequestOpts = {}, - ): Promise<ISendEventResponse> { - 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(Method.Put, path, undefined, content, opts); - } - - /** - * @returns Promise which resolves: TODO - * @returns Rejects: with an error response. - */ - public roomInitialSync(roomId: string, limit: number): Promise<IRoomInitialSyncResponse> { - const path = utils.encodeUri("/rooms/$roomId/initialSync", { $roomId: roomId }); - - return this.http.authedRequest(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, `{}`. - */ - public async setRoomReadMarkersHttpRequest( - roomId: string, - rmEventId: string, - rrEventId?: string, - rpEventId?: string, - ): Promise<{}> { - const path = utils.encodeUri("/rooms/$roomId/read_markers", { - $roomId: roomId, - }); - - const content: IContent = { - [ReceiptType.FullyRead]: rmEventId, - [ReceiptType.Read]: rrEventId, - }; - - if ( - (await this.doesServerSupportUnstableFeature("org.matrix.msc2285.stable")) || - (await this.isVersionSupported("v1.4")) - ) { - content[ReceiptType.ReadPrivate] = rpEventId; - } - - return this.http.authedRequest(Method.Post, path, undefined, content); - } - - /** - * @returns Promise which resolves: A list of the user's current rooms - * @returns Rejects: with an error response. - */ - public getJoinedRooms(): Promise<IJoinedRoomsResponse> { - const path = utils.encodeUri("/joined_rooms", {}); - return this.http.authedRequest(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. - */ - public getJoinedRoomMembers(roomId: string): Promise<IJoinedMembersResponse> { - const path = utils.encodeUri("/rooms/$roomId/joined_members", { - $roomId: roomId, - }); - return this.http.authedRequest(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. - */ - public publicRooms({ - server, - limit, - since, - ...options - }: IRoomDirectoryOptions = {}): Promise<IPublicRoomsResponse> { - const queryParams: QueryDict = { server, limit, since }; - if (Object.keys(options).length === 0) { - return this.http.authedRequest(Method.Get, "/publicRooms", queryParams); - } else { - return this.http.authedRequest(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. - */ - public createAlias(alias: string, roomId: string): Promise<{}> { - const path = utils.encodeUri("/directory/room/$alias", { - $alias: alias, - }); - const data = { - room_id: roomId, - }; - return this.http.authedRequest(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. - */ - public deleteAlias(alias: string): Promise<{}> { - const path = utils.encodeUri("/directory/room/$alias", { - $alias: alias, - }); - return this.http.authedRequest(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. - */ - public getLocalAliases(roomId: string): Promise<{ aliases: string[] }> { - const path = utils.encodeUri("/rooms/$roomId/aliases", { $roomId: roomId }); - const prefix = ClientPrefix.V3; - return this.http.authedRequest(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. - */ - public getRoomIdForAlias(alias: string): Promise<{ room_id: string; servers: string[] }> { - // eslint-disable-line camelcase - // TODO: deprecate this or resolveRoomAlias - const path = utils.encodeUri("/directory/room/$alias", { - $alias: alias, - }); - return this.http.authedRequest(Method.Get, path); - } - - /** - * @returns Promise which resolves: Object with room_id and servers. - * @returns Rejects: with an error response. - */ - // eslint-disable-next-line camelcase - public resolveRoomAlias(roomAlias: string): Promise<{ room_id: string; servers: string[] }> { - // TODO: deprecate this or getRoomIdForAlias - const path = utils.encodeUri("/directory/room/$alias", { $alias: roomAlias }); - return this.http.request(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. - */ - public getRoomDirectoryVisibility(roomId: string): Promise<{ visibility: Visibility }> { - const path = utils.encodeUri("/directory/list/room/$roomId", { - $roomId: roomId, - }); - return this.http.authedRequest(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. - */ - public setRoomDirectoryVisibility(roomId: string, visibility: Visibility): Promise<{}> { - const path = utils.encodeUri("/directory/list/room/$roomId", { - $roomId: roomId, - }); - return this.http.authedRequest(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. - */ - public setRoomDirectoryVisibilityAppService( - networkId: string, - roomId: string, - visibility: "public" | "private", - ): Promise<any> { - // TODO: Types - const path = utils.encodeUri("/directory/list/appservice/$networkId/$roomId", { - $networkId: networkId, - $roomId: roomId, - }); - return this.http.authedRequest(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. - */ - public searchUserDirectory({ term, limit }: { term: string; limit?: number }): Promise<IUserDirectoryResponse> { - const body: any = { - search_term: term, - }; - - if (limit !== undefined) { - body.limit = limit; - } - - return this.http.authedRequest(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). - */ - public uploadContent(file: FileType, opts?: UploadOpts): Promise<UploadResponse> { - 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 - */ - public cancelUpload(upload: Promise<UploadResponse>): boolean { - 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 - */ - public getCurrentUploads(): Upload[] { - 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. - */ - public getProfileInfo( - userId: string, - info?: string, - // eslint-disable-next-line camelcase - ): Promise<{ avatar_url?: string; displayname?: string }> { - const path = info - ? utils.encodeUri("/profile/$userId/$info", { $userId: userId, $info: info }) - : utils.encodeUri("/profile/$userId", { $userId: userId }); - return this.http.authedRequest(Method.Get, path); - } - - /** - * @returns Promise which resolves to a list of the user's threepids. - * @returns Rejects: with an error response. - */ - public getThreePids(): Promise<{ threepids: IThreepid[] }> { - return this.http.authedRequest(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. - */ - public addThreePid(creds: any, bind: boolean): Promise<any> { - // TODO: Types - const path = "/account/3pid"; - const data = { - threePidCreds: creds, - bind: bind, - }; - return this.http.authedRequest(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. - */ - public async addThreePidOnly(data: IAddThreePidOnlyBody): Promise<{}> { - const path = "/account/3pid/add"; - const prefix = (await this.isVersionSupported("r0.6.0")) ? ClientPrefix.R0 : ClientPrefix.Unstable; - return this.http.authedRequest(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. - */ - public async bindThreePid(data: IBindThreePidBody): Promise<{}> { - const path = "/account/3pid/bind"; - const prefix = (await this.isVersionSupported("r0.6.0")) ? ClientPrefix.R0 : ClientPrefix.Unstable; - return this.http.authedRequest(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. - */ - public async unbindThreePid( - medium: string, - address: string, - // eslint-disable-next-line camelcase - ): Promise<{ id_server_unbind_result: IdServerUnbindResult }> { - const path = "/account/3pid/unbind"; - const data = { - medium, - address, - id_server: this.getIdentityServerUrl(true), - }; - const prefix = (await this.isVersionSupported("r0.6.0")) ? ClientPrefix.R0 : ClientPrefix.Unstable; - return this.http.authedRequest(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. - */ - public deleteThreePid( - medium: string, - address: string, - // eslint-disable-next-line camelcase - ): Promise<{ id_server_unbind_result: IdServerUnbindResult }> { - const path = "/account/3pid/delete"; - return this.http.authedRequest(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. - */ - public setPassword(authDict: IAuthDict, newPassword: string, logoutDevices?: boolean): Promise<{}> { - const path = "/account/password"; - const data = { - auth: authDict, - new_password: newPassword, - logout_devices: logoutDevices, - }; - - return this.http.authedRequest<{}>(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. - */ - public getDevices(): Promise<{ devices: IMyDevice[] }> { - return this.http.authedRequest(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. - */ - public getDevice(deviceId: string): Promise<IMyDevice> { - const path = utils.encodeUri("/devices/$device_id", { - $device_id: deviceId, - }); - return this.http.authedRequest(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 - public setDeviceDetails(deviceId: string, body: { display_name: string }): Promise<{}> { - const path = utils.encodeUri("/devices/$device_id", { - $device_id: deviceId, - }); - - return this.http.authedRequest(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. - */ - public deleteDevice(deviceId: string, auth?: IAuthDict): Promise<IAuthData | {}> { - const path = utils.encodeUri("/devices/$device_id", { - $device_id: deviceId, - }); - - const body: any = {}; - - if (auth) { - body.auth = auth; - } - - return this.http.authedRequest(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. - */ - public deleteMultipleDevices(devices: string[], auth?: IAuthDict): Promise<IAuthData | {}> { - const body: any = { devices }; - - if (auth) { - body.auth = auth; - } - - const path = "/delete_devices"; - return this.http.authedRequest(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. - */ - public async getPushers(): Promise<{ pushers: IPusher[] }> { - const response = await this.http.authedRequest<{ pushers: IPusher[] }>(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(PUSHER_ENABLED.name)) { - pusher[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. - */ - public setPusher(pusher: IPusherRequest): Promise<{}> { - const path = "/pushers/set"; - return this.http.authedRequest(Method.Post, path, undefined, pusher); - } - - /** - * Persists local notification settings - * @returns Promise which resolves: an empty object - * @returns Rejects: with an error response. - */ - public setLocalNotificationSettings( - deviceId: string, - notificationSettings: LocalNotificationSettings, - ): Promise<{}> { - const key = `${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. - */ - public getPushRules(): Promise<IPushRules> { - return this.http.authedRequest<IPushRules>(Method.Get, "/pushrules/").then((rules: IPushRules) => { - this.setPushRules(rules); - return this.pushRules!; - }); - } - - /** - * Update the push rules for the account. This should be called whenever - * updated push rules are available. - */ - public setPushRules(rules: IPushRules): void { - // Fix-up defaults, if applicable. - this.pushRules = 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. - */ - public addPushRule( - scope: string, - kind: PushRuleKind, - ruleId: Exclude<string, RuleId>, - body: Pick<IPushRule, "actions" | "conditions" | "pattern">, - ): Promise<{}> { - // 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(Method.Put, path, undefined, body); - } - - /** - * @returns Promise which resolves: an empty object `{}` - * @returns Rejects: with an error response. - */ - public deletePushRule(scope: string, kind: PushRuleKind, ruleId: Exclude<string, RuleId>): Promise<{}> { - // 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(Method.Delete, path); - } - - /** - * Enable or disable a push notification rule. - * @returns Promise which resolves: to an empty object `{}` - * @returns Rejects: with an error response. - */ - public setPushRuleEnabled( - scope: string, - kind: PushRuleKind, - ruleId: RuleId | string, - enabled: boolean, - ): Promise<{}> { - const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId/enabled", { - $kind: kind, - $ruleId: ruleId, - }); - return this.http.authedRequest(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. - */ - public setPushRuleActions( - scope: string, - kind: PushRuleKind, - ruleId: RuleId | string, - actions: PushRuleAction[], - ): Promise<{}> { - const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId/actions", { - $kind: kind, - $ruleId: ruleId, - }); - return this.http.authedRequest(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. - */ - public search( - { body, next_batch: nextBatch }: { body: ISearchRequestBody; next_batch?: string }, - abortSignal?: AbortSignal, - ): Promise<ISearchResponse> { - const queryParams: any = {}; - if (nextBatch) { - queryParams.next_batch = nextBatch; - } - return this.http.authedRequest(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}). - */ - public uploadKeysRequest(content: IUploadKeysRequest, opts?: void): Promise<IKeysUploadResponse> { - return this.http.authedRequest(Method.Post, "/keys/upload", undefined, content); - } - - public uploadKeySignatures(content: KeySignatures): Promise<IUploadKeySignaturesResponse> { - return this.http.authedRequest(Method.Post, "/keys/signatures/upload", undefined, content, { - prefix: 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}). - */ - public downloadKeysForUsers(userIds: string[], { token }: { token?: string } = {}): Promise<IDownloadKeyResult> { - const content: IQueryKeysRequest = { - device_keys: {}, - }; - if (token !== undefined) { - content.token = token; - } - userIds.forEach((u) => { - content.device_keys[u] = []; - }); - - return this.http.authedRequest(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}). - */ - public claimOneTimeKeys( - devices: [string, string][], - keyAlgorithm = "signed_curve25519", - timeout?: number, - ): Promise<IClaimOTKsResult> { - const queries: Record<string, Record<string, string>> = {}; - - if (keyAlgorithm === undefined) { - keyAlgorithm = "signed_curve25519"; - } - - for (const [userId, deviceId] of devices) { - const query = queries[userId] || {}; - queries[userId] = query; - query[deviceId] = keyAlgorithm; - } - const content: IClaimKeysRequest = { one_time_keys: queries }; - if (timeout) { - content.timeout = timeout; - } - const path = "/keys/claim"; - return this.http.authedRequest(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}). - */ - public getKeyChanges(oldToken: string, newToken: string): Promise<{ changed: string[]; left: string[] }> { - const qps = { - from: oldToken, - to: newToken, - }; - - return this.http.authedRequest(Method.Get, "/keys/changes", qps); - } - - public uploadDeviceSigningKeys(auth?: IAuthData, keys?: CrossSigningKeys): Promise<{}> { - // API returns empty object - const data = Object.assign({}, keys); - if (auth) Object.assign(data, { auth }); - return this.http.authedRequest(Method.Post, "/keys/device_signing/upload", undefined, data, { - prefix: 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. - */ - public registerWithIdentityServer(hsOpenIdToken: IOpenIDToken): Promise<{ - access_token: string; - token: string; - }> { - if (!this.idBaseUrl) { - throw new Error("No identity server base URL set"); - } - - const uri = this.http.getUrl("/account/register", undefined, IdentityPrefix.V2, this.idBaseUrl); - return this.http.requestOtherUrl(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 - */ - public requestEmailToken( - email: string, - clientSecret: string, - sendAttempt: number, - nextLink?: string, - identityAccessToken?: string, - ): Promise<IRequestTokenResponse> { - const params: Record<string, string> = { - client_secret: clientSecret, - email: email, - send_attempt: sendAttempt?.toString(), - }; - if (nextLink) { - params.next_link = nextLink; - } - - return this.http.idServerRequest<IRequestTokenResponse>( - Method.Post, - "/validate/email/requestToken", - params, - 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 - */ - public requestMsisdnToken( - phoneCountry: string, - phoneNumber: string, - clientSecret: string, - sendAttempt: number, - nextLink?: string, - identityAccessToken?: string, - ): Promise<IRequestMsisdnTokenResponse> { - const params: Record<string, string> = { - client_secret: clientSecret, - country: phoneCountry, - phone_number: phoneNumber, - send_attempt: sendAttempt?.toString(), - }; - if (nextLink) { - params.next_link = nextLink; - } - - return this.http.idServerRequest<IRequestMsisdnTokenResponse>( - Method.Post, - "/validate/msisdn/requestToken", - params, - 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, currently with no parameters. - * @returns Rejects: with an error response. - * @throws Error if No identity server is set - */ - public submitMsisdnToken( - sid: string, - clientSecret: string, - msisdnToken: string, - identityAccessToken: string, - ): Promise<any> { - // TODO: Types - const params = { - sid: sid, - client_secret: clientSecret, - token: msisdnToken, - }; - - return this.http.idServerRequest( - Method.Post, - "/validate/msisdn/submitToken", - params, - 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, currently with no parameters. - * @returns Rejects: with an error response. - */ - public submitMsisdnTokenOtherUrl( - url: string, - sid: string, - clientSecret: string, - msisdnToken: string, - ): Promise<any> { - // TODO: Types - const params = { - sid: sid, - client_secret: clientSecret, - token: msisdnToken, - }; - return this.http.requestOtherUrl(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. - */ - public getIdentityHashDetails(identityAccessToken: string): Promise<any> { - // TODO: Types - return this.http.idServerRequest( - Method.Get, - "/hash_details", - undefined, - 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. - */ - public async identityHashedLookup( - addressPairs: [string, string][], - identityAccessToken: string, - ): Promise<{ address: string; mxid: string }[]> { - const params: Record<string, string | string[]> = { - // 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: Record<string, string> = { - // 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<{ - mappings: { [address: string]: string }; - }>(Method.Post, "/lookup", params, IdentityPrefix.V2, identityAccessToken); - - if (!response?.["mappings"]) return []; // no results - - const foundAddresses: { address: string; mxid: string }[] = []; - 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. - */ - public async lookupThreePid(medium: string, address: string, identityAccessToken: string): Promise<any> { - // 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. - */ - public async bulkLookupThreePids(query: [string, string][], identityAccessToken: string): Promise<any> { - // 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: [medium: string, address: string, mxid: string][] = []; - 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. - */ - public getIdentityAccount(identityAccessToken: string): Promise<any> { - // TODO: Types - return this.http.idServerRequest(Method.Get, "/account", undefined, 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 `{}` - */ - public sendToDevice(eventType: string, contentMap: SendToDeviceContentMap, txnId?: string): Promise<{}> { - const path = utils.encodeUri("/sendToDevice/$eventType/$txnId", { - $eventType: eventType, - $txnId: txnId ? txnId : this.makeTxnId(), - }); - - const body = { - messages: utils.recursiveMapToObject(contentMap), - }; - - const targets = new Map<string, string[]>(); - - for (const [userId, deviceMessages] of contentMap) { - targets.set(userId, Array.from(deviceMessages.keys())); - } - - logger.log(`PUT ${path}`, targets); - - return this.http.authedRequest(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 - */ - public queueToDevice(batch: ToDeviceBatch): Promise<void> { - 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 - */ - public getThirdpartyProtocols(): Promise<{ [protocol: string]: IProtocol }> { - return this.http - .authedRequest<Record<string, IProtocol>>(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 - */ - public getThirdpartyLocation( - protocol: string, - params: { searchFields?: string[] }, - ): Promise<IThirdPartyLocation[]> { - const path = utils.encodeUri("/thirdparty/location/$protocol", { - $protocol: protocol, - }); - - return this.http.authedRequest(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 - */ - public getThirdpartyUser(protocol: string, params: any): Promise<IThirdPartyUser[]> { - // TODO: Types - const path = utils.encodeUri("/thirdparty/user/$protocol", { - $protocol: protocol, - }); - - return this.http.authedRequest(Method.Get, path, params); - } - - public getTerms(serviceType: SERVICE_TYPES, baseUrl: string): Promise<any> { - // TODO: Types - const url = this.termsUrlForService(serviceType, baseUrl); - return this.http.requestOtherUrl(Method.Get, url); - } - - public agreeToTerms( - serviceType: SERVICE_TYPES, - baseUrl: string, - accessToken: string, - termsUrls: string[], - ): Promise<{}> { - const url = this.termsUrlForService(serviceType, baseUrl); - const headers = { - Authorization: "Bearer " + accessToken, - }; - return this.http.requestOtherUrl( - 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 - */ - public reportEvent(roomId: string, eventId: string, score: number, reason: string): Promise<{}> { - const path = utils.encodeUri("/rooms/$roomId/report/$eventId", { - $roomId: roomId, - $eventId: eventId, - }); - - return this.http.authedRequest(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. - */ - public getRoomHierarchy( - roomId: string, - limit?: number, - maxDepth?: number, - suggestedOnly = false, - fromToken?: string, - ): Promise<IRoomHierarchy> { - const path = utils.encodeUri("/rooms/$roomId/hierarchy", { - $roomId: roomId, - }); - - const queryParams: QueryDict = { - suggested_only: String(suggestedOnly), - max_depth: maxDepth?.toString(), - from: fromToken, - limit: limit?.toString(), - }; - - return this.http - .authedRequest<IRoomHierarchy>(Method.Get, path, queryParams, undefined, { - prefix: ClientPrefix.V1, - }) - .catch((e) => { - if (e.errcode === "M_UNRECOGNIZED") { - // fall back to the prefixed hierarchy API. - return this.http.authedRequest<IRoomHierarchy>(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. - */ - public async unstableCreateFileTree(name: string): Promise<MSC3089TreeSpace> { - const { room_id: roomId } = await this.createRoom({ - name: name, - preset: Preset.PrivateChat, - power_level_content_override: { - ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, - users: { - [this.getUserId()!]: 100, - }, - }, - creation_content: { - [RoomCreateTypeField]: RoomType.Space, - }, - initial_state: [ - { - type: UNSTABLE_MSC3088_PURPOSE.name, - state_key: UNSTABLE_MSC3089_TREE_SUBTYPE.name, - content: { - [UNSTABLE_MSC3088_ENABLED.name]: true, - }, - }, - { - type: EventType.RoomEncryption, - state_key: "", - content: { - algorithm: olmlib.MEGOLM_ALGORITHM, - }, - }, - ], - }); - return new 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. - */ - public unstableGetFileTreeSpace(roomId: string): MSC3089TreeSpace | null { - const room = this.getRoom(roomId); - if (room?.getMyMembership() !== "join") return null; - - const createEvent = room.currentState.getStateEvents(EventType.RoomCreate, ""); - const purposeEvent = room.currentState.getStateEvents( - UNSTABLE_MSC3088_PURPOSE.name, - UNSTABLE_MSC3089_TREE_SUBTYPE.name, - ); - - if (!createEvent) throw new Error("Expected single room create event"); - - if (!purposeEvent?.getContent()?.[UNSTABLE_MSC3088_ENABLED.name]) return null; - if (createEvent.getContent()?.[RoomCreateTypeField] !== RoomType.Space) return null; - - return new 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. - */ - public slidingSync( - req: MSC3575SlidingSyncRequest, - proxyBaseUrl?: string, - abortSignal?: AbortSignal, - ): Promise<MSC3575SlidingSyncResponse> { - const qps: Record<string, any> = {}; - 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<MSC3575SlidingSyncResponse>(Method.Post, "/sync", qps, req, { - prefix: "/_matrix/client/unstable/org.matrix.msc3575", - baseUrl: proxyBaseUrl, - localTimeoutMs: clientTimeout, - abortSignal, - }); - } - - /** - * @deprecated use supportsThreads() instead - */ - public supportsExperimentalThreads(): boolean { - 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 - */ - public supportsThreads(): boolean { - return this.clientOpts?.threadSupport || false; - } - - /** - * A helper to determine intentional mentions support - * @returns a boolean to determine if intentional mentions are enabled - * @experimental - */ - public supportsIntentionalMentions(): boolean { - 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. - */ - public async getRoomSummary(roomIdOrAlias: string, via?: string[]): Promise<IRoomSummary> { - const path = utils.encodeUri("/rooms/$roomid/summary", { $roomid: roomIdOrAlias }); - return this.http.authedRequest(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 - */ - public processThreadEvents(room: Room, threadedEvents: MatrixEvent[], toStartOfTimeline: boolean): void { - 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 - */ - public processThreadRoots(room: Room, threadedEvents: MatrixEvent[], toStartOfTimeline: boolean): void { - room.processThreadRoots(threadedEvents, toStartOfTimeline); - } - - public processBeaconEvents(room?: Room, events?: MatrixEvent[]): void { - 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 - */ - public processAggregatedTimelineEvents(room?: Room, events?: MatrixEvent[]): void { - 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. - */ - public async whoami(): Promise<IWhoamiResponse> { - return this.http.authedRequest(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) - */ - public async timestampToEvent( - roomId: string, - timestamp: number, - dir: Direction, - ): Promise<TimestampToEventResponse> { - const path = utils.encodeUri("/rooms/$roomId/timestamp_to_event", { - $roomId: roomId, - }); - const queryParams = { - ts: timestamp.toString(), - dir: dir, - }; - - try { - return await this.http.authedRequest(Method.Get, path, queryParams, undefined, { - prefix: 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 ( - (<MatrixError>err).errcode === "M_UNRECOGNIZED" && - // XXX: The 400 status code check should be removed in the future - // when Synapse is compliant with MSC3743. - ((<MatrixError>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. - (<MatrixError>err).httpStatus === 404 || - (<MatrixError>err).httpStatus === 405) - ) { - return await this.http.authedRequest(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 - */ -export function fixNotificationCountOnDecryption(cli: MatrixClient, event: MatrixEvent): void { - 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(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, NotificationCountType.Highlight, newCount); - } else { - room.setUnreadNotificationCount(NotificationCountType.Highlight, newCount); - } - } - - // Total count is used to typically increment a room notification counter, but not loudly highlight it. - const currentTotalCount = room.getUnreadCountForEventContext(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, - NotificationCountType.Total, - currentTotalCount + 1, - ); - } else { - room.setUnreadNotificationCount(NotificationCountType.Total, currentTotalCount + 1); - } - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/common-crypto/CryptoBackend.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/common-crypto/CryptoBackend.ts deleted file mode 100644 index a0b4621..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/common-crypto/CryptoBackend.ts +++ /dev/null @@ -1,170 +0,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. -*/ - -import type { IToDeviceEvent } from "../sync-accumulator"; -import { MatrixEvent } from "../models/event"; -import { Room } from "../models/room"; -import { CryptoApi } from "../crypto-api"; -import { DeviceTrustLevel, UserTrustLevel } from "../crypto/CrossSigning"; -import { IEncryptedEventInfo } from "../crypto/api"; -import { IEventDecryptionResult } from "../@types/crypto"; - -/** - * Common interface for the crypto implementations - */ -export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi { - /** - * Whether sendMessage in a room with unknown and unverified devices - * should throw an error and not send the message. This has 'Global' for - * symmetry with setGlobalBlacklistUnverifiedDevices but there is currently - * no room-level equivalent for this setting. - * - * @remarks this is here, rather than in `CryptoApi`, because I don't think we're - * going to support it in the rust crypto implementation. - */ - globalErrorOnUnknownDevices: boolean; - - /** - * Shut down any background processes related to crypto - */ - stop(): void; - - /** - * Get the verification level for a given user - * - * TODO: define this better - * - * @param userId - user to be checked - */ - checkUserTrust(userId: string): UserTrustLevel; - - /** - * Get the verification level for a given device - * - * TODO: define this better - * - * @param userId - user to be checked - * @param deviceId - device to be checked - */ - checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel; - - /** - * 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 - */ - encryptEvent(event: MatrixEvent, room: Room): Promise<void>; - - /** - * Decrypt a received event - * - * @returns a promise which resolves once we have finished decrypting. - * Rejects with an error if there is a problem decrypting the event. - */ - decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult>; - - /** - * Get information about the encryption of an event - * - * @param event - event to be checked - */ - getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo; -} - -/** The methods which crypto implementations should expose to the Sync api */ -export interface SyncCryptoCallbacks { - /** - * Called by the /sync loop whenever there are incoming to-device messages. - * - * The implementation may preprocess the received messages (eg, decrypt them) and return an - * updated list of messages for dispatch to the rest of the system. - * - * Note that, unlike {@link ClientEvent.ToDeviceEvent} events, this is called on the raw to-device - * messages, rather than the results of any decryption attempts. - * - * @param events - the received to-device messages - * @returns A list of preprocessed to-device messages. - */ - preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]>; - - /** - * Called by the /sync loop whenever there are incoming to-device messages. - * - * The implementation may preprocess the received messages (eg, decrypt them) and return an - * updated list of messages for dispatch to the rest of the system. - * - * Note that, unlike {@link ClientEvent.ToDeviceEvent} events, this is called on the raw to-device - * messages, rather than the results of any decryption attempts. - * - * @param oneTimeKeysCounts - the received one time key counts - * @returns A list of preprocessed to-device messages. - */ - preprocessOneTimeKeyCounts(oneTimeKeysCounts: Map<string, number>): Promise<void>; - - /** - * Called by the /sync loop whenever there are incoming to-device messages. - * - * The implementation may preprocess the received messages (eg, decrypt them) and return an - * updated list of messages for dispatch to the rest of the system. - * - * Note that, unlike {@link ClientEvent.ToDeviceEvent} events, this is called on the raw to-device - * messages, rather than the results of any decryption attempts. - * - * @param unusedFallbackKeys - the received unused fallback keys - * @returns A list of preprocessed to-device messages. - */ - preprocessUnusedFallbackKeys(unusedFallbackKeys: Set<string>): Promise<void>; - - /** - * Called by the /sync loop whenever an m.room.encryption event is received. - * - * This is called before RoomStateEvents are emitted for any of the events in the /sync - * response (even if the other events technically happened first). This works around a problem - * if the client uses a RoomStateEvent (typically a membership event) as a trigger to send a message - * in a new room (or one where encryption has been newly enabled): that would otherwise leave the - * crypto layer confused because it expects crypto to be set up, but it has not yet been. - * - * @param room - in which the event was received - * @param event - encryption event to be processed - */ - onCryptoEvent(room: Room, event: MatrixEvent): Promise<void>; - - /** - * Called by the /sync loop after each /sync response is processed. - * - * Used to complete batch processing, or to initiate background processes - * - * @param syncState - information about the completed sync. - */ - onSyncCompleted(syncState: OnSyncCompletedData): void; -} - -export interface OnSyncCompletedData { - /** - * The 'next_batch' result from /sync, which will become the 'since' token for the next call to /sync. - */ - nextSyncToken?: string; - - /** - * True if we are working our way through a backlog of events after connecting. - */ - catchingUp?: boolean; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/common-crypto/README.md b/includes/external/matrix/node_modules/matrix-js-sdk/src/common-crypto/README.md deleted file mode 100644 index 7af3298..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/common-crypto/README.md +++ /dev/null @@ -1,4 +0,0 @@ -This directory contains functionality which is common to both the legacy (libolm-based) crypto implementation, -and the new rust-based implementation. - -It is an internal module, and is _not_ directly exposed to applications. diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/content-helpers.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/content-helpers.ts deleted file mode 100644 index 6790886..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/content-helpers.ts +++ /dev/null @@ -1,288 +0,0 @@ -/* -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. -*/ - -import { MBeaconEventContent, MBeaconInfoContent, MBeaconInfoEventContent } from "./@types/beacon"; -import { MsgType } from "./@types/event"; -import { M_TEXT, REFERENCE_RELATION } from "./@types/extensible_events"; -import { isProvided } from "./extensible_events_v1/utilities"; -import { - M_ASSET, - LocationAssetType, - M_LOCATION, - M_TIMESTAMP, - LocationEventWireContent, - MLocationEventContent, - MLocationContent, - MAssetContent, - LegacyLocationEventContent, -} from "./@types/location"; -import { MRoomTopicEventContent, MTopicContent, M_TOPIC } from "./@types/topic"; -import { IContent } from "./models/event"; - -/** - * 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 - */ -export function makeHtmlMessage(body: string, htmlBody: string): IContent { - return { - msgtype: 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 - */ -export function makeHtmlNotice(body: string, htmlBody: string): IContent { - return { - msgtype: 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 - */ -export function makeHtmlEmote(body: string, htmlBody: string): IContent { - return { - msgtype: 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 - */ -export function makeTextMessage(body: string): IContent { - return { - msgtype: MsgType.Text, - body: body, - }; -} - -/** - * Generates the content for a Plaintext Notice event - * @param body - the plaintext body of the notice - * @returns - */ -export function makeNotice(body: string): IContent { - return { - msgtype: MsgType.Notice, - body: body, - }; -} - -/** - * Generates the content for a Plaintext Emote event - * @param body - the plaintext body of the emote - * @returns - */ -export function makeEmoteMessage(body: string): IContent { - return { - msgtype: MsgType.Emote, - body: body, - }; -} - -/** Location content helpers */ - -export const getTextForLocationEvent = ( - uri: string | undefined, - assetType: LocationAssetType, - timestamp?: number, - description?: string | null, -): string => { - const date = `at ${new Date(timestamp!).toISOString()}`; - const assetName = assetType === 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 - */ -export const makeLocationContent = ( - // this is first but optional - // to avoid a breaking change - text?: string, - uri?: string, - timestamp?: number, - description?: string | null, - assetType?: LocationAssetType, -): LegacyLocationEventContent & MLocationEventContent => { - const defaultedText = - text ?? getTextForLocationEvent(uri, assetType || LocationAssetType.Self, timestamp, description); - const timestampEvent = timestamp ? { [M_TIMESTAMP.name]: timestamp } : {}; - return { - msgtype: MsgType.Location, - body: defaultedText, - geo_uri: uri, - [M_LOCATION.name]: { - description, - uri, - }, - [M_ASSET.name]: { - type: assetType || LocationAssetType.Self, - }, - [M_TEXT.name]: defaultedText, - ...timestampEvent, - } as LegacyLocationEventContent & MLocationEventContent; -}; - -/** - * Parse location event content and transform to - * a backwards compatible modern m.location event format - */ -export const parseLocationEvent = (wireEventContent: LocationEventWireContent): MLocationEventContent => { - const location = M_LOCATION.findIn<MLocationContent>(wireEventContent); - const asset = M_ASSET.findIn<MAssetContent>(wireEventContent); - const timestamp = M_TIMESTAMP.findIn<number>(wireEventContent); - const text = M_TEXT.findIn<string>(wireEventContent); - - const geoUri = location?.uri ?? wireEventContent?.geo_uri; - const description = location?.description; - const assetType = asset?.type ?? LocationAssetType.Self; - const fallbackText = text ?? wireEventContent.body; - - return makeLocationContent(fallbackText, geoUri, timestamp ?? undefined, description, assetType); -}; - -/** - * Topic event helpers - */ -export type MakeTopicContent = (topic: string, htmlTopic?: string) => MRoomTopicEventContent; - -export const makeTopicContent: MakeTopicContent = (topic, htmlTopic) => { - const renderings = [{ body: topic, mimetype: "text/plain" }]; - if (isProvided(htmlTopic)) { - renderings.push({ body: htmlTopic!, mimetype: "text/html" }); - } - return { topic, [M_TOPIC.name]: renderings }; -}; - -export type TopicState = { - text: string; - html?: string; -}; - -export const parseTopicContent = (content: MRoomTopicEventContent): TopicState => { - const mtopic = M_TOPIC.findIn<MTopicContent>(content); - if (!Array.isArray(mtopic)) { - return { text: content.topic }; - } - const text = mtopic?.find((r) => !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 - */ -export type MakeBeaconInfoContent = ( - timeout: number, - isLive?: boolean, - description?: string, - assetType?: LocationAssetType, - timestamp?: number, -) => MBeaconInfoEventContent; - -export const makeBeaconInfoContent: MakeBeaconInfoContent = (timeout, isLive, description, assetType, timestamp) => ({ - description, - timeout, - live: isLive, - [M_TIMESTAMP.name]: timestamp || Date.now(), - [M_ASSET.name]: { - type: assetType ?? LocationAssetType.Self, - }, -}); - -export type BeaconInfoState = MBeaconInfoContent & { - assetType?: LocationAssetType; - timestamp?: number; -}; -/** - * Flatten beacon info event content - */ -export const parseBeaconInfoContent = (content: MBeaconInfoEventContent): BeaconInfoState => { - const { description, timeout, live } = content; - const timestamp = M_TIMESTAMP.findIn<number>(content) ?? undefined; - const asset = M_ASSET.findIn<MAssetContent>(content); - - return { - description, - timeout, - live, - assetType: asset?.type, - timestamp, - }; -}; - -export type MakeBeaconContent = ( - uri: string, - timestamp: number, - beaconInfoEventId: string, - description?: string, -) => MBeaconEventContent; - -export const makeBeaconContent: MakeBeaconContent = (uri, timestamp, beaconInfoEventId, description) => ({ - [M_LOCATION.name]: { - description, - uri, - }, - [M_TIMESTAMP.name]: timestamp, - "m.relates_to": { - rel_type: REFERENCE_RELATION.name, - event_id: beaconInfoEventId, - }, -}); - -export type BeaconLocationState = Omit<MLocationContent, "uri"> & { - uri?: string; // override from MLocationContent to allow optionals - timestamp?: number; -}; - -export const parseBeaconContent = (content: MBeaconEventContent): BeaconLocationState => { - const location = M_LOCATION.findIn<MLocationContent>(content); - const timestamp = M_TIMESTAMP.findIn<number>(content) ?? undefined; - - return { - description: location?.description, - uri: location?.uri, - timestamp, - }; -}; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/content-repo.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/content-repo.ts deleted file mode 100644 index 2575412..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/content-repo.ts +++ /dev/null @@ -1,79 +0,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. -*/ - -import * as utils from "./utils"; - -/** - * 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. - */ -export function getHttpUriForMxc( - baseUrl: string, - mxc?: string, - width?: number, - height?: number, - resizeMethod?: string, - allowDirectLinks = false, -): string { - 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: Record<string, string> = {}; - - 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 ? "" : "?" + utils.encodeParams(params); - return baseUrl + prefix + serverAndMediaId + urlParams + fragment; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto-api.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto-api.ts deleted file mode 100644 index 50617c9..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto-api.ts +++ /dev/null @@ -1,75 +0,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. -*/ - -import type { IMegolmSessionData } from "./@types/crypto"; -import { Room } from "./models/room"; - -/** - * 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. - */ -export interface CryptoApi { - /** - * 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. - * - * If true, all unverified devices will be blacklisted by default - */ - globalBlacklistUnverifiedDevices: boolean; - - /** - * 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. - * - * @returns true if the user has previously published cross-signing keys - */ - userHasCrossSigningKeys(): Promise<boolean>; - - /** - * 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: Room): void; - - /** - * Discard any existing megolm session for the given room. - * - * This will ensure that a new session is created on the next call to {@link prepareToEncrypt}, - * or the next time a message is sent. - * - * This should not normally be necessary: it should only be used as a debugging tool if there has been a - * problem with encryption. - * - * @param roomId - the room to discard sessions for - */ - forceDiscardSession(roomId: string): Promise<void>; - - /** - * 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 - */ - exportRoomKeys(): Promise<IMegolmSessionData[]>; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/CrossSigning.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/CrossSigning.ts deleted file mode 100644 index 31ed2d4..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/CrossSigning.ts +++ /dev/null @@ -1,803 +0,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. -*/ - -/** - * Cross signing methods - */ - -import { PkSigning } from "@matrix-org/olm"; - -import { decodeBase64, encodeBase64, IObject, pkSign, pkVerify } from "./olmlib"; -import { logger } from "../logger"; -import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store"; -import { decryptAES, encryptAES } from "./aes"; -import { DeviceInfo } from "./deviceinfo"; -import { SecretStorage } from "./SecretStorage"; -import { ICrossSigningKey, ISignedKey, MatrixClient } from "../client"; -import { OlmDevice } from "./OlmDevice"; -import { ICryptoCallbacks } from "."; -import { ISignatures } from "../@types/signed"; -import { CryptoStore, SecretStorePrivateKeys } from "./store/base"; -import { SecretStorageKeyDescription } from "../secret-storage"; - -const KEY_REQUEST_TIMEOUT_MS = 1000 * 60; - -function publicKeyFromKeyInfo(keyInfo: ICrossSigningKey): string { - // `keys` is an object with { [`ed25519:${pubKey}`]: pubKey } - // We assume only a single key, and we want the bare form without type - // prefix, so we select the values. - return Object.values(keyInfo.keys)[0]; -} - -export interface ICacheCallbacks { - getCrossSigningKeyCache?(type: string, expectedPublicKey?: string): Promise<Uint8Array | null>; - storeCrossSigningKeyCache?(type: string, key?: Uint8Array): Promise<void>; -} - -export interface ICrossSigningInfo { - keys: Record<string, ICrossSigningKey>; - firstUse: boolean; - crossSigningVerifiedBefore: boolean; -} - -export class CrossSigningInfo { - public keys: Record<string, ICrossSigningKey> = {}; - public 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. - private crossSigningVerifiedBefore = false; - - /** - * 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 - */ - public constructor( - public readonly userId: string, - private callbacks: ICryptoCallbacks = {}, - private cacheCallbacks: ICacheCallbacks = {}, - ) {} - - public static fromStorage(obj: ICrossSigningInfo, userId: string): CrossSigningInfo { - 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; - } - - public toStorage(): ICrossSigningInfo { - 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 ] - */ - public async getCrossSigningKey(type: string, expectedPubkey?: string): Promise<[string, PkSigning]> { - 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: Uint8Array | null): [string, PkSigning] | undefined { - 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: Uint8Array | null = 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 - */ - public async isStoredInSecretStorage( - secretStorage: SecretStorage<MatrixClient | undefined>, - ): Promise<Record<string, object> | null> { - // 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: Record<string, SecretStorageKeyDescription>): void { - 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 - */ - public static async storeInSecretStorage( - keys: Map<string, Uint8Array>, - secretStorage: SecretStorage<undefined>, - ): Promise<void> { - for (const [type, privateKey] of keys) { - const encodedKey = encodeBase64(privateKey); - await secretStorage.store(`m.cross_signing.${type}`, encodedKey); - } - } - - /** - * Get private keys from secret storage created by some other device. This - * also passes the private keys to the app-specific callback. - * - * @param 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 - */ - public static async getFromSecretStorage(type: string, secretStorage: SecretStorage): Promise<Uint8Array | null> { - const encodedKey = await secretStorage.get(`m.cross_signing.${type}`); - if (!encodedKey) { - return null; - } - return decodeBase64(encodedKey); - } - - /** - * Check whether the private keys exist in the local key cache. - * - * @param 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. - */ - public async isStoredInKeyCache(type?: string): Promise<boolean> { - 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) - */ - public async getCrossSigningKeysFromCache(): Promise<Map<string, Uint8Array>> { - 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 - */ - public getId(type = "master"): string | null { - 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 - */ - public async resetKeys(level?: CrossSigningLevel): Promise<void> { - 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 as CrossSigningLevel)) { - return; - } - - const privateKeys: Record<string, Uint8Array> = {}; - const keys: Record<string, ICrossSigningKey> = {}; - let masterSigning; - let masterPub; - - try { - if (level & CrossSigningLevel.MASTER) { - masterSigning = new global.Olm.PkSigning(); - privateKeys.master = masterSigning.generate_seed(); - masterPub = masterSigning.init_with_seed(privateKeys.master); - keys.master = { - user_id: this.userId, - usage: ["master"], - keys: { - ["ed25519:" + masterPub]: masterPub, - }, - }; - } else { - [masterPub, masterSigning] = await this.getCrossSigningKey("master"); - } - - if (level & CrossSigningLevel.SELF_SIGNING) { - const sskSigning = new global.Olm.PkSigning(); - try { - privateKeys.self_signing = sskSigning.generate_seed(); - const sskPub = sskSigning.init_with_seed(privateKeys.self_signing); - keys.self_signing = { - user_id: this.userId, - usage: ["self_signing"], - keys: { - ["ed25519:" + sskPub]: sskPub, - }, - }; - pkSign(keys.self_signing, masterSigning, this.userId, masterPub); - } finally { - sskSigning.free(); - } - } - - if (level & CrossSigningLevel.USER_SIGNING) { - const uskSigning = new global.Olm.PkSigning(); - try { - privateKeys.user_signing = uskSigning.generate_seed(); - const uskPub = uskSigning.init_with_seed(privateKeys.user_signing); - keys.user_signing = { - user_id: this.userId, - usage: ["user_signing"], - keys: { - ["ed25519:" + uskPub]: uskPub, - }, - }; - pkSign(keys.user_signing, masterSigning, this.userId, masterPub); - } finally { - uskSigning.free(); - } - } - - Object.assign(this.keys, keys); - this.callbacks.saveCrossSigningKeys(privateKeys); - } finally { - if (masterSigning) { - masterSigning.free(); - } - } - } - - /** - * unsets the keys, used when another session has reset the keys, to disable cross-signing - */ - public clearKeys(): void { - this.keys = {}; - } - - public setKeys(keys: Record<string, ICrossSigningKey>): void { - const signingKeys: Record<string, ICrossSigningKey> = {}; - if (keys.master) { - if (keys.master.user_id !== this.userId) { - const error = "Mismatched user ID " + keys.master.user_id + " in master key from " + this.userId; - logger.error(error); - throw new Error(error); - } - if (!this.keys.master) { - // this is the first key we've seen, so first-use is true - this.firstUse = true; - } else if (publicKeyFromKeyInfo(keys.master) !== this.getId()) { - // this is a different key, so first-use is false - this.firstUse = false; - } // otherwise, same key, so no change - signingKeys.master = keys.master; - } else if (this.keys.master) { - signingKeys.master = this.keys.master; - } else { - throw new Error("Tried to set cross-signing keys without a master key"); - } - const masterKey = publicKeyFromKeyInfo(signingKeys.master); - - // verify signatures - if (keys.user_signing) { - if (keys.user_signing.user_id !== this.userId) { - const error = "Mismatched user ID " + keys.master.user_id + " in user_signing key from " + this.userId; - logger.error(error); - throw new Error(error); - } - try { - pkVerify(keys.user_signing, masterKey, this.userId); - } catch (e) { - logger.error("invalid signature on user-signing key"); - // FIXME: what do we want to do here? - throw e; - } - } - if (keys.self_signing) { - if (keys.self_signing.user_id !== this.userId) { - const error = "Mismatched user ID " + keys.master.user_id + " in self_signing key from " + this.userId; - logger.error(error); - throw new Error(error); - } - try { - pkVerify(keys.self_signing, masterKey, this.userId); - } catch (e) { - logger.error("invalid signature on self-signing key"); - // FIXME: what do we want to do here? - throw e; - } - } - - // if everything checks out, then save the keys - if (keys.master) { - this.keys.master = keys.master; - // if the master key is set, then the old self-signing and user-signing keys are obsolete - 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; - } - } - - public updateCrossSigningVerifiedBefore(isCrossSigningVerified: boolean): void { - // 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; - } - } - - public async signObject<T extends object>(data: T, type: string): Promise<T & { signatures: ISignatures }> { - if (!this.keys[type]) { - throw new Error("Attempted to sign with " + type + " key but no such key present"); - } - const [pubkey, signing] = await this.getCrossSigningKey(type); - try { - pkSign(data, signing, this.userId, pubkey); - return data as T & { signatures: ISignatures }; - } finally { - signing.free(); - } - } - - public async signUser(key: CrossSigningInfo): Promise<ICrossSigningKey | undefined> { - if (!this.keys.user_signing) { - logger.info("No user signing key: not signing user"); - return; - } - return this.signObject(key.keys.master, "user_signing"); - } - - public async signDevice(userId: string, device: DeviceInfo): Promise<ISignedKey | undefined> { - if (userId !== this.userId) { - throw new Error(`Trying to sign ${userId}'s device; can only sign our own device`); - } - if (!this.keys.self_signing) { - logger.info("No self signing key: not signing device"); - return; - } - return this.signObject<Omit<ISignedKey, "signatures">>( - { - 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 - */ - public checkUserTrust(userCrossSigning: CrossSigningInfo): UserTrustLevel { - // 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: boolean; - const userMaster = userCrossSigning.keys.master; - const uskId = this.getId("user_signing")!; - try { - pkVerify(userMaster, uskId, this.userId); - userTrusted = true; - } catch (e) { - userTrusted = false; - } - return new UserTrustLevel(userTrusted, userCrossSigning.crossSigningVerifiedBefore, userCrossSigning.firstUse); - } - - /** - * Check whether a given device is trusted. - * - * @param 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 - */ - public checkDeviceTrust( - userCrossSigning: CrossSigningInfo, - device: DeviceInfo, - localTrust: boolean, - trustCrossSignedDevices: boolean, - ): DeviceTrustLevel { - const userTrust = this.checkUserTrust(userCrossSigning); - - const userSSK = userCrossSigning.keys.self_signing; - if (!userSSK) { - // if the user has no self-signing key then we cannot make any - // trust assertions about this device from cross-signing - return new DeviceTrustLevel(false, false, localTrust, trustCrossSignedDevices); - } - - const deviceObj = deviceToObject(device, userCrossSigning.userId); - try { - // if we can verify the user's SSK from their master key... - pkVerify(userSSK, userCrossSigning.getId()!, userCrossSigning.userId); - // ...and this device's key from their SSK... - pkVerify(deviceObj, publicKeyFromKeyInfo(userSSK), userCrossSigning.userId); - // ...then we trust this device as much as far as we trust the user - return DeviceTrustLevel.fromUserTrustLevel(userTrust, localTrust, trustCrossSignedDevices); - } catch (e) { - return new DeviceTrustLevel(false, false, localTrust, trustCrossSignedDevices); - } - } - - /** - * @returns Cache callbacks - */ - public getCacheCallbacks(): ICacheCallbacks { - return this.cacheCallbacks; - } -} - -interface DeviceObject extends IObject { - algorithms: string[]; - keys: Record<string, string>; - device_id: string; - user_id: string; -} - -function deviceToObject(device: DeviceInfo, userId: string): DeviceObject { - return { - algorithms: device.algorithms, - keys: device.keys, - device_id: device.deviceId, - user_id: userId, - signatures: device.signatures, - }; -} - -export enum CrossSigningLevel { - MASTER = 4, - USER_SIGNING = 2, - SELF_SIGNING = 1, -} - -/** - * Represents the ways in which we trust a user - */ -export class UserTrustLevel { - public constructor( - private readonly crossSigningVerified: boolean, - private readonly crossSigningVerifiedBefore: boolean, - private readonly tofu: boolean, - ) {} - - /** - * @returns true if this user is verified via any means - */ - public isVerified(): boolean { - return this.isCrossSigningVerified(); - } - - /** - * @returns true if this user is verified via cross signing - */ - public isCrossSigningVerified(): boolean { - return this.crossSigningVerified; - } - - /** - * @returns true if we ever verified this user before (at least for - * the history of verifications observed by this device). - */ - public wasCrossSigningVerified(): boolean { - return this.crossSigningVerifiedBefore; - } - - /** - * @returns true if this user's key is trusted on first use - */ - public isTofu(): boolean { - return this.tofu; - } -} - -/** - * Represents the ways in which we trust a device - */ -export class DeviceTrustLevel { - public constructor( - public readonly crossSigningVerified: boolean, - public readonly tofu: boolean, - private readonly localVerified: boolean, - private readonly trustCrossSignedDevices: boolean, - ) {} - - public static fromUserTrustLevel( - userTrustLevel: UserTrustLevel, - localVerified: boolean, - trustCrossSignedDevices: boolean, - ): DeviceTrustLevel { - return new DeviceTrustLevel( - userTrustLevel.isCrossSigningVerified(), - userTrustLevel.isTofu(), - localVerified, - trustCrossSignedDevices, - ); - } - - /** - * @returns true if this device is verified via any means - */ - public isVerified(): boolean { - return Boolean(this.isLocallyVerified() || (this.trustCrossSignedDevices && this.isCrossSigningVerified())); - } - - /** - * @returns true if this device is verified via cross signing - */ - public isCrossSigningVerified(): boolean { - return this.crossSigningVerified; - } - - /** - * @returns true if this device is verified locally - */ - public isLocallyVerified(): boolean { - return this.localVerified; - } - - /** - * @returns true if this device is trusted from a user's key - * that is trusted on first use - */ - public isTofu(): boolean { - return this.tofu; - } -} - -export function createCryptoStoreCacheCallbacks(store: CryptoStore, olmDevice: OlmDevice): ICacheCallbacks { - return { - getCrossSigningKeyCache: async function ( - type: keyof SecretStorePrivateKeys, - _expectedPublicKey: string, - ): Promise<Uint8Array> { - const key = await new Promise<any>((resolve) => { - return store.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - store.getSecretStorePrivateKey(txn, resolve, type); - }); - }); - - if (key && key.ciphertext) { - const pickleKey = Buffer.from(olmDevice.pickleKey); - const decrypted = await decryptAES(key, pickleKey, type); - return decodeBase64(decrypted); - } else { - return key; - } - }, - storeCrossSigningKeyCache: async function ( - type: keyof SecretStorePrivateKeys, - key?: Uint8Array, - ): Promise<void> { - if (!(key instanceof Uint8Array)) { - throw new Error(`storeCrossSigningKeyCache expects Uint8Array, got ${key}`); - } - const pickleKey = Buffer.from(olmDevice.pickleKey); - const encryptedKey = await encryptAES(encodeBase64(key), pickleKey, type); - return store.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - store.storeSecretStorePrivateKey(txn, type, encryptedKey); - }); - }, - }; -} - -export type KeysDuringVerification = [[string, PkSigning], [string, PkSigning], [string, PkSigning], void]; - -/** - * 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 - */ -export async function requestKeysDuringVerification( - baseApis: MatrixClient, - userId: string, - deviceId: string, -): Promise<KeysDuringVerification | void> { - // If this is a self-verification, ask the other party for keys - if (baseApis.getUserId() !== userId) { - return; - } - logger.log("Cross-signing: Self-verification done; requesting keys"); - // This happens asynchronously, and we're not concerned about waiting for - // it. We return here in order to test. - return new Promise<KeysDuringVerification | void>((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): Promise<Uint8Array> => { - logger.debug("Cross-signing: requesting secret", type, deviceId); - const { promise } = client.requestSecret(`m.cross_signing.${type}`, [deviceId]); - const result = await promise; - const decoded = decodeBase64(result); - return Uint8Array.from(decoded); - }, - }, - original.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<void>((resolve) => { - setTimeout(resolve, KEY_REQUEST_TIMEOUT_MS, new Error("Timeout")); - }); - - // also request and cache the key backup key - const backupKeyPromise = (async (): Promise<void> => { - const cachedKey = await client.crypto!.getSessionBackupPrivateKey(); - if (!cachedKey) { - logger.info("No cached backup key found. Requesting..."); - const secretReq = client.requestSecret("m.megolm_backup.v1", [deviceId]); - const base64Key = await secretReq.promise; - logger.info("Got key backup key, decoding..."); - const decodedKey = decodeBase64(base64Key); - logger.info("Decoded backup key, storing..."); - await client.crypto!.storeSessionBackupPrivateKey(Uint8Array.from(decodedKey)); - logger.info("Backup key stored. Starting backup restore..."); - const backupInfo = await client.getKeyBackupVersion(); - // no need to await for this - just let it go in the bg - client.restoreKeyBackupWithCache(undefined, undefined, backupInfo!).then(() => { - logger.info("Backup restored."); - }); - } - })(); - - // We call getCrossSigningKey() for its side-effects - return Promise.race<KeysDuringVerification | void>([ - Promise.all([ - crossSigning.getCrossSigningKey("master"), - crossSigning.getCrossSigningKey("self_signing"), - crossSigning.getCrossSigningKey("user_signing"), - backupKeyPromise, - ]) as Promise<KeysDuringVerification>, - timeout, - ]).then(resolve, reject); - }).catch((e) => { - logger.warn("Cross-signing: failure while requesting keys:", e); - }); -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/DeviceList.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/DeviceList.ts deleted file mode 100644 index a1ff0eb..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/DeviceList.ts +++ /dev/null @@ -1,989 +0,0 @@ -/* -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 - */ - -import { logger } from "../logger"; -import { DeviceInfo, IDevice } from "./deviceinfo"; -import { CrossSigningInfo, ICrossSigningInfo } from "./CrossSigning"; -import * as olmlib from "./olmlib"; -import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store"; -import { chunkPromises, defer, IDeferred, sleep } from "../utils"; -import { DeviceKeys, IDownloadKeyResult, Keys, MatrixClient, SigningKeys } from "../client"; -import { OlmDevice } from "./OlmDevice"; -import { CryptoStore } from "./store/base"; -import { TypedEventEmitter } from "../models/typed-event-emitter"; -import { CryptoEvent, CryptoEventHandlerMap } from "./index"; - -/* 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 -export enum TrackingStatus { - NotTracked, - PendingDownload, - DownloadInProgress, - UpToDate, -} - -// user-Id → device-Id → DeviceInfo -export type DeviceInfoMap = Map<string, Map<string, DeviceInfo>>; - -type EmittedEvents = CryptoEvent.WillUpdateDevices | CryptoEvent.DevicesUpdated | CryptoEvent.UserCrossSigningUpdated; - -export class DeviceList extends TypedEventEmitter<EmittedEvents, CryptoEventHandlerMap> { - private devices: { [userId: string]: { [deviceId: string]: IDevice } } = {}; - - public crossSigningInfo: { [userId: string]: ICrossSigningInfo } = {}; - - // map of identity keys to the user who owns it - private userByIdentityKey: Record<string, string> = {}; - - // which users we are tracking device status for. - private deviceTrackingStatus: { [userId: string]: TrackingStatus } = {}; // 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. - private syncToken: string | null = null; - - private keyDownloadsInProgressByUser = new Map<string, Promise<void>>(); - - // Set whenever changes are made other than setting the sync token - private dirty = false; - - // Promise resolved when device data is saved - private savePromise: Promise<boolean> | null = null; - // Function that resolves the save promise - private resolveSavePromise: ((saved: boolean) => void) | null = null; - // The time the save is scheduled for - private savePromiseTime: number | null = null; - // The timer used to delay the save - private saveTimer: ReturnType<typeof setTimeout> | null = null; - // True if we have fetched data from the server or loaded a non-empty - // set of device data from the store - private hasFetched: boolean | null = null; - - private readonly serialiser: DeviceListUpdateSerialiser; - - public constructor( - baseApis: MatrixClient, - private readonly cryptoStore: CryptoStore, - olmDevice: OlmDevice, - // Maximum number of user IDs per request to prevent server overload (#1619) - public readonly keyDownloadChunkSize = 250, - ) { - super(); - - this.serialiser = new DeviceListUpdateSerialiser(baseApis, olmDevice, this); - } - - /** - * Load the device tracking state from storage - */ - public async load(): Promise<void> { - await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => { - this.cryptoStore.getEndToEndDeviceData(txn, (deviceData) => { - this.hasFetched = Boolean(deviceData && deviceData.devices); - this.devices = deviceData ? deviceData.devices : {}; - this.crossSigningInfo = deviceData ? deviceData.crossSigningInfo || {} : {}; - this.deviceTrackingStatus = deviceData ? deviceData.trackingStatus : {}; - this.syncToken = deviceData?.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; - } - } - } - - public stop(): void { - 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. - */ - public async saveIfDirty(delay = 500): Promise<boolean> { - 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.log("Saving device tracking data", this.syncToken); - - // null out savePromise now (after the delay but before the write), - // otherwise we could return the existing promise when the save has - // actually already happened. - this.savePromiseTime = null; - this.saveTimer = null; - this.savePromise = null; - this.resolveSavePromise = null; - - this.cryptoStore - .doTxn("readwrite", [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => { - this.cryptoStore.storeEndToEndDeviceData( - { - devices: this.devices, - crossSigningInfo: this.crossSigningInfo, - trackingStatus: this.deviceTrackingStatus, - syncToken: this.syncToken ?? undefined, - }, - txn, - ); - }) - .then( - () => { - // The device list is considered dirty until the write completes. - this.dirty = false; - resolveSavePromise?.(true); - }, - (err) => { - logger.error("Failed to save device tracking data", this.syncToken); - logger.error(err); - }, - ); - }, delay); - } - - return savePromise; - } - - /** - * Gets the sync token last set with setSyncToken - * - * @returns The sync token - */ - public getSyncToken(): string | null { - 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 - */ - public setSyncToken(st: string | null): void { - 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}. - */ - public downloadKeys(userIds: string[], forceDownload: boolean): Promise<DeviceInfoMap> { - const usersToDownload: string[] = []; - const promises: Promise<unknown>[] = []; - - 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.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.log("downloadKeys: downloading for", usersToDownload); - const downloadPromise = this.doKeyDownload(usersToDownload); - promises.push(downloadPromise); - } - - if (promises.length === 0) { - logger.log("downloadKeys: already have all necessary keys"); - } - - return Promise.all(promises).then(() => { - return this.getDevicesFromStore(userIds); - }); - } - - /** - * Get the stored device keys for a list of user ids - * - * @param userIds - the list of users to list keys for. - * - * @returns userId-\>deviceId-\>{@link DeviceInfo}. - */ - private getDevicesFromStore(userIds: string[]): DeviceInfoMap { - const stored: DeviceInfoMap = 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 - */ - public getKnownUserIds(): string[] { - 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. - */ - public getStoredDevicesForUser(userId: string): DeviceInfo[] | null { - const devs = this.devices[userId]; - if (!devs) { - return null; - } - const res: DeviceInfo[] = []; - for (const deviceId in devs) { - if (devs.hasOwnProperty(deviceId)) { - res.push(DeviceInfo.fromStorage(devs[deviceId], deviceId)); - } - } - return res; - } - - /** - * Get the stored device data for a user, in raw object form - * - * @param userId - the user to get data for - * - * @returns `deviceId->{object}` devices, or undefined if - * there is no data for this user. - */ - public getRawStoredDevicesForUser(userId: string): Record<string, IDevice> { - return this.devices[userId]; - } - - public getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null { - if (!this.crossSigningInfo[userId]) return null; - - return CrossSigningInfo.fromStorage(this.crossSigningInfo[userId], userId); - } - - public storeCrossSigningForUser(userId: string, info: ICrossSigningInfo): void { - 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 - */ - public getStoredDevice(userId: string, deviceId: string): DeviceInfo | undefined { - const devs = this.devices[userId]; - if (!devs?.[deviceId]) { - return undefined; - } - return 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 - */ - public getUserByIdentityKey(algorithm: string, senderKey: string): string | null { - 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 - */ - public getDeviceByIdentityKey(algorithm: string, senderKey: string): DeviceInfo | null { - const userId = this.getUserByIdentityKey(algorithm, senderKey); - if (!userId) { - return null; - } - - const devices = this.devices[userId]; - if (!devices) { - return null; - } - - for (const deviceId in devices) { - if (!devices.hasOwnProperty(deviceId)) { - continue; - } - - const device = devices[deviceId]; - for (const keyId in device.keys) { - if (!device.keys.hasOwnProperty(keyId)) { - continue; - } - if (keyId.indexOf("curve25519:") !== 0) { - continue; - } - const deviceKey = device.keys[keyId]; - if (deviceKey == senderKey) { - return DeviceInfo.fromStorage(device, deviceId); - } - } - } - - // doesn't match a known device - return null; - } - - /** - * Replaces the list of devices for a user with the given device list - * - * @param userId - The user ID - * @param devices - New device info for user - */ - public storeDevicesForUser(userId: string, devices: Record<string, IDevice>): void { - 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. - * - */ - public startTrackingDeviceList(userId: string): void { - // sanity-check the userId. This is mostly paranoia, but if synapse - // can't parse the userId we give it as an mxid, it 500s the whole - // request and we can never update the device lists again (because - // the broken userId is always 'invalid' and always included in any - // refresh request). - // By checking it is at least a string, we can eliminate a class of - // silly errors. - if (typeof userId !== "string") { - throw new Error("userId must be a string; was " + userId); - } - if (!this.deviceTrackingStatus[userId]) { - logger.log("Now tracking device list for " + userId); - this.deviceTrackingStatus[userId] = 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. - * - */ - public stopTrackingDeviceList(userId: string): void { - if (this.deviceTrackingStatus[userId]) { - 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. - */ - public stopTrackingAllDeviceLists(): void { - 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. - * - */ - public invalidateUserDeviceList(userId: string): void { - if (this.deviceTrackingStatus[userId]) { - 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). - */ - public refreshOutdatedDeviceLists(): Promise<void> { - this.saveIfDirty(); - - const usersToDownload: string[] = []; - 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 - */ - public setRawStoredDevicesForUser(userId: string, devices: Record<string, IDevice>): void { - // 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; - } - } - - public setRawStoredCrossSigningForUser(userId: string, info: ICrossSigningInfo): void { - 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. - */ - private doKeyDownload(users: string[]): Promise<void> { - if (users.length === 0) { - // nothing to do - return Promise.resolve(); - } - - const prom = this.serialiser.updateDevicesForUsers(users, this.syncToken!).then( - () => { - finished(true); - }, - (e) => { - logger.error("Error downloading keys for " + users + ":", e); - finished(false); - throw e; - }, - ); - - users.forEach((u) => { - this.keyDownloadsInProgressByUser.set(u, prom); - const stat = this.deviceTrackingStatus[u]; - if (stat == TrackingStatus.PendingDownload) { - this.deviceTrackingStatus[u] = TrackingStatus.DownloadInProgress; - } - }); - - const finished = (success: boolean): void => { - this.emit(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.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.log("Device list for", u, "now up to date"); - } else { - this.deviceTrackingStatus[u] = TrackingStatus.PendingDownload; - } - } - }); - this.saveIfDirty(); - this.emit(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). - */ -class DeviceListUpdateSerialiser { - private downloadInProgress = false; - - // users which are queued for download - // userId -> true - private keyDownloadsQueuedByUser: Record<string, boolean> = {}; - - // deferred which is resolved when the queued users are downloaded. - // non-null indicates that we have users queued for download. - private queuedQueryDeferred?: IDeferred<void>; - - private syncToken?: string; // 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 - */ - public constructor( - private readonly baseApis: MatrixClient, - private readonly olmDevice: OlmDevice, - private readonly deviceList: DeviceList, - ) {} - - /** - * 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. - */ - public updateDevicesForUsers(users: string[], syncToken: string): Promise<void> { - users.forEach((u) => { - this.keyDownloadsQueuedByUser[u] = true; - }); - - if (!this.queuedQueryDeferred) { - this.queuedQueryDeferred = defer(); - } - - // We always take the new sync token and just use the latest one we've - // been given, since it just needs to be at least as recent as the - // sync response the device invalidation message arrived in - this.syncToken = syncToken; - - if (this.downloadInProgress) { - // just queue up these users - logger.log("Queued key download for", users); - return this.queuedQueryDeferred.promise; - } - - // start a new download. - return this.doQueuedQueries(); - } - - private doQueuedQueries(): Promise<void> { - 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.log("Starting key download for", downloadUsers); - this.downloadInProgress = true; - - const opts: Parameters<MatrixClient["downloadKeysForUsers"]>[1] = {}; - if (this.syncToken) { - opts.token = this.syncToken; - } - - const factories: Array<() => Promise<IDownloadKeyResult>> = []; - for (let i = 0; i < downloadUsers.length; i += this.deviceList.keyDownloadChunkSize) { - const userSlice = downloadUsers.slice(i, i + this.deviceList.keyDownloadChunkSize); - factories.push(() => this.baseApis.downloadKeysForUsers(userSlice, opts)); - } - - chunkPromises(factories, 3) - .then(async (responses: IDownloadKeyResult[]) => { - const dk: IDownloadKeyResult["device_keys"] = Object.assign( - {}, - ...responses.map((res) => res.device_keys || {}), - ); - const masterKeys: IDownloadKeyResult["master_keys"] = Object.assign( - {}, - ...responses.map((res) => res.master_keys || {}), - ); - const ssks: IDownloadKeyResult["self_signing_keys"] = Object.assign( - {}, - ...responses.map((res) => res.self_signing_keys || {}), - ); - const usks: IDownloadKeyResult["user_signing_keys"] = Object.assign( - {}, - ...responses.map((res) => res.user_signing_keys || {}), - ); - - // yield to other things that want to execute in between users, to - // avoid wedging the CPU - // (https://github.com/vector-im/element-web/issues/3158) - // - // of course we ought to do this in a web worker or similar, but - // this serves as an easy solution for now. - for (const userId of downloadUsers) { - await sleep(5); - try { - await this.processQueryResponseForUser(userId, dk[userId], { - master: masterKeys?.[userId], - self_signing: ssks?.[userId], - user_signing: usks?.[userId], - }); - } catch (e) { - // log the error but continue, so that one bad key - // doesn't kill the whole process - logger.error(`Error processing keys for ${userId}:`, e); - } - } - }) - .then( - () => { - logger.log("Completed key download for " + downloadUsers); - - this.downloadInProgress = false; - deferred?.resolve(); - - // if we have queued users, fire off another request. - if (this.queuedQueryDeferred) { - this.doQueuedQueries(); - } - }, - (e) => { - logger.warn("Error downloading keys for " + downloadUsers + ":", e); - this.downloadInProgress = false; - deferred?.reject(e); - }, - ); - - return deferred!.promise; - } - - private async processQueryResponseForUser( - userId: string, - dkResponse: DeviceKeys, - crossSigningResponse: { - master?: Keys; - self_signing?: SigningKeys; - user_signing?: SigningKeys; - }, - ): Promise<void> { - logger.log("got device keys for " + userId + ":", dkResponse); - logger.log("got cross-signing keys for " + userId + ":", crossSigningResponse); - - { - // map from deviceid -> deviceinfo for this user - const userStore: Record<string, DeviceInfo> = {}; - const devs = this.deviceList.getRawStoredDevicesForUser(userId); - if (devs) { - Object.keys(devs).forEach((deviceId) => { - const d = DeviceInfo.fromStorage(devs[deviceId], deviceId); - userStore[deviceId] = d; - }); - } - - await updateStoredDeviceKeysForUser( - this.olmDevice, - userId, - userStore, - dkResponse || {}, - this.baseApis.getUserId()!, - this.baseApis.deviceId!, - ); - - // put the updates into the object that will be returned as our results - const storage: Record<string, IDevice> = {}; - Object.keys(userStore).forEach((deviceId) => { - storage[deviceId] = userStore[deviceId].toStorage(); - }); - - this.deviceList.setRawStoredDevicesForUser(userId, storage); - } - - // now do the same for the cross-signing keys - { - // FIXME: should we be ignoring empty cross-signing responses, or - // should we be dropping the keys? - if ( - crossSigningResponse && - (crossSigningResponse.master || crossSigningResponse.self_signing || crossSigningResponse.user_signing) - ) { - const crossSigning = - this.deviceList.getStoredCrossSigningForUser(userId) || new CrossSigningInfo(userId); - - crossSigning.setKeys(crossSigningResponse); - - this.deviceList.setRawStoredCrossSigningForUser(userId, crossSigning.toStorage()); - - // NB. Unlike most events in the js-sdk, this one is internal to the - // js-sdk and is not re-emitted - this.deviceList.emit(CryptoEvent.UserCrossSigningUpdated, userId); - } - } - } -} - -async function updateStoredDeviceKeysForUser( - olmDevice: OlmDevice, - userId: string, - userStore: Record<string, DeviceInfo>, - userResult: IDownloadKeyResult["device_keys"]["user_id"], - localUserId: string, - localDeviceId: string, -): Promise<boolean> { - let updated = false; - - // remove any devices in the store which aren't in the response - for (const deviceId in userStore) { - if (!userStore.hasOwnProperty(deviceId)) { - continue; - } - - if (!(deviceId in userResult)) { - if (userId === localUserId && deviceId === localDeviceId) { - logger.warn(`Local device ${deviceId} missing from sync, skipping removal`); - continue; - } - - logger.log("Device " + userId + ":" + deviceId + " has been removed"); - delete userStore[deviceId]; - updated = true; - } - } - - for (const deviceId in userResult) { - if (!userResult.hasOwnProperty(deviceId)) { - continue; - } - - const deviceResult = userResult[deviceId]; - - // check that the user_id and device_id in the response object are - // correct - if (deviceResult.user_id !== userId) { - logger.warn("Mismatched user_id " + deviceResult.user_id + " in keys from " + userId + ":" + deviceId); - continue; - } - if (deviceResult.device_id !== deviceId) { - logger.warn("Mismatched device_id " + deviceResult.device_id + " in keys from " + userId + ":" + deviceId); - continue; - } - - if (await storeDeviceKeys(olmDevice, userStore, deviceResult)) { - updated = true; - } - } - - return updated; -} - -/* - * Process a device in a /query response, and add it to the userStore - * - * returns (a promise for) true if a change was made, else false - */ -async function storeDeviceKeys( - olmDevice: OlmDevice, - userStore: Record<string, DeviceInfo>, - deviceResult: IDownloadKeyResult["device_keys"]["user_id"]["device_id"], -): Promise<boolean> { - if (!deviceResult.keys) { - // no keys? - return false; - } - - const deviceId = deviceResult.device_id; - const userId = deviceResult.user_id; - - const signKeyId = "ed25519:" + deviceId; - const signKey = deviceResult.keys[signKeyId]; - if (!signKey) { - logger.warn("Device " + userId + ":" + deviceId + " has no ed25519 key"); - return false; - } - - const unsigned = deviceResult.unsigned || {}; - const signatures = deviceResult.signatures || {}; - - try { - await olmlib.verifySignature(olmDevice, deviceResult, userId, deviceId, signKey); - } catch (e) { - logger.warn("Unable to verify signature on device " + userId + ":" + deviceId + ":" + e); - return false; - } - - // DeviceInfo - let deviceStore; - - if (deviceId in userStore) { - // already have this device. - deviceStore = userStore[deviceId]; - - if (deviceStore.getFingerprint() != signKey) { - // this should only happen if the list has been MITMed; we are - // best off sticking with the original keys. - // - // Should we warn the user about it somehow? - logger.warn("Ed25519 key for device " + userId + ":" + deviceId + " has changed"); - return false; - } - } else { - userStore[deviceId] = deviceStore = new DeviceInfo(deviceId); - } - - deviceStore.keys = deviceResult.keys || {}; - deviceStore.algorithms = deviceResult.algorithms || []; - deviceStore.unsigned = unsigned; - deviceStore.signatures = signatures; - return true; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/EncryptionSetup.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/EncryptionSetup.ts deleted file mode 100644 index 4efe677..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/EncryptionSetup.ts +++ /dev/null @@ -1,356 +0,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. -*/ - -import { logger } from "../logger"; -import { IContent, MatrixEvent } from "../models/event"; -import { createCryptoStoreCacheCallbacks, ICacheCallbacks } from "./CrossSigning"; -import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store"; -import { Method, ClientPrefix } from "../http-api"; -import { Crypto, ICryptoCallbacks, IBootstrapCrossSigningOpts } from "./index"; -import { - ClientEvent, - ClientEventHandlerMap, - CrossSigningKeys, - ICrossSigningKey, - ISignedKey, - KeySignatures, -} from "../client"; -import { IKeyBackupInfo } from "./keybackup"; -import { TypedEventEmitter } from "../models/typed-event-emitter"; -import { IAccountDataClient } from "./SecretStorage"; -import { SecretStorageKeyDescription } from "../secret-storage"; - -interface ICrossSigningKeys { - authUpload: IBootstrapCrossSigningOpts["authUploadDeviceSigningKeys"]; - keys: Record<"master" | "self_signing" | "user_signing", ICrossSigningKey>; -} - -/** - * Builds an EncryptionSetupOperation by calling any of the add.. methods. - * Once done, `buildOperation()` can be called which allows to apply to operation. - * - * This is used as a helper by Crypto to keep track of all the network requests - * and other side-effects of bootstrapping, so it can be applied in one go (and retried in the future) - * Also keeps track of all the private keys created during bootstrapping, so we don't need to prompt for them - * more than once. - */ -export class EncryptionSetupBuilder { - public readonly accountDataClientAdapter: AccountDataClientAdapter; - public readonly crossSigningCallbacks: CrossSigningCallbacks; - public readonly ssssCryptoCallbacks: SSSSCryptoCallbacks; - - private crossSigningKeys?: ICrossSigningKeys; - private keySignatures?: KeySignatures; - private keyBackupInfo?: IKeyBackupInfo; - private sessionBackupPrivateKey?: Uint8Array; - - /** - * @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 - */ - public constructor(accountData: Map<string, MatrixEvent>, delegateCryptoCallbacks?: ICryptoCallbacks) { - 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 - */ - public addCrossSigningKeys(authUpload: ICrossSigningKeys["authUpload"], keys: ICrossSigningKeys["keys"]): void { - 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 - */ - public addSessionBackup(keyBackupInfo: IKeyBackupInfo): void { - this.keyBackupInfo = keyBackupInfo; - } - - /** - * Adds the session backup private key to be updated in the local cache - * - * Used after fixing the format of the key - * - */ - public addSessionBackupPrivateKeyToCache(privateKey: Uint8Array): void { - 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 - * - */ - public addKeySignature(userId: string, deviceId: string, signature: ISignedKey): void { - if (!this.keySignatures) { - this.keySignatures = {}; - } - const userSignatures = this.keySignatures[userId] || {}; - this.keySignatures[userId] = userSignatures; - userSignatures[deviceId] = signature; - } - - public async setAccountData(type: string, content: object): Promise<void> { - await this.accountDataClientAdapter.setAccountData(type, content); - } - - /** - * builds the operation containing all the parts that have been added to the builder - */ - public buildOperation(): EncryptionSetupOperation { - 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. - */ - public async persist(crypto: Crypto): Promise<void> { - // store private keys in cache - if (this.crossSigningKeys) { - const cacheCallbacks = createCryptoStoreCacheCallbacks(crypto.cryptoStore, crypto.olmDevice); - for (const type of ["master", "self_signing", "user_signing"]) { - logger.log(`Cache ${type} cross-signing private key locally`); - const privateKey = this.crossSigningCallbacks.privateKeys.get(type); - await cacheCallbacks.storeCrossSigningKeyCache?.(type, privateKey); - } - // store own cross-sign pubkeys as trusted - await crypto.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - crypto.cryptoStore.storeCrossSigningKeys(txn, this.crossSigningKeys!.keys); - }); - } - // store session backup key in cache - if (this.sessionBackupPrivateKey) { - await crypto.storeSessionBackupPrivateKey(this.sessionBackupPrivateKey); - } - } -} - -/** - * Can be created from EncryptionSetupBuilder, or - * (in a follow-up PR, not implemented yet) restored from storage, to retry. - * - * It does not have knowledge of any private keys, unlike the builder. - */ -export class EncryptionSetupOperation { - /** - */ - public constructor( - private readonly accountData: Map<string, object>, - private readonly crossSigningKeys?: ICrossSigningKeys, - private readonly keyBackupInfo?: IKeyBackupInfo, - private readonly keySignatures?: KeySignatures, - ) {} - - /** - * Runs the (remaining part of, in the future) operation by sending requests to the server. - */ - public async apply(crypto: Crypto): Promise<void> { - const baseApis = crypto.baseApis; - // upload cross-signing keys - if (this.crossSigningKeys) { - const keys: Partial<CrossSigningKeys> = {}; - for (const [name, key] of Object.entries(this.crossSigningKeys.keys)) { - keys[((name as keyof ICrossSigningKeys["keys"]) + "_key") as keyof CrossSigningKeys] = 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 as CrossSigningKeys); - }); - - // 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( - Method.Put, - "/room_keys/version/" + this.keyBackupInfo.version, - undefined, - { - algorithm: this.keyBackupInfo.algorithm, - auth_data: this.keyBackupInfo.auth_data, - }, - { prefix: ClientPrefix.V3 }, - ); - } else { - // add new key backup - await baseApis.http.authedRequest(Method.Post, "/room_keys/version", undefined, this.keyBackupInfo, { - prefix: ClientPrefix.V3, - }); - } - } - } -} - -/** - * Catches account data set by SecretStorage during bootstrapping by - * implementing the methods related to account data in MatrixClient - */ -class AccountDataClientAdapter - extends TypedEventEmitter<ClientEvent.AccountData, ClientEventHandlerMap> - implements IAccountDataClient -{ - // - public readonly values = new Map<string, MatrixEvent>(); - - /** - * @param existingValues - existing account data - */ - public constructor(private readonly existingValues: Map<string, MatrixEvent>) { - super(); - } - - /** - * @returns the content of the account data - */ - public getAccountDataFromServer<T extends { [k: string]: any }>(type: string): Promise<T> { - return Promise.resolve(this.getAccountData(type) as T); - } - - /** - * @returns the content of the account data - */ - public getAccountData(type: string): IContent | null { - const modifiedValue = this.values.get(type); - if (modifiedValue) { - return modifiedValue; - } - const existingValue = this.existingValues.get(type); - if (existingValue) { - return existingValue.getContent(); - } - return null; - } - - public setAccountData(type: string, content: any): Promise<{}> { - const lastEvent = this.values.get(type); - this.values.set(type, content); - // ensure accountData is emitted on the next tick, - // as SecretStorage listens for it while calling this method - // and it seems to rely on this. - return Promise.resolve().then(() => { - const event = new MatrixEvent({ type, content }); - this.emit(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 implements ICryptoCallbacks, ICacheCallbacks { - public readonly privateKeys = new Map<string, Uint8Array>(); - - // cache callbacks - public getCrossSigningKeyCache(type: string, expectedPublicKey: string): Promise<Uint8Array | null> { - return this.getCrossSigningKey(type, expectedPublicKey); - } - - public storeCrossSigningKeyCache(type: string, key: Uint8Array): Promise<void> { - this.privateKeys.set(type, key); - return Promise.resolve(); - } - - // non-cache callbacks - public getCrossSigningKey(type: string, expectedPubkey: string): Promise<Uint8Array | null> { - return Promise.resolve(this.privateKeys.get(type) ?? null); - } - - public saveCrossSigningKeys(privateKeys: Record<string, Uint8Array>): void { - 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 { - private readonly privateKeys = new Map<string, Uint8Array>(); - - public constructor(private readonly delegateCryptoCallbacks?: ICryptoCallbacks) {} - - public async getSecretStorageKey( - { keys }: { keys: Record<string, SecretStorageKeyDescription> }, - name: string, - ): Promise<[string, Uint8Array] | null> { - 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; - } - - public addPrivateKey(keyId: string, keyInfo: SecretStorageKeyDescription, privKey: Uint8Array): void { - this.privateKeys.set(keyId, privKey); - // Also pass along to application to cache if it wishes - this.delegateCryptoCallbacks?.cacheSecretStorageKey?.(keyId, keyInfo, privKey); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OlmDevice.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OlmDevice.ts deleted file mode 100644 index 82a0a9a..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OlmDevice.ts +++ /dev/null @@ -1,1496 +0,0 @@ -/* -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. -*/ - -import { Account, InboundGroupSession, OutboundGroupSession, Session, Utility } from "@matrix-org/olm"; - -import { logger, PrefixedLogger } from "../logger"; -import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store"; -import * as algorithms from "./algorithms"; -import { CryptoStore, IProblem, ISessionInfo, IWithheld } from "./store/base"; -import { IOlmDevice, IOutboundGroupSessionKey } from "./algorithms/megolm"; -import { IMegolmSessionData, OlmGroupSessionExtraData } from "../@types/crypto"; -import { IMessage } from "./algorithms/olm"; - -// 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; - -export class PayloadTooLargeError extends Error { - public readonly data = { - errcode: "M_TOO_LARGE", - error: "Payload too large for encrypted message", - }; -} - -function checkPayloadLength(payloadString: string): void { - 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.`, - ); - } -} - -interface IInitOpts { - fromExportedDevice?: IExportedDevice; - pickleKey?: string; -} - -/** data stored in the session store about an inbound group session */ -export interface InboundGroupSessionData { - room_id: string; // eslint-disable-line camelcase - /** pickled Olm.InboundGroupSession */ - session: string; - keysClaimed: Record<string, string>; - /** Devices involved in forwarding this session to us (normally empty). */ - forwardingCurve25519KeyChain: string[]; - /** whether this session is untrusted. */ - untrusted?: boolean; - /** whether this session exists during the room being set to shared history. */ - sharedHistory?: boolean; -} - -export interface IDecryptedGroupMessage { - result: string; - keysClaimed: Record<string, string>; - senderKey: string; - forwardingCurve25519KeyChain: string[]; - untrusted: boolean; -} - -export interface IInboundSession { - payload: string; - session_id: string; -} - -export interface IExportedDevice { - pickleKey: string; - pickledAccount: string; - sessions: ISessionInfo[]; -} - -interface IUnpickledSessionInfo extends Omit<ISessionInfo, "session"> { - session: Session; -} - -/* eslint-disable camelcase */ -interface IInboundGroupSessionKey { - chain_index: number; - key: string; - forwarding_curve25519_key_chain: string[]; - sender_claimed_ed25519_key: string | null; - shared_history: boolean; - untrusted?: boolean; -} -/* eslint-enable camelcase */ - -type OneTimeKeys = { curve25519: { [keyId: string]: string } }; - -/** - * 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. - */ -export class OlmDevice { - public pickleKey = "DEFAULT_KEY"; // set by consumers - - /** Curve25519 key for the account, unknown until we load the account from storage in init() */ - public deviceCurve25519Key: string | null = null; - /** Ed25519 key for the account, unknown until we load the account from storage in init() */ - public deviceEd25519Key: string | null = null; - private maxOneTimeKeys: number | null = null; - - // we don't bother stashing outboundgroupsessions in the cryptoStore - - // instead we keep them here. - private outboundGroupSessionStore: Record<string, string> = {}; - - // 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>}" - private inboundGroupSessionMessageIndexes: Record<string, { id: string; timestamp: number }> = {}; - - // Keep track of sessions that we're starting, so that we don't start - // multiple sessions for the same device at the same time. - public sessionsInProgress: Record<string, Promise<void>> = {}; // set by consumers - - // Used by olm to serialise prekey message decryptions - public olmPrekeyPromise: Promise<any> = Promise.resolve(); // set by consumers - - public constructor(private readonly cryptoStore: CryptoStore) {} - - /** - * @returns The version of Olm. - */ - public static getOlmVersion(): [number, number, number] { - 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 - */ - public async init({ pickleKey, fromExportedDevice }: IInitOpts = {}): Promise<void> { - let e2eKeys; - const account = new global.Olm.Account(); - - try { - if (fromExportedDevice) { - if (pickleKey) { - 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 - */ - private async initialiseFromExportedDevice(exportedData: IExportedDevice, account: Account): Promise<void> { - await this.cryptoStore.doTxn( - "readwrite", - [IndexedDBCryptoStore.STORE_ACCOUNT, 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); - } - - private async initialiseAccount(account: Account): Promise<void> { - await this.cryptoStore.doTxn("readwrite", [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 - */ - private getAccount(txn: unknown, func: (account: Account) => void): void { - this.cryptoStore.getAccount(txn, (pickledAccount: string | null) => { - 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 - */ - private storeAccount(txn: unknown, account: Account): void { - 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 - */ - public async export(): Promise<IExportedDevice> { - const result: Partial<IExportedDevice> = { - pickleKey: this.pickleKey, - }; - - await this.cryptoStore.doTxn( - "readonly", - [IndexedDBCryptoStore.STORE_ACCOUNT, IndexedDBCryptoStore.STORE_SESSIONS], - (txn) => { - this.cryptoStore.getAccount(txn, (pickledAccount: string | null) => { - 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 as IExportedDevice; - } - - /** - * 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 - */ - private getSession( - deviceKey: string, - sessionId: string, - txn: unknown, - func: (unpickledSessionInfo: IUnpickledSessionInfo) => void, - ): void { - this.cryptoStore.getEndToEndSession(deviceKey, sessionId, txn, (sessionInfo: ISessionInfo | null) => { - 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 - */ - private unpickleSession( - sessionInfo: ISessionInfo, - func: (unpickledSessionInfo: IUnpickledSessionInfo) => void, - ): void { - const session = new global.Olm.Session(); - try { - session.unpickle(this.pickleKey, sessionInfo.session!); - const unpickledSessInfo: IUnpickledSessionInfo = 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 - */ - private saveSession(deviceKey: string, sessionInfo: IUnpickledSessionInfo, txn: unknown): void { - const sessionId = sessionInfo.session.session_id(); - 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 - */ - private getUtility<T>(func: (utility: Utility) => T): T { - 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 - */ - public async sign(message: string): Promise<string> { - let result: string; - await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.getAccount(txn, (account: 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. - */ - public async getOneTimeKeys(): Promise<OneTimeKeys> { - let result: OneTimeKeys; - await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.getAccount(txn, (account) => { - result = JSON.parse(account.one_time_keys()); - }); - }); - - return result!; - } - - /** - * Get the maximum number of one-time keys we can store. - * - * @returns number of keys - */ - public maxNumberOfOneTimeKeys(): number { - return this.maxOneTimeKeys ?? -1; - } - - /** - * Marks all of the one-time keys as published. - */ - public async markKeysAsPublished(): Promise<void> { - await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.getAccount(txn, (account: 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 - */ - public generateOneTimeKeys(numKeys: number): Promise<void> { - return this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.getAccount(txn, (account) => { - account.generate_one_time_keys(numKeys); - this.storeAccount(txn, account); - }); - }); - } - - /** - * Generate a new fallback keys - * - * @returns Resolved once the account is saved back having generated the key - */ - public async generateFallbackKey(): Promise<void> { - await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.getAccount(txn, (account) => { - account.generate_fallback_key(); - this.storeAccount(txn, account); - }); - }); - } - - public async getFallbackKey(): Promise<Record<string, Record<string, string>>> { - let result: Record<string, Record<string, string>>; - await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.getAccount(txn, (account: Account) => { - result = JSON.parse(account.unpublished_fallback_key()); - }); - }); - return result!; - } - - public async forgetOldFallbackKey(): Promise<void> { - await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.getAccount(txn, (account: 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. - */ - public async createOutboundSession(theirIdentityKey: string, theirOneTimeKey: string): Promise<string> { - let newSessionId: string; - await this.cryptoStore.doTxn( - "readwrite", - [IndexedDBCryptoStore.STORE_ACCOUNT, IndexedDBCryptoStore.STORE_SESSIONS], - (txn) => { - this.getAccount(txn, (account: Account) => { - const session = new global.Olm.Session(); - try { - session.create_outbound(account, theirIdentityKey, theirOneTimeKey); - newSessionId = session.session_id(); - this.storeAccount(txn, account); - const sessionInfo: IUnpickledSessionInfo = { - session, - // Pretend we've received a message at this point, otherwise - // if we try to send a message to the device, it won't use - // this session - lastReceivedMessageTs: Date.now(), - }; - this.saveSession(theirIdentityKey, sessionInfo, txn); - } finally { - session.free(); - } - }); - }, - logger.withPrefix("[createOutboundSession]"), - ); - return newSessionId!; - } - - /** - * Generate a new inbound session, given an incoming message - * - * @param 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). - */ - public async createInboundSession( - theirDeviceIdentityKey: string, - messageType: number, - ciphertext: string, - ): Promise<IInboundSession> { - if (messageType !== 0) { - throw new Error("Need messageType == 0 to create inbound session"); - } - - let result: { payload: string; session_id: string }; // eslint-disable-line camelcase - await this.cryptoStore.doTxn( - "readwrite", - [IndexedDBCryptoStore.STORE_ACCOUNT, IndexedDBCryptoStore.STORE_SESSIONS], - (txn) => { - this.getAccount(txn, (account: 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: IUnpickledSessionInfo = { - session, - // this counts as a received message: set last received message time - // to now - lastReceivedMessageTs: Date.now(), - }; - this.saveSession(theirDeviceIdentityKey, sessionInfo, txn); - - result = { - payload: payloadString, - session_id: session.session_id(), - }; - } finally { - session.free(); - } - }); - }, - logger.withPrefix("[createInboundSession]"), - ); - - return result!; - } - - /** - * Get a list of known session IDs for the given device - * - * @param theirDeviceIdentityKey - Curve25519 identity key for the - * remote device - * @returns a list of known session ids for the device - */ - public async getSessionIdsForDevice(theirDeviceIdentityKey: string): Promise<string[]> { - const log = 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: string[]; - await this.cryptoStore.doTxn( - "readonly", - [IndexedDBCryptoStore.STORE_SESSIONS], - (txn) => { - this.cryptoStore.getEndToEndSessions(theirDeviceIdentityKey, txn, (sessions) => { - sessionIds = Object.keys(sessions); - }); - }, - log, - ); - - return sessionIds!; - } - - /** - * Get the right olm session id for encrypting messages to the given identity key - * - * @param 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 - */ - public async getSessionIdForDevice( - theirDeviceIdentityKey: string, - nowait = false, - log?: PrefixedLogger, - ): Promise<string | null> { - 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 - */ - public async getSessionInfoForDevice( - deviceIdentityKey: string, - nowait = false, - log = logger, - ): Promise<{ sessionId: string; lastReceivedMessageTs: number; hasReceivedMessage: boolean }[]> { - 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: { - lastReceivedMessageTs: number; - hasReceivedMessage: boolean; - sessionId: string; - }[] = []; - - await this.cryptoStore.doTxn( - "readonly", - [IndexedDBCryptoStore.STORE_SESSIONS], - (txn) => { - this.cryptoStore.getEndToEndSessions(deviceIdentityKey, txn, (sessions) => { - const sessionIds = Object.keys(sessions).sort(); - for (const sessionId of sessionIds) { - this.unpickleSession(sessions[sessionId], (sessInfo: IUnpickledSessionInfo) => { - 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 - */ - public async encryptMessage( - theirDeviceIdentityKey: string, - sessionId: string, - payloadString: string, - ): Promise<IMessage> { - checkPayloadLength(payloadString); - - let res: IMessage; - await this.cryptoStore.doTxn( - "readwrite", - [IndexedDBCryptoStore.STORE_SESSIONS], - (txn) => { - this.getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => { - const sessionDesc = sessionInfo.session.describe(); - logger.log( - "encryptMessage: Olm Session ID " + - sessionId + - " to " + - theirDeviceIdentityKey + - ": " + - sessionDesc, - ); - res = sessionInfo.session.encrypt(payloadString); - this.saveSession(theirDeviceIdentityKey, sessionInfo, txn); - }); - }, - logger.withPrefix("[encryptMessage]"), - ); - return res!; - } - - /** - * Decrypt an incoming message using an existing session - * - * @param 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. - */ - public async decryptMessage( - theirDeviceIdentityKey: string, - sessionId: string, - messageType: number, - ciphertext: string, - ): Promise<string> { - let payloadString: string; - await this.cryptoStore.doTxn( - "readwrite", - [IndexedDBCryptoStore.STORE_SESSIONS], - (txn) => { - this.getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo: IUnpickledSessionInfo) => { - const sessionDesc = sessionInfo.session.describe(); - logger.log( - "decryptMessage: Olm Session ID " + - sessionId + - " from " + - theirDeviceIdentityKey + - ": " + - sessionDesc, - ); - payloadString = sessionInfo.session.decrypt(messageType, ciphertext); - sessionInfo.lastReceivedMessageTs = Date.now(); - this.saveSession(theirDeviceIdentityKey, sessionInfo, txn); - }); - }, - logger.withPrefix("[decryptMessage]"), - ); - return payloadString!; - } - - /** - * Determine if an incoming messages is a prekey message matching an existing session - * - * @param 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. - */ - public async matchesSession( - theirDeviceIdentityKey: string, - sessionId: string, - messageType: number, - ciphertext: string, - ): Promise<boolean> { - if (messageType !== 0) { - return false; - } - - let matches: boolean; - await this.cryptoStore.doTxn( - "readonly", - [IndexedDBCryptoStore.STORE_SESSIONS], - (txn) => { - this.getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => { - matches = sessionInfo.session.matches_inbound(ciphertext); - }); - }, - logger.withPrefix("[matchesSession]"), - ); - return matches!; - } - - public async recordSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> { - logger.info(`Recording problem on olm session with ${deviceKey} of type ${type}. Recreating: ${fixed}`); - await this.cryptoStore.storeEndToEndSessionProblem(deviceKey, type, fixed); - } - - public sessionMayHaveProblems(deviceKey: string, timestamp: number): Promise<IProblem | null> { - return this.cryptoStore.getEndToEndSessionProblem(deviceKey, timestamp); - } - - public filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> { - return this.cryptoStore.filterOutNotifiedErrorDevices(devices); - } - - // Outbound group session - // ====================== - - /** - * store an OutboundGroupSession in outboundGroupSessionStore - * - * @internal - */ - private saveOutboundGroupSession(session: OutboundGroupSession): void { - this.outboundGroupSessionStore[session.session_id()] = session.pickle(this.pickleKey); - } - - /** - * extract an OutboundGroupSession from outboundGroupSessionStore and call the - * given function - * - * @returns result of func - * @internal - */ - private getOutboundGroupSession<T>(sessionId: string, func: (session: OutboundGroupSession) => T): T { - 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. - */ - public createOutboundGroupSession(): string { - 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 - */ - public encryptGroupMessage(sessionId: string, payloadString: string): string { - logger.log(`encrypting msg with megolm session ${sessionId}`); - - checkPayloadLength(payloadString); - - return this.getOutboundGroupSession(sessionId, (session: OutboundGroupSession) => { - 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. - */ - public getOutboundGroupSessionKey(sessionId: string): IOutboundGroupSessionKey { - return this.getOutboundGroupSession(sessionId, function (session: OutboundGroupSession) { - 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 - */ - private unpickleInboundGroupSession<T>( - sessionData: InboundGroupSessionData, - func: (session: InboundGroupSession) => T, - ): T { - 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 - */ - private getInboundGroupSession( - roomId: string, - senderKey: string, - sessionId: string, - txn: unknown, - func: ( - session: InboundGroupSession | null, - data: InboundGroupSessionData | null, - withheld: IWithheld | null, - ) => void, - ): void { - this.cryptoStore.getEndToEndInboundGroupSession( - senderKey, - sessionId, - txn, - (sessionData: InboundGroupSessionData | null, withheld: IWithheld | null) => { - 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: InboundGroupSession) => { - 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 - */ - public async addInboundGroupSession( - roomId: string, - senderKey: string, - forwardingCurve25519KeyChain: string[], - sessionId: string, - sessionKey: string, - keysClaimed: Record<string, string>, - exportFormat: boolean, - extraSessionData: OlmGroupSessionExtraData = {}, - ): Promise<void> { - await this.cryptoStore.doTxn( - "readwrite", - [ - IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, - IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, - IndexedDBCryptoStore.STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS, - ], - (txn) => { - /* if we already have this session, consider updating it */ - this.getInboundGroupSession( - roomId, - senderKey, - sessionId, - txn, - ( - existingSession: InboundGroupSession | null, - existingSessionData: InboundGroupSessionData | null, - ) => { - // new session. - const session = new global.Olm.InboundGroupSession(); - try { - if (exportFormat) { - session.import_session(sessionKey); - } else { - session.create(sessionKey); - } - if (sessionId != session.session_id()) { - throw new Error("Mismatched group session ID from senderKey: " + senderKey); - } - - if (existingSession) { - logger.log(`Update for megolm session ${senderKey}|${sessionId}`); - if (existingSession.first_known_index() <= session.first_known_index()) { - 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.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.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.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.info( - `Storing megolm session ${senderKey}|${sessionId} with first index ` + - session.first_known_index(), - ); - - const sessionData = Object.assign({}, extraSessionData, { - room_id: roomId, - session: session.pickle(this.pickleKey), - keysClaimed: keysClaimed, - forwardingCurve25519KeyChain: forwardingCurve25519KeyChain, - }); - - this.cryptoStore.storeEndToEndInboundGroupSession(senderKey, sessionId, sessionData, txn); - - if (!existingSession && extraSessionData.sharedHistory) { - this.cryptoStore.addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn); - } - } finally { - session.free(); - } - }, - ); - }, - logger.withPrefix("[addInboundGroupSession]"), - ); - } - - /** - * Record in the data store why an inbound group session was withheld. - * - * @param 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` - */ - public async addInboundGroupSessionWithheld( - roomId: string, - senderKey: string, - sessionId: string, - code: string, - reason: string, - ): Promise<void> { - await this.cryptoStore.doTxn( - "readwrite", - [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD], - (txn) => { - this.cryptoStore.storeEndToEndInboundGroupSessionWithheld( - senderKey, - sessionId, - { - room_id: roomId, - code: code, - reason: reason, - }, - txn, - ); - }, - ); - } - - /** - * 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 - */ - public async decryptGroupMessage( - roomId: string, - senderKey: string, - sessionId: string, - body: string, - eventId: string, - timestamp: number, - ): Promise<IDecryptedGroupMessage | null> { - let result: IDecryptedGroupMessage | null = 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: Error; - - await this.cryptoStore.doTxn( - "readwrite", - [ - IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, - IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, - ], - (txn) => { - this.getInboundGroupSession(roomId, senderKey, sessionId, txn, (session, sessionData, withheld) => { - if (session === null || sessionData === null) { - if (withheld) { - error = new algorithms.DecryptionError( - "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", - calculateWithheldMessage(withheld), - { - session: senderKey + "|" + sessionId, - }, - ); - } - result = null; - return; - } - let res: ReturnType<InboundGroupSession["decrypt"]>; - try { - res = session.decrypt(body); - } catch (e) { - if ((<Error>e)?.message === "OLM.UNKNOWN_MESSAGE_INDEX" && withheld) { - error = new algorithms.DecryptionError( - "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", - calculateWithheldMessage(withheld), - { - session: senderKey + "|" + sessionId, - }, - ); - } else { - error = <Error>e; - } - return; - } - - let plaintext: string = res.plaintext; - if (plaintext === undefined) { - // @ts-ignore - Compatibility for older olm versions. - plaintext = res as string; - } else { - // Check if we have seen this message index before to detect replay attacks. - // If the event ID and timestamp are specified, and the match the event ID - // and timestamp from the last time we used this message index, then we - // don't consider it a replay attack. - const messageIndexKey = senderKey + "|" + sessionId + "|" + res.message_index; - if (messageIndexKey in this.inboundGroupSessionMessageIndexes) { - const msgInfo = this.inboundGroupSessionMessageIndexes[messageIndexKey]; - if (msgInfo.id !== eventId || msgInfo.timestamp !== timestamp) { - error = new Error( - "Duplicate message index, possible replay attack: " + messageIndexKey, - ); - return; - } - } - this.inboundGroupSessionMessageIndexes[messageIndexKey] = { - id: eventId, - timestamp: timestamp, - }; - } - - sessionData.session = session.pickle(this.pickleKey); - this.cryptoStore.storeEndToEndInboundGroupSession(senderKey, sessionId, sessionData, txn); - result = { - result: plaintext, - keysClaimed: sessionData.keysClaimed || {}, - senderKey: senderKey, - forwardingCurve25519KeyChain: sessionData.forwardingCurve25519KeyChain || [], - untrusted: !!sessionData.untrusted, - }; - }); - }, - logger.withPrefix("[decryptGroupMessage]"), - ); - - if (error!) { - throw error; - } - return result!; - } - - /** - * Determine if we have the keys for a given megolm session - * - * @param 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 - */ - public async hasInboundSessionKeys(roomId: string, senderKey: string, sessionId: string): Promise<boolean> { - let result: boolean; - await this.cryptoStore.doTxn( - "readonly", - [ - IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, - IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, - ], - (txn) => { - this.cryptoStore.getEndToEndInboundGroupSession(senderKey, sessionId, txn, (sessionData) => { - if (sessionData === null) { - result = false; - return; - } - - if (roomId !== sessionData.room_id) { - logger.warn( - `requested keys for inbound group session ${senderKey}|` + - `${sessionId}, with incorrect room_id ` + - `(expected ${sessionData.room_id}, ` + - `was ${roomId})`, - ); - result = false; - } else { - result = true; - } - }); - }, - logger.withPrefix("[hasInboundSessionKeys]"), - ); - - return result!; - } - - /** - * Extract the keys to a given megolm session, for sharing - * - * @param 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). - */ - public async getInboundGroupSessionKey( - roomId: string, - senderKey: string, - sessionId: string, - chainIndex?: number, - ): Promise<IInboundGroupSessionKey | null> { - let result: IInboundGroupSessionKey | null = null; - await this.cryptoStore.doTxn( - "readonly", - [ - IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, - IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, - ], - (txn) => { - this.getInboundGroupSession(roomId, senderKey, sessionId, txn, (session, sessionData) => { - if (session === null || 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.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 - */ - public exportInboundGroupSession( - senderKey: string, - sessionId: string, - sessionData: InboundGroupSessionData, - ): IMegolmSessionData { - 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, - } as IMegolmSessionData; - }); - } - - public async getSharedHistoryInboundGroupSessions( - roomId: string, - ): Promise<[senderKey: string, sessionId: string][]> { - let result: Promise<[senderKey: string, sessionId: string][]>; - await this.cryptoStore.doTxn( - "readonly", - [IndexedDBCryptoStore.STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS], - (txn) => { - result = this.cryptoStore.getSharedHistoryInboundGroupSessions(roomId, txn); - }, - logger.withPrefix("[getSharedHistoryInboundGroupSessionsForRoom]"), - ); - return result!; - } - - // Utilities - // ========= - - /** - * Verify an ed25519 signature. - * - * @param 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". - */ - public verifySignature(key: string, message: string, signature: string): void { - this.getUtility(function (util: Utility) { - util.ed25519_verify(key, message, signature); - }); - } -} - -export const WITHHELD_MESSAGES: Record<string, string> = { - "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 - */ -function calculateWithheldMessage(withheld: IWithheld): string { - 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"; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OutgoingRoomKeyRequestManager.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OutgoingRoomKeyRequestManager.ts deleted file mode 100644 index 4628b3e..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OutgoingRoomKeyRequestManager.ts +++ /dev/null @@ -1,485 +0,0 @@ -/* -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. -*/ - -import { v4 as uuidv4 } from "uuid"; - -import { logger } from "../logger"; -import { MatrixClient } from "../client"; -import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "./index"; -import { CryptoStore, OutgoingRoomKeyRequest } from "./store/base"; -import { EventType, ToDeviceMessageId } from "../@types/event"; -import { MapWithDefault } from "../utils"; - -/** - * Internal module. Management of outgoing room key requests. - * - * See https://docs.google.com/document/d/1m4gQkcnJkxNuBmb5NoFCIadIY-DyqqNAS3lloE73BlQ - * for draft documentation on what we're supposed to be implementing here. - */ - -// 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) <---------------------------+ - * ``` - */ -export enum RoomKeyRequestState { - /** request not yet sent */ - Unsent, - /** request sent, awaiting reply */ - Sent, - /** reply received, cancellation not yet sent */ - CancellationPending, - /** - * Cancellation not yet sent and will transition to UNSENT instead of - * being deleted once the cancellation has been sent. - */ - CancellationPendingAndWillResend, -} - -interface RequestMessageBase { - requesting_device_id: string; - request_id: string; -} - -interface RequestMessageRequest extends RequestMessageBase { - action: "request"; - body: IRoomKeyRequestBody; -} - -interface RequestMessageCancellation extends RequestMessageBase { - action: "request_cancellation"; -} - -type RequestMessage = RequestMessageRequest | RequestMessageCancellation; - -export class OutgoingRoomKeyRequestManager { - // handle for the delayed call to sendOutgoingRoomKeyRequests. Non-null - // if the callback has been set, or if it is still running. - private sendOutgoingRoomKeyRequestsTimer?: ReturnType<typeof setTimeout>; - - // sanity check to ensure that we don't end up with two concurrent runs - // of sendOutgoingRoomKeyRequests - private sendOutgoingRoomKeyRequestsRunning = false; - - private clientRunning = true; - - public constructor( - private readonly baseApis: MatrixClient, - private readonly deviceId: string, - private readonly cryptoStore: CryptoStore, - ) {} - - /** - * Called when the client is stopped. Stops any running background processes. - */ - public stop(): void { - logger.log("stopping OutgoingRoomKeyRequestManager"); - // stop the timer on the next run - this.clientRunning = false; - } - - /** - * Send any requests that have been queued - */ - public sendQueuedRequests(): void { - 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) - */ - public async queueRoomKeyRequest( - requestBody: IRoomKeyRequestBody, - recipients: IRoomKeyRequestRecipient[], - resend = false, - ): Promise<void> { - 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.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. - */ - public cancelRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<unknown> { - return this.cryptoStore.getOutgoingRoomKeyRequest(requestBody).then((req): unknown => { - 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.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.log( - "Tried to cancel room key request for " + - stringifyRequestBody(requestBody) + - " but it was already cancelled in another tab", - ); - return; - } - - // We don't want to wait for the timer, so we send it - // immediately. (We might actually end up racing with the timer, - // but that's ok: even if we make the request twice, we'll do it - // with the same transaction_id, so only one message will get - // sent). - // - // (We also don't want to wait for the response from the server - // here, as it will slow down processing of received keys if we - // do.) - this.sendOutgoingRoomKeyRequestCancellation(updatedReq).catch((e) => { - logger.error("Error sending room key request cancellation;" + " will retry later.", e); - this.startTimer(); - }); - }); - } - default: - throw new Error("unhandled state: " + req.state); - } - }); - } - - /** - * Look for room key requests by target device and state - * - * @param userId - Target user ID - * @param deviceId - Target device ID - * - * @returns resolves to a list of all the {@link OutgoingRoomKeyRequest} - */ - public getOutgoingSentRoomKeyRequest(userId: string, deviceId: string): Promise<OutgoingRoomKeyRequest[]> { - 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. - */ - public async cancelAndResendAllOutgoingRequests(): Promise<void[]> { - 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 - private startTimer(): void { - if (this.sendOutgoingRoomKeyRequestsTimer) { - return; - } - - const startSendingOutgoingRoomKeyRequests = (): void => { - if (this.sendOutgoingRoomKeyRequestsRunning) { - throw new Error("RoomKeyRequestSend already in progress!"); - } - this.sendOutgoingRoomKeyRequestsRunning = true; - - this.sendOutgoingRoomKeyRequests() - .finally(() => { - this.sendOutgoingRoomKeyRequestsRunning = false; - }) - .catch((e) => { - // this should only happen if there is an indexeddb error, - // in which case we're a bit stuffed anyway. - logger.warn(`error in OutgoingRoomKeyRequestManager: ${e}`); - }); - }; - - this.sendOutgoingRoomKeyRequestsTimer = 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). - private async sendOutgoingRoomKeyRequests(): Promise<void> { - 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.error("Error sending room key request; will retry later.", e); - this.sendOutgoingRoomKeyRequestsTimer = undefined; - } - } - - // given a RoomKeyRequest, send it and update the request record - private sendOutgoingRoomKeyRequest(req: OutgoingRoomKeyRequest): Promise<unknown> { - logger.log( - `Requesting keys for ${stringifyRequestBody(req.requestBody)}` + - ` from ${stringifyRecipientList(req.recipients)}` + - `(id ${req.requestId})`, - ); - - const requestMessage: 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. - private sendOutgoingRoomKeyRequestCancellation(req: OutgoingRoomKeyRequest, andResend = false): Promise<unknown> { - logger.log( - `Sending cancellation for key request for ` + - `${stringifyRequestBody(req.requestBody)} to ` + - `${stringifyRecipientList(req.recipients)} ` + - `(cancellation id ${req.cancellationTxnId})`, - ); - - const requestMessage: 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 - private sendMessageToDevices( - message: RequestMessage, - recipients: IRoomKeyRequestRecipient[], - txnId?: string, - ): Promise<{}> { - const contentMap = new MapWithDefault<string, Map<string, Record<string, any>>>(() => new Map()); - for (const recip of recipients) { - const userDeviceMap = contentMap.getOrCreate(recip.userId); - userDeviceMap.set(recip.deviceId, { - ...message, - [ToDeviceMessageId]: uuidv4(), - }); - } - - return this.baseApis.sendToDevice(EventType.RoomKeyRequest, contentMap, txnId); - } -} - -function stringifyRequestBody(requestBody: IRoomKeyRequestBody): string { - // 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: IRoomKeyRequestRecipient[]): string { - return `[${recipients.map((r) => `${r.userId}:${r.deviceId}`).join(",")}]`; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/RoomList.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/RoomList.ts deleted file mode 100644 index a73efcd..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/RoomList.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* -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 - */ - -import { CryptoStore } from "./store/base"; -import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store"; - -/* eslint-disable camelcase */ -export interface IRoomEncryption { - algorithm: string; - rotation_period_ms?: number; - rotation_period_msgs?: number; -} -/* eslint-enable camelcase */ - -export class RoomList { - // Object of roomId -> room e2e info object (body of the m.room.encryption event) - private roomEncryption: Record<string, IRoomEncryption> = {}; - - public constructor(private readonly cryptoStore?: CryptoStore) {} - - public async init(): Promise<void> { - await this.cryptoStore!.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ROOMS], (txn) => { - this.cryptoStore!.getEndToEndRooms(txn, (result) => { - this.roomEncryption = result; - }); - }); - } - - public getRoomEncryption(roomId: string): IRoomEncryption { - return this.roomEncryption[roomId] || null; - } - - public isRoomEncrypted(roomId: string): boolean { - return Boolean(this.getRoomEncryption(roomId)); - } - - public async setRoomEncryption(roomId: string, roomInfo: IRoomEncryption): Promise<void> { - // important that this happens before calling into the store - // as it prevents the Crypto::setRoomEncryption from calling - // this twice for consecutive m.room.encryption events - this.roomEncryption[roomId] = roomInfo; - await this.cryptoStore!.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ROOMS], (txn) => { - this.cryptoStore!.storeEndToEndRoom(roomId, roomInfo, txn); - }); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/SecretStorage.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/SecretStorage.ts deleted file mode 100644 index 5c9049f..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/SecretStorage.ts +++ /dev/null @@ -1,583 +0,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. -*/ - -import { v4 as uuidv4 } from "uuid"; - -import { logger } from "../logger"; -import * as olmlib from "./olmlib"; -import { randomString } from "../randomstring"; -import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from "./aes"; -import { ICryptoCallbacks, IEncryptedContent } from "."; -import { IContent, MatrixEvent } from "../models/event"; -import { ClientEvent, ClientEventHandlerMap, MatrixClient } from "../client"; -import { IAddSecretStorageKeyOpts } from "./api"; -import { TypedEventEmitter } from "../models/typed-event-emitter"; -import { defer, IDeferred } from "../utils"; -import { ToDeviceMessageId } from "../@types/event"; -import { SecretStorageKeyDescription, SecretStorageKeyDescriptionAesV1 } from "../secret-storage"; - -export const SECRET_STORAGE_ALGORITHM_V1_AES = "m.secret_storage.v1.aes-hmac-sha2"; - -// Some of the key functions use a tuple and some use an object... -export type SecretStorageKeyTuple = [keyId: string, keyInfo: SecretStorageKeyDescription]; -export type SecretStorageKeyObject = { keyId: string; keyInfo: SecretStorageKeyDescription }; - -export interface ISecretRequest { - requestId: string; - promise: Promise<string>; - cancel: (reason: string) => void; -} - -export interface IAccountDataClient extends TypedEventEmitter<ClientEvent.AccountData, ClientEventHandlerMap> { - // Subset of MatrixClient (which also uses any for the event content) - getAccountDataFromServer: <T extends { [k: string]: any }>(eventType: string) => Promise<T>; - getAccountData: (eventType: string) => IContent | null; - setAccountData: (eventType: string, content: any) => Promise<{}>; -} - -interface ISecretRequestInternal { - name: string; - devices: string[]; - deferred: IDeferred<string>; -} - -interface IDecryptors { - encrypt: (plaintext: string) => Promise<IEncryptedPayload>; - decrypt: (ciphertext: IEncryptedPayload) => Promise<string>; -} - -interface ISecretInfo { - encrypted: { - [keyId: string]: IEncryptedPayload; - }; -} - -/** - * Implements Secure Secret Storage and Sharing (MSC1946) - */ -export class SecretStorage<B extends MatrixClient | undefined = MatrixClient> { - private requests = new Map<string, ISecretRequestInternal>(); - - // In it's 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, this now has - // an extra, optional param for a real matrix client, so you can not pass it as long - // as you don't request any secrets. - // A better solution would probably be to split this class up into secret storage and - // secret sharing which are really two separate things, even though they share an MSC. - public constructor( - private readonly accountDataAdapter: IAccountDataClient, - private readonly cryptoCallbacks: ICryptoCallbacks, - private readonly baseApis: B, - ) {} - - public async getDefaultKeyId(): Promise<string | null> { - const defaultKey = await this.accountDataAdapter.getAccountDataFromServer<{ key: string }>( - "m.secret_storage.default_key", - ); - if (!defaultKey) return null; - return defaultKey.key; - } - - public setDefaultKeyId(keyId: string): Promise<void> { - return new Promise<void>((resolve, reject) => { - const listener = (ev: MatrixEvent): void => { - if (ev.getType() === "m.secret_storage.default_key" && ev.getContent().key === keyId) { - this.accountDataAdapter.removeListener(ClientEvent.AccountData, listener); - resolve(); - } - }; - this.accountDataAdapter.on(ClientEvent.AccountData, listener); - - this.accountDataAdapter.setAccountData("m.secret_storage.default_key", { key: keyId }).catch((e) => { - this.accountDataAdapter.removeListener(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) - */ - public async addKey( - algorithm: string, - opts: IAddSecretStorageKeyOpts = {}, - keyId?: string, - ): Promise<SecretStorageKeyObject> { - if (algorithm !== SECRET_STORAGE_ALGORITHM_V1_AES) { - throw new Error(`Unknown key algorithm ${algorithm}`); - } - - const keyInfo = { algorithm } as SecretStorageKeyDescriptionAesV1; - - if (opts.name) { - keyInfo.name = opts.name; - } - - if (opts.passphrase) { - keyInfo.passphrase = opts.passphrase; - } - if (opts.key) { - const { iv, mac } = await calculateKeyCheck(opts.key); - keyInfo.iv = iv; - keyInfo.mac = mac; - } - - if (!keyId) { - do { - keyId = randomString(32); - } while ( - await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>( - `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? - */ - public async getKey(keyId?: string | null): Promise<SecretStorageKeyTuple | null> { - if (!keyId) { - keyId = await this.getDefaultKeyId(); - } - if (!keyId) { - return null; - } - - const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>( - "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. - */ - public async hasKey(keyId?: string): Promise<boolean> { - return Boolean(await this.getKey(keyId)); - } - - /** - * 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 - */ - public async checkKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise<boolean> { - if (info.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { - if (info.mac) { - const { mac } = await calculateKeyCheck(key, info.iv); - return info.mac.replace(/=+$/g, "") === mac.replace(/=+$/g, ""); - } else { - // if we have no information, we have to assume the key is right - return true; - } - } else { - throw new Error("Unknown algorithm"); - } - } - - /** - * Store an encrypted secret on the server - * - * @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 key. - */ - public async store(name: string, secret: string, keys?: string[] | null): Promise<void> { - const encrypted: Record<string, IEncryptedPayload> = {}; - - 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<SecretStorageKeyDescription>( - "m.secret_storage.key." + keyId, - ); - if (!keyInfo) { - throw new Error("Unknown key: " + keyId); - } - - // encrypt secret, based on the algorithm - if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { - const keys = { [keyId]: keyInfo }; - const [, encryption] = await this.getSecretStorageKey(keys, name); - encrypted[keyId] = await encryption.encrypt(secret); - } else { - logger.warn("unknown algorithm for secret storage key " + keyId + ": " + keyInfo.algorithm); - // do nothing if we don't understand the encryption algorithm - } - } - - // save encrypted secret - await this.accountDataAdapter.setAccountData(name, { encrypted }); - } - - /** - * Get a secret from storage. - * - * @param name - the name of the secret - * - * @returns the contents of the secret - */ - public async get(name: string): Promise<string | undefined> { - const secretInfo = await this.accountDataAdapter.getAccountDataFromServer<ISecretInfo>(name); - if (!secretInfo) { - return; - } - if (!secretInfo.encrypted) { - throw new Error("Content is not encrypted!"); - } - - // get possible keys to decrypt - const keys: Record<string, SecretStorageKeyDescription> = {}; - for (const keyId of Object.keys(secretInfo.encrypted)) { - // get key information from key storage - const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>( - "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 - */ - public async isStored(name: string): Promise<Record<string, SecretStorageKeyDescription> | null> { - // check if secret exists - const secretInfo = await this.accountDataAdapter.getAccountDataFromServer<ISecretInfo>(name); - if (!secretInfo?.encrypted) return null; - - const ret: Record<string, SecretStorageKeyDescription> = {}; - - // 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<SecretStorageKeyDescription>( - "m.secret_storage.key." + keyId, - ); - if (!keyInfo) continue; - const encInfo = secretInfo.encrypted[keyId]; - - // only use keys we understand the encryption algorithm of - if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { - if (encInfo.iv && encInfo.ciphertext && encInfo.mac) { - ret[keyId] = keyInfo; - } - } - } - return Object.keys(ret).length ? ret : null; - } - - /** - * Request a secret from another device - * - * @param name - the name of the secret to request - * @param devices - the devices to request the secret from - */ - public request(this: SecretStorage<MatrixClient>, name: string, devices: string[]): ISecretRequest { - const requestId = this.baseApis.makeTxnId(); - - const deferred = defer<string>(); - this.requests.set(requestId, { name, devices, deferred }); - - const cancel = (reason: string): void => { - // send cancellation event - const cancelData = { - action: "request_cancellation", - requesting_device_id: this.baseApis.deviceId, - request_id: requestId, - }; - const toDevice: Map<string, typeof cancelData> = 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, - [ToDeviceMessageId]: uuidv4(), - }; - const toDevice: Map<string, typeof requestData> = new Map(); - for (const device of devices) { - toDevice.set(device, requestData); - } - 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, - }; - } - - public async onRequestReceived(this: SecretStorage<MatrixClient>, event: MatrixEvent): Promise<void> { - 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.info("received request for secret (" + sender + ", " + deviceId + ", " + content.request_id + ")"); - if (!this.cryptoCallbacks.onSecretRequested) { - return; - } - const secret = await this.cryptoCallbacks.onSecretRequested( - sender, - deviceId, - content.request_id, - content.name, - this.baseApis.checkDeviceTrust(sender, deviceId), - ); - if (secret) { - logger.info(`Preparing ${content.name} secret for ${deviceId}`); - const payload = { - type: "m.secret.send", - content: { - request_id: content.request_id, - secret: secret, - }, - }; - const encryptedContent: IEncryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.baseApis.crypto!.olmDevice.deviceCurve25519Key!, - ciphertext: {}, - [ToDeviceMessageId]: uuidv4(), - }; - 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.info(`Sending ${content.name} secret for ${deviceId}`); - this.baseApis.sendToDevice("m.room.encrypted", contentMap); - } else { - logger.info(`Request denied for ${content.name} secret for ${deviceId}`); - } - } - } - - public onSecretReceived(this: SecretStorage<MatrixClient>, event: MatrixEvent): void { - 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.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.error("sending device does not belong to the user it claims to be from"); - return; - } - - 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.log("secret share from unknown device with key", event.getSenderKey()); - return; - } - if (!requestControl.devices.includes(deviceInfo.deviceId)) { - logger.log("unsolicited secret share from device", deviceInfo.deviceId); - return; - } - // 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.log("secret share from unverified device"); - return; - } - - logger.log(`Successfully received secret ${requestControl.name} ` + `from ${deviceInfo.deviceId}`); - requestControl.deferred.resolve(content.secret); - } - } - - private async getSecretStorageKey( - keys: Record<string, SecretStorageKeyDescription>, - name: string, - ): Promise<[string, IDecryptors]> { - if (!this.cryptoCallbacks.getSecretStorageKey) { - throw new Error("No getSecretStorageKey callback supplied"); - } - - const returned = await this.cryptoCallbacks.getSecretStorageKey({ keys }, name); - - if (!returned) { - throw new Error("getSecretStorageKey callback returned falsey"); - } - if (returned.length < 2) { - throw new Error("getSecretStorageKey callback returned invalid data"); - } - - const [keyId, privateKey] = returned; - if (!keys[keyId]) { - throw new Error("App returned unknown key from getSecretStorageKey!"); - } - - if (keys[keyId].algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { - const decryption = { - encrypt: function (secret: string): Promise<IEncryptedPayload> { - return encryptAES(secret, privateKey, name); - }, - decrypt: function (encInfo: IEncryptedPayload): Promise<string> { - return decryptAES(encInfo, privateKey, name); - }, - }; - return [keyId, decryption]; - } else { - throw new Error("Unknown key type: " + keys[keyId].algorithm); - } - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/aes.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/aes.ts deleted file mode 100644 index 48470af..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/aes.ts +++ /dev/null @@ -1,157 +0,0 @@ -/* -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. -*/ - -import { decodeBase64, encodeBase64 } from "./olmlib"; -import { subtleCrypto, crypto, TextEncoder } from "./crypto"; - -// salt for HKDF, with 8 bytes of zeros -const zeroSalt = new Uint8Array(8); - -export interface IEncryptedPayload { - [key: string]: any; // extensible - /** the initialization vector in base64 */ - iv: string; - /** the ciphertext in base64 */ - ciphertext: string; - /** the HMAC in base64 */ - mac: string; -} - -/** - * 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 - */ -export async function encryptAES( - data: string, - key: Uint8Array, - name: string, - ivStr?: string, -): Promise<IEncryptedPayload> { - let iv: Uint8Array; - if (ivStr) { - iv = decodeBase64(ivStr); - } else { - iv = new Uint8Array(16); - 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 TextEncoder().encode(data); - - const ciphertext = await subtleCrypto.encrypt( - { - name: "AES-CTR", - counter: iv, - length: 64, - }, - aesKey, - encodedData, - ); - - const hmac = await subtleCrypto.sign({ name: "HMAC" }, hmacKey, ciphertext); - - return { - iv: encodeBase64(iv), - ciphertext: encodeBase64(ciphertext), - mac: encodeBase64(hmac), - }; -} - -/** - * decrypt a string - * - * @param data - the encrypted data - * @param key - the encryption key to use - * @param name - the name of the secret - */ -export async function decryptAES(data: IEncryptedPayload, key: Uint8Array, name: string): Promise<string> { - const [aesKey, hmacKey] = await deriveKeys(key, name); - - const ciphertext = decodeBase64(data.ciphertext); - - if (!(await subtleCrypto.verify({ name: "HMAC" }, hmacKey, decodeBase64(data.mac), ciphertext))) { - throw new Error(`Error decrypting secret ${name}: bad MAC`); - } - - const plaintext = await subtleCrypto.decrypt( - { - name: "AES-CTR", - counter: decodeBase64(data.iv), - length: 64, - }, - aesKey, - ciphertext, - ); - - return new TextDecoder().decode(new Uint8Array(plaintext)); -} - -async function deriveKeys(key: Uint8Array, name: string): Promise<[CryptoKey, CryptoKey]> { - const hkdfkey = await subtleCrypto.importKey("raw", key, { name: "HKDF" }, false, ["deriveBits"]); - const keybits = await 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 TextEncoder().encode(name), - hash: "SHA-256", - }, - hkdfkey, - 512, - ); - - const aesKey = keybits.slice(0, 32); - const hmacKey = keybits.slice(32); - - const aesProm = subtleCrypto.importKey("raw", aesKey, { name: "AES-CTR" }, false, ["encrypt", "decrypt"]); - - const hmacProm = subtleCrypto.importKey( - "raw", - hmacKey, - { - name: "HMAC", - hash: { name: "SHA-256" }, - }, - false, - ["sign", "verify"], - ); - - return 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. - */ -export function calculateKeyCheck(key: Uint8Array, iv?: string): Promise<IEncryptedPayload> { - return encryptAES(ZERO_STR, key, "", iv); -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/base.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/base.ts deleted file mode 100644 index 6473009..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/base.ts +++ /dev/null @@ -1,268 +0,0 @@ -/* -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 - */ - -import type { IMegolmSessionData } from "../../@types/crypto"; -import { MatrixClient } from "../../client"; -import { Room } from "../../models/room"; -import { OlmDevice } from "../OlmDevice"; -import { IContent, MatrixEvent, RoomMember } from "../../matrix"; -import { Crypto, IEncryptedContent, IEventDecryptionResult, IncomingRoomKeyRequest } from ".."; -import { DeviceInfo } from "../deviceinfo"; -import { IRoomEncryption } from "../RoomList"; -import { DeviceInfoMap } from "../DeviceList"; - -/** - * Map of registered encryption algorithm classes. A map from string to {@link EncryptionAlgorithm} class - */ -export const ENCRYPTION_CLASSES = new Map<string, new (params: IParams) => EncryptionAlgorithm>(); - -export type DecryptionClassParams<P extends IParams = IParams> = Omit<P, "deviceId" | "config">; - -/** - * map of registered encryption algorithm classes. Map from string to {@link DecryptionAlgorithm} class - */ -export const DECRYPTION_CLASSES = new Map<string, new (params: DecryptionClassParams) => DecryptionAlgorithm>(); - -export interface IParams { - /** The UserID for the local user */ - userId: string; - /** The identifier for this device. */ - deviceId: string; - /** crypto core */ - crypto: Crypto; - /** olm.js wrapper */ - olmDevice: OlmDevice; - /** base matrix api interface */ - baseApis: MatrixClient; - /** The ID of the room we will be sending to */ - roomId?: string; - /** The body of the m.room.encryption event */ - config: IRoomEncryption & object; -} - -/** - * base type for encryption implementations - */ -export abstract class EncryptionAlgorithm { - protected readonly userId: string; - protected readonly deviceId: string; - protected readonly crypto: Crypto; - protected readonly olmDevice: OlmDevice; - protected readonly baseApis: MatrixClient; - protected readonly roomId?: string; - - /** - * @param params - parameters - */ - public constructor(params: IParams) { - 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 - */ - public prepareToEncrypt(room: Room): void {} - - /** - * Encrypt a message event - * - * @public - * - * @param content - event content - * - * @returns Promise which resolves to the new event body - */ - public abstract encryptMessage(room: Room, eventType: string, content: IContent): Promise<IEncryptedContent>; - - /** - * 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 - */ - public onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void {} - - public reshareKeyWithDevice?( - senderKey: string, - sessionId: string, - userId: string, - device: DeviceInfo, - ): Promise<void>; - - public forceDiscardSession?(): void; -} - -/** - * base type for decryption implementations - */ -export abstract class DecryptionAlgorithm { - protected readonly userId: string; - protected readonly crypto: Crypto; - protected readonly olmDevice: OlmDevice; - protected readonly baseApis: MatrixClient; - protected readonly roomId?: string; - - public constructor(params: DecryptionClassParams) { - 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. - */ - public abstract decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult>; - - /** - * Handle a key event - * - * @param params - event key event - */ - public async onRoomKeyEvent(params: MatrixEvent): Promise<void> { - // ignore by default - } - - /** - * Import a room key - * - * @param opts - object - */ - public async importRoomKey(session: IMegolmSessionData, opts: object): Promise<void> { - // 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. - */ - public hasKeysForKeyRequest(keyRequest: IncomingRoomKeyRequest): Promise<boolean> { - return Promise.resolve(false); - } - - /** - * Send the response to a room key request - * - */ - public shareKeysWithDevice(keyRequest: IncomingRoomKeyRequest): void { - 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 - */ - public async retryDecryptionFromSender(senderKey: string): Promise<boolean> { - // ignore by default - return false; - } - - public onRoomKeyWithheldEvent?(event: MatrixEvent): Promise<void>; - public sendSharedHistoryInboundSessions?(devicesByUser: Map<string, DeviceInfo[]>): Promise<void>; -} - -/** - * 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. - */ -export class DecryptionError extends Error { - public readonly detailedString: string; - - public constructor(public readonly code: string, msg: string, details?: Record<string, string | Error>) { - super(msg); - this.code = code; - this.name = "DecryptionError"; - this.detailedString = detailedStringForDecryptionError(this, details); - } -} - -function detailedStringForDecryptionError(err: DecryptionError, details?: Record<string, string | Error>): string { - let result = err.name + "[msg: " + err.message; - - if (details) { - result += - ", " + - Object.keys(details) - .map((k) => k + ": " + details[k]) - .join(", "); - } - - result += "]"; - - return result; -} - -export 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 - */ - public constructor(msg: string, public readonly devices: DeviceInfoMap, public event?: MatrixEvent) { - super(msg); - 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 - */ -export function registerAlgorithm<P extends IParams = IParams>( - algorithm: string, - encryptor: new (params: P) => EncryptionAlgorithm, - decryptor: new (params: DecryptionClassParams<P>) => DecryptionAlgorithm, -): void { - ENCRYPTION_CLASSES.set(algorithm, encryptor as new (params: IParams) => EncryptionAlgorithm); - DECRYPTION_CLASSES.set(algorithm, decryptor as new (params: DecryptionClassParams) => DecryptionAlgorithm); -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/index.ts deleted file mode 100644 index b3c5b0e..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* -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. -*/ - -import "./olm"; -import "./megolm"; - -export * from "./base"; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/megolm.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/megolm.ts deleted file mode 100644 index 061e169..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/megolm.ts +++ /dev/null @@ -1,2208 +0,0 @@ -/* -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 - */ - -import { v4 as uuidv4 } from "uuid"; - -import type { IEventDecryptionResult, IMegolmSessionData } from "../../@types/crypto"; -import { logger, PrefixedLogger } from "../../logger"; -import * as olmlib from "../olmlib"; -import { - DecryptionAlgorithm, - DecryptionClassParams, - DecryptionError, - EncryptionAlgorithm, - IParams, - registerAlgorithm, - UnknownDeviceError, -} from "./base"; -import { IDecryptedGroupMessage, WITHHELD_MESSAGES } from "../OlmDevice"; -import { Room } from "../../models/room"; -import { DeviceInfo } from "../deviceinfo"; -import { IOlmSessionResult } from "../olmlib"; -import { DeviceInfoMap } from "../DeviceList"; -import { IContent, MatrixEvent } from "../../models/event"; -import { EventType, MsgType, ToDeviceMessageId } from "../../@types/event"; -import { IMegolmEncryptedContent, IncomingRoomKeyRequest, IEncryptedContent } from "../index"; -import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager"; -import { OlmGroupSessionExtraData } from "../../@types/crypto"; -import { MatrixError } from "../../http-api"; -import { immediate, MapWithDefault } from "../../utils"; - -// determine whether the key can be shared with invitees -export function isRoomSharedHistory(room: Room): boolean { - 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); -} - -interface IBlockedDevice { - code: string; - reason: string; - deviceInfo: DeviceInfo; -} - -// map user Id → device Id → IBlockedDevice -type BlockedMap = Map<string, Map<string, IBlockedDevice>>; - -export interface IOlmDevice<T = DeviceInfo> { - userId: string; - deviceInfo: T; -} - -/** - * 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: IEncryptedContent): boolean => { - 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 */ -interface RoomKey { - /** - * The Curve25519 key of the megolm session creator. - * - * For `m.room_key`, this is also the sender of the `m.room_key` to-device event. - * For `m.forwarded_room_key`, the two are different (and the key of the sender of the - * `m.forwarded_room_key` event is included in `forwardingKeyChain`) - */ - senderKey: string; - sessionId: string; - sessionKey: string; - exportFormat: boolean; - roomId: string; - algorithm: string; - /** - * A list of the curve25519 keys of the users involved in forwarding this key, most recent last. - * For `m.room_key` events, this is empty. - */ - forwardingKeyChain: string[]; - keysClaimed: Partial<Record<"ed25519", string>>; - extraSessionData: OlmGroupSessionExtraData; -} - -export interface IOutboundGroupSessionKey { - chain_index: number; - key: string; -} - -interface IMessage { - type: string; - content: { - "algorithm": string; - "room_id": string; - "sender_key"?: string; - "sender_claimed_ed25519_key"?: string; - "session_id": string; - "session_key": string; - "chain_index": number; - "forwarding_curve25519_key_chain"?: string[]; - "org.matrix.msc3061.shared_history": boolean; - }; -} - -interface IKeyForwardingMessage extends IMessage { - type: "m.forwarded_room_key"; -} - -interface IPayload extends Partial<IMessage> { - code?: string; - reason?: string; - room_id?: string; - session_id?: string; - algorithm?: string; - sender_key?: string; -} - -interface SharedWithData { - // The identity key of the device we shared with - deviceKey: string; - // The message index of the ratchet we shared with that device - messageIndex: number; -} - -/** - * @internal - */ -class OutboundSessionInfo { - /** number of times this session has been used */ - public useCount = 0; - /** when the session was created (ms since the epoch) */ - public creationTime: number; - /** devices with which we have shared the session key `userId -> {deviceId -> SharedWithData}` */ - public sharedWithDevices: MapWithDefault<string, Map<string, SharedWithData>> = new MapWithDefault(() => new Map()); - public blockedDevicesNotified: MapWithDefault<string, Map<string, boolean>> = new MapWithDefault(() => new Map()); - - /** - * @param sharedHistory - whether the session can be freely shared with - * other group members, according to the room history visibility settings - */ - public constructor(public readonly sessionId: string, public readonly sharedHistory = false) { - this.creationTime = new Date().getTime(); - } - - /** - * Check if it's time to rotate the session - */ - public needsRotation(rotationPeriodMsgs: number, rotationPeriodMs: number): boolean { - const sessionLifetime = new Date().getTime() - this.creationTime; - - if (this.useCount >= rotationPeriodMsgs || sessionLifetime >= rotationPeriodMs) { - logger.log("Rotating megolm session after " + this.useCount + " messages, " + sessionLifetime + "ms"); - return true; - } - - return false; - } - - public markSharedWithDevice(userId: string, deviceId: string, deviceKey: string, chainIndex: number): void { - this.sharedWithDevices.getOrCreate(userId).set(deviceId, { deviceKey, messageIndex: chainIndex }); - } - - public markNotifiedBlockedDevice(userId: string, deviceId: string): void { - 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. - */ - public sharedWithTooManyDevices(devicesInRoom: DeviceInfoMap): boolean { - for (const [userId, devices] of this.sharedWithDevices) { - if (!devicesInRoom.has(userId)) { - 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.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} - */ -export class MegolmEncryption extends EncryptionAlgorithm { - // 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). - private setupPromise = Promise.resolve<OutboundSessionInfo | null>(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). - private outboundSessions: Record<string, OutboundSessionInfo> = {}; - - private readonly sessionRotationPeriodMsgs: number; - private readonly sessionRotationPeriodMs: number; - private encryptionPreparation?: { - promise: Promise<void>; - startTime: number; - cancel: () => void; - }; - - protected readonly roomId: string; - private readonly prefixedLogger: PrefixedLogger; - - public constructor(params: IParams & Required<Pick<IParams, "roomId">>) { - super(params); - this.roomId = params.roomId; - this.prefixedLogger = 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. - */ - private async ensureOutboundSession( - room: Room, - devicesInRoom: DeviceInfoMap, - blocked: BlockedMap, - singleOlmCreationPhase = false, - ): Promise<OutboundSessionInfo> { - // 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: OutboundSessionInfo | null): Promise<OutboundSessionInfo> => { - 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; - } - - private async prepareSession( - devicesInRoom: DeviceInfoMap, - sharedHistory: boolean, - session: OutboundSessionInfo | null, - ): Promise<OutboundSessionInfo> { - // 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; - } - - private async shareSession( - devicesInRoom: DeviceInfoMap, - sharedHistory: boolean, - singleOlmCreationPhase: boolean, - blocked: BlockedMap, - session: OutboundSessionInfo, - ): Promise<void> { - // now check if we need to share with any devices - const shareMap: Record<string, DeviceInfo[]> = {}; - - 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: IPayload = { - 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 (): Promise<void> => { - // 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 (): Promise<void> => { - 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: IOlmDevice[] = []; - - // 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: string[] = []; - 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 (): Promise<void> => { - // 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: MapWithDefault<string, DeviceInfo[]> = new MapWithDefault(() => []); - const failedServerMap = new Set(); - for (const server of failedServers) { - failedServerMap.add(server); - } - const failedDevices: IOlmDevice[] = []; - 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 (): Promise<void> => { - 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: MapWithDefault<string, Map<string, { device: IBlockedDevice }>> = new 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 - */ - private async prepareNewSession(sharedHistory: boolean): Promise<OutboundSessionInfo> { - 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. - */ - private getDevicesWithoutSessions( - deviceMap: Map<string, Map<string, IOlmSessionResult>>, - devicesByUser: Map<string, DeviceInfo[]>, - noOlmDevices: IOlmDevice[] = [], - ): IOlmDevice[] { - 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 - */ - private splitDevices<T extends DeviceInfo | IBlockedDevice>( - devicesByUser: Map<string, Map<string, { device: T }>>, - ): IOlmDevice<T>[][] { - const maxDevicesPerRequest = 20; - - // use an array where the slices of a content map gets stored - let currentSlice: IOlmDevice<T>[] = []; - 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. - */ - private encryptAndSendKeysToDevices( - session: OutboundSessionInfo, - chainIndex: number, - devices: IOlmDevice[], - payload: IPayload, - ): Promise<void> { - 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. - */ - private async sendBlockedNotificationsToDevices( - session: OutboundSessionInfo, - userDeviceMap: IOlmDevice<IBlockedDevice>[], - payload: IPayload, - ): Promise<void> { - const contentMap: MapWithDefault<string, Map<string, IPayload>> = new 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 = { - ...payload, - code: blockedInfo.code, - reason: blockedInfo.reason, - [ToDeviceMessageId]: uuidv4(), - }; - - 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 - */ - public async reshareKeyWithDevice( - senderKey: string, - sessionId: string, - userId: string, - device: DeviceInfo, - ): Promise<void> { - 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: IEncryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key!, - ciphertext: {}, - [ToDeviceMessageId]: uuidv4(), - }; - 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. - */ - private async shareKeyWithDevices( - session: OutboundSessionInfo, - key: IOutboundGroupSessionKey, - payload: IPayload, - devicesByUser: Map<string, DeviceInfo[]>, - errorDevices: IOlmDevice[], - otkTimeout: number, - failedServers?: string[], - ): Promise<void> { - 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); - } - - private async shareKeyWithOlmSessions( - session: OutboundSessionInfo, - key: IOutboundGroupSessionKey, - payload: IPayload, - deviceMap: Map<string, Map<string, IOlmSessionResult>>, - ): Promise<void> { - 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 - */ - private async notifyFailedOlmDevices( - session: OutboundSessionInfo, - key: IOutboundGroupSessionKey, - failedDevices: IOlmDevice[], - ): Promise<void> { - 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: MapWithDefault<string, Map<string, { device: IBlockedDevice }>> = new 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: 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 - */ - private async notifyBlockedDevices( - session: OutboundSessionInfo, - devicesByUser: Map<string, Map<string, { device: IBlockedDevice }>>, - ): Promise<void> { - const payload: IPayload = { - 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 - */ - public prepareToEncrypt(room: Room): () => void { - 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 = (): boolean => cancelled; - - this.encryptionPreparation = { - startTime: Date.now(), - promise: (async (): Promise<void> => { - 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: (): void => { - // 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 - */ - public async encryptMessage(room: Room, eventType: string, content: IContent): Promise<IMegolmEncryptedContent> { - 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: IEncryptedContent = { - 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; - } - - private isVerificationEvent(eventType: string, content: IContent): boolean { - switch (eventType) { - case EventType.KeyVerificationCancel: - case EventType.KeyVerificationDone: - case EventType.KeyVerificationMac: - case EventType.KeyVerificationStart: - case EventType.KeyVerificationKey: - case EventType.KeyVerificationReady: - case EventType.KeyVerificationAccept: { - return true; - } - case EventType.RoomMessage: { - return content["msgtype"] === 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. - */ - public forceDiscardSession(): void { - 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. - */ - private checkForUnknownDevices(devicesInRoom: DeviceInfoMap): void { - const unknownDevices: MapWithDefault<string, Map<string, DeviceInfo>> = new 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 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. - */ - private removeUnknownDevices(devicesInRoom: DeviceInfoMap): void { - 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`. - */ - private async getDevicesInRoom( - room: Room, - forceDistributeToUnverified?: boolean, - ): Promise<[DeviceInfoMap, BlockedMap]>; - private async getDevicesInRoom( - room: Room, - forceDistributeToUnverified?: boolean, - isCancelled?: () => boolean, - ): Promise<null | [DeviceInfoMap, BlockedMap]>; - private async getDevicesInRoom( - room: Room, - forceDistributeToUnverified = false, - isCancelled?: () => boolean, - ): Promise<null | [DeviceInfoMap, BlockedMap]> { - 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 MapWithDefault<string, Map<string, IBlockedDevice>>(() => 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 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: 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} - */ -export class MegolmDecryption extends DecryptionAlgorithm { - // 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 - private pendingEvents = new Map<string, Map<string, Set<MatrixEvent>>>(); - - // this gets stubbed out by the unit tests. - private olmlib = olmlib; - - protected readonly roomId: string; - private readonly prefixedLogger: PrefixedLogger; - - public constructor(params: DecryptionClassParams<IParams & Required<Pick<IParams, "roomId">>>) { - super(params); - this.roomId = params.roomId; - this.prefixedLogger = 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. - */ - public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> { - const content = event.getWireContent(); - - if (!content.sender_key || !content.session_id || !content.ciphertext) { - throw new DecryptionError("MEGOLM_MISSING_FIELDS", "Missing fields in input"); - } - - // we add the event to the pending list *before* we start decryption. - // - // then, if the key turns up while decryption is in progress (and - // decryption fails), we will schedule a retry. - // (fixes https://github.com/vector-im/element-web/issues/5001) - this.addEventToPendingList(event); - - let res: IDecryptedGroupMessage | null; - try { - res = await this.olmDevice.decryptGroupMessage( - event.getRoomId()!, - content.sender_key, - content.session_id, - content.ciphertext, - event.getId()!, - event.getTs(), - ); - } catch (e) { - if ((<Error>e).name === "DecryptionError") { - // re-throw decryption errors as-is - throw e; - } - - let errorCode = "OLM_DECRYPT_GROUP_MESSAGE_ERROR"; - - if ((<MatrixError>e)?.message === "OLM.UNKNOWN_MESSAGE_INDEX") { - this.requestKeysForEvent(event); - - errorCode = "OLM_UNKNOWN_MESSAGE_INDEX"; - } - - throw new 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 as "no_olm"] || PROBLEM_DESCRIPTIONS.unknown; - if (problem.fixed) { - problemDescription += " Trying to create a new secure channel and re-requesting the keys."; - } - throw new DecryptionError("MEGOLM_UNKNOWN_INBOUND_SESSION_ID", problemDescription, { - session: content.sender_key + "|" + content.session_id, - }); - } - - throw new DecryptionError( - "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", - "The sender's device has not sent us the keys for this message.", - { - session: content.sender_key + "|" + content.session_id, - }, - ); - } - - // Success. We can remove the event from the pending list, if - // that hasn't already happened. 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 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, - }; - } - - private requestKeysForEvent(event: MatrixEvent): void { - 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 - * - */ - private addEventToPendingList(event: MatrixEvent): void { - 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<string, Set<MatrixEvent>>()); - } - 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 - * - */ - private removeEventFromPendingList(event: MatrixEvent): void { - 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 - * - */ - private roomKeyFromEvent(event: MatrixEvent): RoomKey | undefined { - const senderKey = event.getSenderKey()!; - const content = event.getContent<Partial<IMessage["content"]>>(); - const extraSessionData: OlmGroupSessionExtraData = {}; - - 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: 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 - * - */ - private forwardedRoomKeyFromEvent(event: MatrixEvent): RoomKey | undefined { - // 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<Partial<IMessage["content"]>>(); - - 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 - * - */ - private async shouldAcceptForwardedKey(event: MatrixEvent, roomKey: RoomKey): Promise<boolean> { - 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 - * - */ - private async wasRoomKeyRequested(event: MatrixEvent, roomKey: RoomKey): Promise<boolean> { - // 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()!, - "*", - [RoomKeyRequestState.Sent], - ); - - return outgoingRequests.some( - (req) => req.requestBody.room_id === roomKey.roomId && req.requestBody.session_id === roomKey.sessionId, - ); - } - - private wasRoomKeyForwardedByInviter(event: MatrixEvent, roomKey: RoomKey): boolean { - // 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; - } - } - - private wasRoomKeyForwardedAsHistory(roomKey: RoomKey): boolean { - 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. - */ - private shouldParkForwardedKey(roomKey: RoomKey): boolean { - 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 - * - */ - private async parkForwardedKey(event: MatrixEvent, roomKey: RoomKey): Promise<void> { - 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.withPrefix("[addParkedSharedHistory]"), - ); - } - - /** - * Add the given room key to our store. - * - * @param roomKey - The room key that should be added to the store. - * - * @internal - * - */ - private async addRoomKey(roomKey: RoomKey): Promise<void> { - 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 - * - */ - private async onForwardedRoomKey(event: MatrixEvent): Promise<void> { - 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); - } - } - - public async onRoomKeyEvent(event: MatrixEvent): Promise<void> { - 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 - */ - public async onRoomKeyWithheldEvent(event: MatrixEvent): Promise<void> { - 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); - } - } - - private async onNoOlmWithheldEvent(event: MatrixEvent): Promise<void> { - 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: IEncryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key!, - ciphertext: {}, - [ToDeviceMessageId]: uuidv4(), - }; - 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]])]]), - ); - } - - public hasKeysForKeyRequest(keyRequest: IncomingRoomKeyRequest): Promise<boolean> { - const body = keyRequest.requestBody; - - return this.olmDevice.hasInboundSessionKeys( - body.room_id, - body.sender_key, - body.session_id, - // TODO: ratchet index - ); - } - - public shareKeysWithDevice(keyRequest: IncomingRoomKeyRequest): void { - 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: IEncryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key!, - ciphertext: {}, - [ToDeviceMessageId]: uuidv4(), - }; - - 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]])]]), - ); - }); - }); - } - - private async buildKeyForwardingMessage( - roomId: string, - senderKey: string, - sessionId: string, - ): Promise<IKeyForwardingMessage> { - 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 - */ - public importRoomKey( - session: IMegolmSessionData, - { untrusted, source }: { untrusted?: boolean; source?: string } = {}, - ): Promise<void> { - const extraSessionData: OlmGroupSessionExtraData = {}; - 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 - */ - private async retryDecryption( - senderKey: string, - sessionId: string, - forceRedecryptIfUntrusted?: boolean, - ): Promise<boolean> { - 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); - } - - public async retryDecryptionFromSender(senderKey: string): Promise<boolean> { - 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); - } - - public async sendSharedHistoryInboundSessions(devicesByUser: Map<string, DeviceInfo[]>): Promise<void> { - 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: Promise<unknown>[] = []; - const contentMap: Map<string, Map<string, IEncryptedContent>> = new Map(); - for (const [userId, devices] of devicesByUser) { - const deviceMessages = new Map(); - contentMap.set(userId, deviceMessages); - for (const deviceInfo of devices) { - const encryptedContent: IEncryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key!, - ciphertext: {}, - [ToDeviceMessageId]: uuidv4(), - }; - 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); - } - } -} - -const PROBLEM_DESCRIPTIONS = { - no_olm: "The sender was unable to establish a secure channel.", - unknown: "The secure channel with the sender was corrupted.", -}; - -registerAlgorithm(olmlib.MEGOLM_ALGORITHM, MegolmEncryption, MegolmDecryption); diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/olm.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/olm.ts deleted file mode 100644 index 1a79554..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/olm.ts +++ /dev/null @@ -1,329 +0,0 @@ -/* -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 - */ - -import type { IEventDecryptionResult } from "../../@types/crypto"; -import { logger } from "../../logger"; -import * as olmlib from "../olmlib"; -import { DeviceInfo } from "../deviceinfo"; -import { DecryptionAlgorithm, DecryptionError, EncryptionAlgorithm, registerAlgorithm } from "./base"; -import { Room } from "../../models/room"; -import { IContent, MatrixEvent } from "../../models/event"; -import { IEncryptedContent, IOlmEncryptedContent } from "../index"; -import { IInboundSession } from "../OlmDevice"; - -const DeviceVerification = DeviceInfo.DeviceVerification; - -export interface IMessage { - type: number; - body: string; -} - -/** - * Olm encryption implementation - * - * @param params - parameters, as per {@link EncryptionAlgorithm} - */ -class OlmEncryption extends EncryptionAlgorithm { - private sessionPrepared = false; - private prepPromise: Promise<void> | null = null; - - /** - * @internal - - * @param roomMembers - list of currently-joined users in the room - * @returns Promise which resolves when setup is complete - */ - private ensureSession(roomMembers: string[]): Promise<void> { - 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 - */ - public async encryptMessage(room: Room, eventType: string, content: IContent): Promise<IOlmEncryptedContent> { - // 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: IEncryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key!, - ciphertext: {}, - }; - - const promises: Promise<void>[] = []; - - 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 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. - */ - public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> { - const content = event.getWireContent(); - const deviceKey = content.sender_key; - const ciphertext = content.ciphertext; - - if (!ciphertext) { - throw new DecryptionError("OLM_MISSING_CIPHERTEXT", "Missing ciphertext"); - } - - if (!(this.olmDevice.deviceCurve25519Key! in ciphertext)) { - throw new DecryptionError("OLM_NOT_INCLUDED_IN_RECIPIENTS", "Not included in recipients"); - } - const message = ciphertext[this.olmDevice.deviceCurve25519Key!]; - let payloadString: string; - - try { - payloadString = await this.decryptMessage(deviceKey, message); - } catch (e) { - throw new DecryptionError("OLM_BAD_ENCRYPTED_MESSAGE", "Bad Encrypted Message", { - sender: deviceKey, - err: e as Error, - }); - } - - const payload = JSON.parse(payloadString); - - // check that we were the intended recipient, to avoid unknown-key attack - // https://github.com/vector-im/vector-web/issues/2483 - if (payload.recipient != this.userId) { - throw new DecryptionError("OLM_BAD_RECIPIENT", "Message was intented for " + payload.recipient); - } - - if (payload.recipient_keys.ed25519 != this.olmDevice.deviceEd25519Key) { - throw new DecryptionError("OLM_BAD_RECIPIENT_KEY", "Message not intended for this device", { - intended: payload.recipient_keys.ed25519, - our_key: this.olmDevice.deviceEd25519Key!, - }); - } - - // check that the device that encrypted the event belongs to the user - // that the event claims it's from. 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. Some event handlers, such as - // secret sharing, may be more strict and reject events that come from - // unknown devices. - 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 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 DecryptionError("OLM_FORWARDED_MESSAGE", "Message forwarded from " + payload.sender, { - reported_sender: event.getSender()!, - }); - } - - // Olm events intended for a room have a room_id. - if (payload.room_id !== event.getRoomId()) { - throw new DecryptionError("OLM_BAD_ROOM", "Message intended for room " + payload.room_id, { - reported_room: event.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. - */ - private decryptMessage(theirDeviceIdentityKey: string, message: IMessage): Promise<string> { - // 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; - } - } - - private async reallyDecryptMessage(theirDeviceIdentityKey: string, message: IMessage): Promise<string> { - const sessionIds = await this.olmDevice.getSessionIdsForDevice(theirDeviceIdentityKey); - - // try each session in turn. - const decryptionErrors: Record<string, string> = {}; - for (const sessionId of sessionIds) { - try { - const payload = await this.olmDevice.decryptMessage( - theirDeviceIdentityKey, - sessionId, - message.type, - message.body, - ); - logger.log("Decrypted Olm message from " + theirDeviceIdentityKey + " with session " + sessionId); - return payload; - } catch (e) { - const foundSession = await this.olmDevice.matchesSession( - theirDeviceIdentityKey, - sessionId, - message.type, - message.body, - ); - - if (foundSession) { - // decryption failed, but it was a prekey message matching this - // session, so it should have worked. - throw new Error( - "Error decrypting prekey message with existing session id " + - sessionId + - ": " + - (<Error>e).message, - ); - } - - // otherwise it's probably a message for another session; carry on, but - // keep a record of the error - decryptionErrors[sessionId] = (<Error>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: IInboundSession; - try { - res = await this.olmDevice.createInboundSession(theirDeviceIdentityKey, message.type, message.body); - } catch (e) { - decryptionErrors["(new)"] = (<Error>e).message; - throw new Error("Error decrypting prekey message: " + JSON.stringify(decryptionErrors)); - } - - logger.log("created new inbound Olm session ID " + res.session_id + " with " + theirDeviceIdentityKey); - return res.payload; - } -} - -registerAlgorithm(olmlib.OLM_ALGORITHM, OlmEncryption, OlmDecryption); diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/api.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/api.ts deleted file mode 100644 index 9e9ba52..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/api.ts +++ /dev/null @@ -1,127 +0,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. -*/ - -import { DeviceInfo } from "./deviceinfo"; -import { IKeyBackupInfo } from "./keybackup"; -import { PassphraseInfo } from "../secret-storage"; - -/* re-exports for backwards compatibility. */ -export { - PassphraseInfo as IPassphraseInfo, - SecretStorageKeyDescription as ISecretStorageKeyInfo, -} from "../secret-storage"; - -// TODO: Merge this with crypto.js once converted - -export enum CrossSigningKey { - Master = "master", - SelfSigning = "self_signing", - UserSigning = "user_signing", -} - -export interface IEncryptedEventInfo { - /** - * whether the event is encrypted (if not encrypted, some of the other properties may not be set) - */ - encrypted: boolean; - - /** - * the sender's key - */ - senderKey: string; - - /** - * the algorithm used to encrypt the event - */ - algorithm: string; - - /** - * whether we can be sure that the owner of the senderKey sent the event - */ - authenticated: boolean; - - /** - * the sender's device information, if available - */ - sender?: DeviceInfo; - - /** - * if the event's ed25519 and curve25519 keys don't match (only meaningful if `sender` is set) - */ - mismatchedSender: boolean; -} - -export interface IRecoveryKey { - keyInfo?: IAddSecretStorageKeyOpts; - privateKey: Uint8Array; - encodedPrivateKey?: string; -} - -export interface ICreateSecretStorageOpts { - /** - * Function called to await a secret storage key creation flow. - * @returns Promise resolving 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. - */ - createSecretStorageKey?: () => Promise<IRecoveryKey>; - - /** - * The current key backup object. If passed, - * the passphrase and recovery key from this backup will be used. - */ - keyBackupInfo?: IKeyBackupInfo; - - /** - * 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. - */ - setupNewKeyBackup?: boolean; - - /** - * Reset even if keys already exist. - */ - setupNewSecretStorage?: boolean; - - /** - * Function called to get the user's - * current key backup passphrase. Should return a promise that resolves with a Uint8Array - * containing the key, or rejects if the key cannot be obtained. - */ - getKeyBackupPassphrase?: () => Promise<Uint8Array>; -} - -export interface IAddSecretStorageKeyOpts { - pubkey?: string; - passphrase?: PassphraseInfo; - name?: string; - key?: Uint8Array; -} - -export interface IImportOpts { - stage: string; // TODO: Enum - successes: number; - failures: number; - total: number; -} - -export interface IImportRoomKeysOpts { - /** called with an object that has a "stage" param */ - progressCallback?: (stage: IImportOpts) => void; - untrusted?: boolean; - source?: string; // TODO: Enum -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/backup.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/backup.ts deleted file mode 100644 index d240bda..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/backup.ts +++ /dev/null @@ -1,813 +0,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. -*/ - -/** - * Classes for dealing with key backup. - */ - -import type { IMegolmSessionData } from "../@types/crypto"; -import { MatrixClient } from "../client"; -import { logger } from "../logger"; -import { MEGOLM_ALGORITHM, verifySignature } from "./olmlib"; -import { DeviceInfo } from "./deviceinfo"; -import { DeviceTrustLevel } from "./CrossSigning"; -import { keyFromPassphrase } from "./key_passphrase"; -import { safeSet, sleep } from "../utils"; -import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store"; -import { encodeRecoveryKey } from "./recoverykey"; -import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from "./aes"; -import { - Curve25519SessionData, - IAes256AuthData, - ICurve25519AuthData, - IKeyBackupInfo, - IKeyBackupSession, -} from "./keybackup"; -import { UnstableValue } from "../NamespacedValue"; -import { CryptoEvent } from "./index"; -import { crypto } from "./crypto"; -import { HTTPError, MatrixError } from "../http-api"; - -const KEY_BACKUP_KEYS_PER_REQUEST = 200; -const KEY_BACKUP_CHECK_RATE_LIMIT = 5000; // ms - -type AuthData = IKeyBackupInfo["auth_data"]; - -type SigInfo = { - deviceId: string; - valid?: boolean | null; // true: valid, false: invalid, null: cannot attempt validation - device?: DeviceInfo | null; - crossSigningId?: boolean; - deviceTrust?: DeviceTrustLevel; -}; - -export type TrustInfo = { - usable: boolean; // is the backup trusted, true iff there is a sig that is valid & from a trusted device - sigs: SigInfo[]; - // eslint-disable-next-line camelcase - trusted_locally?: boolean; -}; - -export interface IKeyBackupCheck { - backupInfo?: IKeyBackupInfo; - trustInfo: TrustInfo; -} - -/* eslint-disable camelcase */ -export interface IPreparedKeyBackupVersion { - algorithm: string; - auth_data: AuthData; - recovery_key: string; - privateKey: Uint8Array; -} -/* eslint-enable camelcase */ - -/** A function used to get the secret key for a backup. - */ -type GetKey = () => Promise<ArrayLike<number>>; - -interface BackupAlgorithmClass { - algorithmName: string; - // initialize from an existing backup - init(authData: AuthData, getKey: GetKey): Promise<BackupAlgorithm>; - - // prepare a brand new backup - prepare(key?: string | Uint8Array | null): Promise<[Uint8Array, AuthData]>; - - checkBackupVersion(info: IKeyBackupInfo): void; -} - -interface BackupAlgorithm { - untrusted: boolean; - encryptSession(data: Record<string, any>): Promise<Curve25519SessionData | IEncryptedPayload>; - decryptSessions(ciphertexts: Record<string, IKeyBackupSession>): Promise<IMegolmSessionData[]>; - authData: AuthData; - keyMatches(key: ArrayLike<number>): Promise<boolean>; - free(): void; -} - -export interface IKeyBackup { - rooms: { - [roomId: string]: { - sessions: { - [sessionId: string]: IKeyBackupSession; - }; - }; - }; -} - -/** - * Manages the key backup. - */ -export class BackupManager { - private algorithm: BackupAlgorithm | undefined; - public backupInfo: IKeyBackupInfo | undefined; // The info dict from /room_keys/version - public checkedForBackup: boolean; // Have we checked the server for a backup we can use? - private sendingBackups: boolean; // Are we currently sending backups? - private sessionLastCheckAttemptedTime: Record<string, number> = {}; // When did we last try to check the server for a given session id? - - public constructor(private readonly baseApis: MatrixClient, public readonly getKey: GetKey) { - this.checkedForBackup = false; - this.sendingBackups = false; - } - - public get version(): string | undefined { - 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 - */ - public static checkBackupVersion(info: IKeyBackupInfo): void { - 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); - } - - public static makeAlgorithm(info: IKeyBackupInfo, getKey: GetKey): Promise<BackupAlgorithm> { - const Algorithm = algorithmsByName[info.algorithm]; - if (!Algorithm) { - throw new Error("Unknown backup algorithm"); - } - return Algorithm.init(info.auth_data, getKey); - } - - public async enableKeyBackup(info: IKeyBackupInfo): Promise<void> { - this.backupInfo = info; - if (this.algorithm) { - this.algorithm.free(); - } - - this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey); - - this.baseApis.emit(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. - */ - public disableKeyBackup(): void { - if (this.algorithm) { - this.algorithm.free(); - } - this.algorithm = undefined; - - this.backupInfo = undefined; - - this.baseApis.emit(CryptoEvent.KeyBackupStatus, false); - } - - public getKeyBackupEnabled(): boolean | null { - if (!this.checkedForBackup) { - return null; - } - return Boolean(this.algorithm); - } - - public async prepareKeyBackupVersion( - key?: string | Uint8Array | null, - algorithm?: string | undefined, - ): Promise<IPreparedKeyBackupVersion> { - const Algorithm = algorithm ? algorithmsByName[algorithm] : DefaultAlgorithm; - if (!Algorithm) { - throw new Error("Unknown backup algorithm"); - } - - const [privateKey, authData] = await Algorithm.prepare(key); - const recoveryKey = encodeRecoveryKey(privateKey)!; - return { - algorithm: Algorithm.algorithmName, - auth_data: authData, - recovery_key: recoveryKey, - privateKey, - }; - } - - public async createKeyBackupVersion(info: IKeyBackupInfo): Promise<void> { - 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. - */ - public async checkAndStart(): Promise<IKeyBackupCheck | null> { - logger.log("Checking key backup status..."); - if (this.baseApis.isGuest()) { - logger.log("Skipping key backup check since user is guest"); - this.checkedForBackup = true; - return null; - } - let backupInfo: IKeyBackupInfo | undefined; - try { - backupInfo = (await this.baseApis.getKeyBackupVersion()) ?? undefined; - } catch (e) { - logger.log("Error checking for active key backup", e); - if ((<HTTPError>e).httpStatus === 404) { - // 404 is returned when the key backup does not exist, so that - // counts as successfully checking. - this.checkedForBackup = true; - } - return null; - } - this.checkedForBackup = true; - - const trustInfo = await this.isKeyBackupTrusted(backupInfo); - - if (trustInfo.usable && !this.backupInfo) { - logger.log(`Found usable key backup v${backupInfo!.version}: enabling key backups`); - await this.enableKeyBackup(backupInfo!); - } else if (!trustInfo.usable && this.backupInfo) { - logger.log("No usable key backup: disabling key backup"); - this.disableKeyBackup(); - } else if (!trustInfo.usable && !this.backupInfo) { - logger.log("No usable key backup: not enabling key backup"); - } else if (trustInfo.usable && this.backupInfo) { - // may not be the same version: if not, we should switch - if (backupInfo!.version !== this.backupInfo.version) { - logger.log( - `On backup version ${this.backupInfo.version} but ` + - `found version ${backupInfo!.version}: switching.`, - ); - this.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.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. - */ - public async checkKeyBackup(): Promise<IKeyBackupCheck | null> { - 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. - */ - public async queryKeyBackupRateLimited( - targetRoomId: string | undefined, - targetSessionId: string | undefined, - ): Promise<void> { - 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 - */ - public async isKeyBackupTrusted(backupInfo?: IKeyBackupInfo): Promise<TrustInfo> { - const ret = { - usable: false, - trusted_locally: false, - sigs: [] as SigInfo[], - }; - - if (!backupInfo || !backupInfo.algorithm || !backupInfo.auth_data || !backupInfo.auth_data.signatures) { - 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: BackupAlgorithm | null = null; - try { - algorithm = await BackupManager.makeAlgorithm(backupInfo, async () => privKey); - - if (await algorithm.keyMatches(privKey)) { - 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.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: 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 verifySignature( - this.baseApis.crypto!.olmDevice, - backupInfo.auth_data, - userId, - sigInfo.deviceId, - crossSigningId, - ); - sigInfo.valid = true; - } catch (e) { - 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 verifySignature( - this.baseApis.crypto!.olmDevice, - backupInfo.auth_data, - userId, - device.deviceId, - device.getFingerprint(), - ); - sigInfo.valid = true; - } catch (e) { - 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.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. - */ - public async scheduleKeyBackupSend(maxDelay = 10000): Promise<void> { - if (this.sendingBackups) return; - - this.sendingBackups = true; - - try { - // wait between 0 and `maxDelay` seconds, to avoid backup - // requests from different clients hitting the server all at - // the same time when a new key is sent - const delay = Math.random() * maxDelay; - await sleep(delay); - let numFailures = 0; // number of consecutive failures - 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.log("Key backup request failed", err); - if ((<MatrixError>err).data) { - if ( - (<MatrixError>err).data.errcode == "M_NOT_FOUND" || - (<MatrixError>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(CryptoEvent.KeyBackupFailed, (<MatrixError>err).data.errcode!); - throw err; - } - } - } - if (numFailures) { - // exponential backoff if we have failures - await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4))); - } - } - } finally { - this.sendingBackups = false; - } - } - - /** - * Take some e2e keys waiting to be backed up and send them - * to the backup. - * - * @param limit - Maximum number of keys to back up - * @returns Number of sessions backed up - */ - public async backupPendingKeys(limit: number): Promise<number> { - 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(CryptoEvent.KeyBackupSessionsRemaining, remaining); - - const rooms: IKeyBackup["rooms"] = {}; - for (const session of sessions) { - const roomId = session.sessionData!.room_id; - safeSet(rooms, roomId, rooms[roomId] || { sessions: {} }); - - const sessionData = this.baseApis.crypto!.olmDevice.exportInboundGroupSession( - session.senderKey, - session.sessionId, - session.sessionData!, - ); - sessionData.algorithm = MEGOLM_ALGORITHM; - - const forwardedCount = (sessionData.forwarding_curve25519_key_chain || []).length; - - const userId = this.baseApis.crypto!.deviceList.getUserByIdentityKey(MEGOLM_ALGORITHM, session.senderKey); - const device = - this.baseApis.crypto!.deviceList.getDeviceByIdentityKey(MEGOLM_ALGORITHM, session.senderKey) ?? - undefined; - const verified = this.baseApis.crypto!.checkDeviceInfoTrust(userId!, device).isVerified(); - - 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(CryptoEvent.KeyBackupSessionsRemaining, remaining); - - return sessions.length; - } - - public async backupGroupSession(senderKey: string, sessionId: string): Promise<void> { - 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. - */ - public async scheduleAllGroupSessionsForBackup(): Promise<void> { - 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). - */ - public async flagAllGroupSessionsForBackup(): Promise<number> { - await this.baseApis.crypto!.cryptoStore.doTxn( - "readwrite", - [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, 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(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 - */ - public countSessionsNeedingBackup(): Promise<number> { - return this.baseApis.crypto!.cryptoStore.countSessionsNeedingBackup(); - } -} - -export class Curve25519 implements BackupAlgorithm { - public static algorithmName = "m.megolm_backup.v1.curve25519-aes-sha2"; - - public constructor( - public authData: ICurve25519AuthData, - private publicKey: any, // FIXME: PkEncryption - private getKey: () => Promise<Uint8Array>, - ) {} - - public static async init(authData: AuthData, getKey: () => Promise<Uint8Array>): Promise<Curve25519> { - 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 as ICurve25519AuthData, publicKey, getKey); - } - - public static async prepare(key?: string | Uint8Array | null): Promise<[Uint8Array, AuthData]> { - const decryption = new global.Olm.PkDecryption(); - try { - const authData: Partial<ICurve25519AuthData> = {}; - 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 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 as AuthData]; - } finally { - decryption.free(); - } - } - - public static checkBackupVersion(info: IKeyBackupInfo): void { - if (!("public_key" in info.auth_data)) { - throw new Error("Invalid backup data returned"); - } - } - - public get untrusted(): boolean { - return true; - } - - public async encryptSession(data: Record<string, any>): Promise<Curve25519SessionData> { - const plainText: Record<string, any> = Object.assign({}, data); - delete plainText.session_id; - delete plainText.room_id; - delete plainText.first_known_index; - return this.publicKey.encrypt(JSON.stringify(plainText)); - } - - public async decryptSessions( - sessions: Record<string, IKeyBackupSession<Curve25519SessionData>>, - ): Promise<IMegolmSessionData[]> { - 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 MatrixError({ errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY }); - } - - const keys: IMegolmSessionData[] = []; - - 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.log("Failed to decrypt megolm session from backup", e, sessionData); - } - } - return keys; - } finally { - decryption.free(); - } - } - - public async keyMatches(key: Uint8Array): Promise<boolean> { - const decryption = new global.Olm.PkDecryption(); - let pubKey: string; - try { - pubKey = decryption.init_with_private_key(key); - } finally { - decryption.free(); - } - - return pubKey === this.authData.public_key; - } - - public free(): void { - this.publicKey.free(); - } -} - -function randomBytes(size: number): Uint8Array { - const buf = new Uint8Array(size); - crypto.getRandomValues(buf); - return buf; -} - -const UNSTABLE_MSC3270_NAME = new UnstableValue( - "m.megolm_backup.v1.aes-hmac-sha2", - "org.matrix.msc3270.v1.aes-hmac-sha2", -); - -export class Aes256 implements BackupAlgorithm { - public static algorithmName = UNSTABLE_MSC3270_NAME.name; - - public constructor(public readonly authData: IAes256AuthData, private readonly key: Uint8Array) {} - - public static async init(authData: IAes256AuthData, getKey: () => Promise<Uint8Array>): Promise<Aes256> { - if (!authData) { - throw new Error("auth_data missing"); - } - const key = await getKey(); - if (authData.mac) { - const { mac } = await calculateKeyCheck(key, authData.iv); - if (authData.mac.replace(/=+$/g, "") !== mac.replace(/=+/g, "")) { - throw new Error("Key does not match"); - } - } - return new Aes256(authData, key); - } - - public static async prepare(key?: string | Uint8Array | null): Promise<[Uint8Array, AuthData]> { - let outKey: Uint8Array; - const authData: Partial<IAes256AuthData> = {}; - if (!key) { - outKey = randomBytes(32); - } else if (key instanceof Uint8Array) { - outKey = new Uint8Array(key); - } else { - const derivation = await keyFromPassphrase(key); - authData.private_key_salt = derivation.salt; - authData.private_key_iterations = derivation.iterations; - outKey = derivation.key; - } - - const { iv, mac } = await calculateKeyCheck(outKey); - authData.iv = iv; - authData.mac = mac; - - return [outKey, authData as AuthData]; - } - - public static checkBackupVersion(info: IKeyBackupInfo): void { - if (!("iv" in info.auth_data && "mac" in info.auth_data)) { - throw new Error("Invalid backup data returned"); - } - } - - public get untrusted(): boolean { - return false; - } - - public encryptSession(data: Record<string, any>): Promise<IEncryptedPayload> { - const plainText: Record<string, any> = Object.assign({}, data); - delete plainText.session_id; - delete plainText.room_id; - delete plainText.first_known_index; - return encryptAES(JSON.stringify(plainText), this.key, data.session_id); - } - - public async decryptSessions( - sessions: Record<string, IKeyBackupSession<IEncryptedPayload>>, - ): Promise<IMegolmSessionData[]> { - const keys: IMegolmSessionData[] = []; - - for (const [sessionId, sessionData] of Object.entries(sessions)) { - try { - const decrypted = JSON.parse(await decryptAES(sessionData.session_data, this.key, sessionId)); - decrypted.session_id = sessionId; - keys.push(decrypted); - } catch (e) { - logger.log("Failed to decrypt megolm session from backup", e, sessionData); - } - } - return keys; - } - - public async keyMatches(key: Uint8Array): Promise<boolean> { - if (this.authData.mac) { - const { mac } = await 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; - } - } - - public free(): void { - this.key.fill(0); - } -} - -export const algorithmsByName: Record<string, BackupAlgorithmClass> = { - [Curve25519.algorithmName]: Curve25519, - [Aes256.algorithmName]: Aes256, -}; - -export const DefaultAlgorithm: BackupAlgorithmClass = Curve25519; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/crypto.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/crypto.ts deleted file mode 100644 index 704754f..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/crypto.ts +++ /dev/null @@ -1,50 +0,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. -*/ - -import { logger } from "../logger"; - -export let crypto = global.window?.crypto; -export let subtleCrypto = global.window?.crypto?.subtle ?? global.window?.crypto?.webkitSubtle; -export let TextEncoder = global.window?.TextEncoder; - -/* eslint-disable @typescript-eslint/no-var-requires */ -if (!crypto) { - try { - crypto = require("crypto").webcrypto; - } catch (e) { - logger.error("Failed to load webcrypto", e); - } -} -if (!subtleCrypto) { - subtleCrypto = crypto?.subtle; -} -if (!TextEncoder) { - try { - TextEncoder = require("util").TextEncoder; - } catch (e) { - logger.error("Failed to load TextEncoder util", e); - } -} -/* eslint-enable @typescript-eslint/no-var-requires */ - -export function setCrypto(_crypto: Crypto): void { - crypto = _crypto; - subtleCrypto = _crypto.subtle ?? _crypto.webkitSubtle; -} - -export function setTextEncoder(_TextEncoder: typeof TextEncoder): void { - TextEncoder = _TextEncoder; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/dehydration.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/dehydration.ts deleted file mode 100644 index 373b236..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/dehydration.ts +++ /dev/null @@ -1,271 +0,0 @@ -/* -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. -*/ - -import anotherjson from "another-json"; - -import type { IDeviceKeys, IOneTimeKey } from "../@types/crypto"; -import { decodeBase64, encodeBase64 } from "./olmlib"; -import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store"; -import { decryptAES, encryptAES } from "./aes"; -import { logger } from "../logger"; -import { Crypto } from "./index"; -import { Method } from "../http-api"; -import { SecretStorageKeyDescription } from "../secret-storage"; - -export interface IDehydratedDevice { - device_id: string; // eslint-disable-line camelcase - device_data: SecretStorageKeyDescription & { - // eslint-disable-line camelcase - algorithm: string; - account: string; // pickle - }; -} - -export interface IDehydratedDeviceKeyInfo { - passphrase?: string; -} - -export const DEHYDRATION_ALGORITHM = "org.matrix.msc2697.v1.olm.libolm_pickle"; - -const oneweek = 7 * 24 * 60 * 60 * 1000; - -export class DehydrationManager { - private inProgress = false; - private timeoutId: any; - private key?: Uint8Array; - private keyInfo?: { [props: string]: any }; - private deviceDisplayName?: string; - - public constructor(private readonly crypto: Crypto) { - this.getDehydrationKeyFromCache(); - } - - public getDehydrationKeyFromCache(): Promise<void> { - return this.crypto.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.crypto.cryptoStore.getSecretStorePrivateKey( - txn, - async (result) => { - if (result) { - const { key, keyInfo, deviceDisplayName, time } = result; - const pickleKey = Buffer.from(this.crypto.olmDevice.pickleKey); - const decrypted = await decryptAES(key, pickleKey, DEHYDRATION_ALGORITHM); - this.key = decodeBase64(decrypted); - this.keyInfo = keyInfo; - this.deviceDisplayName = deviceDisplayName; - const now = Date.now(); - const delay = Math.max(1, time + oneweek - now); - this.timeoutId = global.setTimeout(this.dehydrateDevice.bind(this), delay); - } - }, - "dehydration", - ); - }); - } - - /** set the key, and queue periodic dehydration to the server in the background */ - public async setKeyAndQueueDehydration( - key: Uint8Array, - keyInfo: { [props: string]: any } = {}, - deviceDisplayName?: string, - ): Promise<void> { - const matches = await this.setKey(key, keyInfo, deviceDisplayName); - if (!matches) { - // start dehydration in the background - this.dehydrateDevice(); - } - } - - public async setKey( - key: Uint8Array, - keyInfo: { [props: string]: any } = {}, - deviceDisplayName?: string, - ): Promise<boolean | undefined> { - if (!key) { - // unsetting the key -- cancel any pending dehydration task - if (this.timeoutId) { - global.clearTimeout(this.timeoutId); - this.timeoutId = undefined; - } - // clear storage - await this.crypto.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.crypto.cryptoStore.storeSecretStorePrivateKey(txn, "dehydration", null); - }); - this.key = undefined; - this.keyInfo = undefined; - return; - } - - // Check to see if it's the same key as before. If it's different, - // dehydrate a new device. If it's the same, we can keep the same - // device. (Assume that keyInfo and deviceDisplayName will be the - // same if the key is the same.) - let matches: boolean = !!this.key && key.length == this.key.length; - for (let i = 0; matches && i < key.length; i++) { - if (key[i] != this.key![i]) { - matches = false; - } - } - if (!matches) { - this.key = key; - this.keyInfo = keyInfo; - this.deviceDisplayName = deviceDisplayName; - } - return matches; - } - - /** returns the device id of the newly created dehydrated device */ - public async dehydrateDevice(): Promise<string | undefined> { - if (this.inProgress) { - logger.log("Dehydration already in progress -- not starting new dehydration"); - return; - } - this.inProgress = true; - if (this.timeoutId) { - global.clearTimeout(this.timeoutId); - this.timeoutId = undefined; - } - try { - const pickleKey = Buffer.from(this.crypto.olmDevice.pickleKey); - - // update the crypto store with the timestamp - const key = await encryptAES(encodeBase64(this.key!), pickleKey, DEHYDRATION_ALGORITHM); - await this.crypto.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.crypto.cryptoStore.storeSecretStorePrivateKey(txn, "dehydration", { - keyInfo: this.keyInfo, - key, - deviceDisplayName: this.deviceDisplayName!, - time: Date.now(), - }); - }); - logger.log("Attempting to dehydrate device"); - - logger.log("Creating account"); - // create the account and all the necessary keys - const account = new global.Olm.Account(); - account.create(); - const e2eKeys = JSON.parse(account.identity_keys()); - - const maxKeys = account.max_number_of_one_time_keys(); - // FIXME: generate in small batches? - account.generate_one_time_keys(maxKeys / 2); - account.generate_fallback_key(); - const otks: Record<string, string> = JSON.parse(account.one_time_keys()); - const fallbacks: Record<string, string> = JSON.parse(account.fallback_key()); - account.mark_keys_as_published(); - - // dehydrate the account and store it on the server - const pickledAccount = account.pickle(new Uint8Array(this.key!)); - - const deviceData: { [props: string]: any } = { - algorithm: DEHYDRATION_ALGORITHM, - account: pickledAccount, - }; - if (this.keyInfo!.passphrase) { - deviceData.passphrase = this.keyInfo!.passphrase; - } - - logger.log("Uploading account to server"); - // eslint-disable-next-line camelcase - const dehydrateResult = await this.crypto.baseApis.http.authedRequest<{ device_id: string }>( - 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.log("Preparing device keys", deviceId); - const deviceKeys: IDeviceKeys = { - algorithms: this.crypto.supportedAlgorithms, - device_id: deviceId, - user_id: this.crypto.userId, - keys: { - [`ed25519:${deviceId}`]: e2eKeys.ed25519, - [`curve25519:${deviceId}`]: e2eKeys.curve25519, - }, - }; - const deviceSignature = account.sign(anotherjson.stringify(deviceKeys)); - deviceKeys.signatures = { - [this.crypto.userId]: { - [`ed25519:${deviceId}`]: deviceSignature, - }, - }; - if (this.crypto.crossSigningInfo.getId("self_signing")) { - await this.crypto.crossSigningInfo.signObject(deviceKeys, "self_signing"); - } - - logger.log("Preparing one-time keys"); - const oneTimeKeys: Record<string, IOneTimeKey> = {}; - for (const [keyId, key] of Object.entries(otks.curve25519)) { - const k: IOneTimeKey = { key }; - const signature = account.sign(anotherjson.stringify(k)); - k.signatures = { - [this.crypto.userId]: { - [`ed25519:${deviceId}`]: signature, - }, - }; - oneTimeKeys[`signed_curve25519:${keyId}`] = k; - } - - logger.log("Preparing fallback keys"); - const fallbackKeys: Record<string, IOneTimeKey> = {}; - for (const [keyId, key] of Object.entries(fallbacks.curve25519)) { - const k: IOneTimeKey = { key, fallback: true }; - const signature = account.sign(anotherjson.stringify(k)); - k.signatures = { - [this.crypto.userId]: { - [`ed25519:${deviceId}`]: signature, - }, - }; - fallbackKeys[`signed_curve25519:${keyId}`] = k; - } - - logger.log("Uploading keys to server"); - await this.crypto.baseApis.http.authedRequest( - Method.Post, - "/keys/upload/" + encodeURI(deviceId), - undefined, - { - "device_keys": deviceKeys, - "one_time_keys": oneTimeKeys, - "org.matrix.msc2732.fallback_keys": fallbackKeys, - }, - ); - logger.log("Done dehydrating"); - - // dehydrate again in a week - this.timeoutId = global.setTimeout(this.dehydrateDevice.bind(this), oneweek); - - return deviceId; - } finally { - this.inProgress = false; - } - } - - public stop(): void { - if (this.timeoutId) { - global.clearTimeout(this.timeoutId); - this.timeoutId = undefined; - } - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/deviceinfo.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/deviceinfo.ts deleted file mode 100644 index b4bb4fd..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/deviceinfo.ts +++ /dev/null @@ -1,161 +0,0 @@ -/* -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. -*/ - -import { ISignatures } from "../@types/signed"; - -export interface IDevice { - keys: Record<string, string>; - algorithms: string[]; - verified: DeviceVerification; - known: boolean; - unsigned?: Record<string, any>; - signatures?: ISignatures; -} - -enum DeviceVerification { - Blocked = -1, - Unverified = 0, - Verified = 1, -} - -/** - * Information about a user's device - */ -export 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 - */ - public static fromStorage(obj: Partial<IDevice>, deviceId: string): DeviceInfo { - 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 as keyof IDevice] = obj[prop as keyof IDevice]; - } - } - return res; - } - - public static DeviceVerification = { - VERIFIED: DeviceVerification.Verified, - UNVERIFIED: DeviceVerification.Unverified, - BLOCKED: DeviceVerification.Blocked, - }; - - /** list of algorithms supported by this device */ - public algorithms: string[] = []; - /** a map from `<key type>:<id> -> <base64-encoded key>` */ - public keys: Record<string, string> = {}; - /** whether the device has been verified/blocked by the user */ - public verified = DeviceVerification.Unverified; - /** - * whether the user knows of this device's existence - * (useful when warning the user that a user has added new devices) - */ - public known = false; - /** additional data from the homeserver */ - public unsigned: Record<string, any> = {}; - public signatures: ISignatures = {}; - - /** - * @param deviceId - id of the device - */ - public constructor(public readonly deviceId: string) {} - - /** - * Prepare a DeviceInfo for JSON serialisation in the session store - * - * @returns deviceinfo with non-serialised members removed - */ - public toStorage(): IDevice { - 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 - */ - public getFingerprint(): string { - 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 - */ - public getIdentityKey(): string { - return this.keys["curve25519:" + this.deviceId]; - } - - /** - * Get the configured display name for this device, if any - * - * @returns displayname - */ - public getDisplayName(): string | null { - return this.unsigned.device_display_name || null; - } - - /** - * Returns true if this device is blocked - * - * @returns true if blocked - */ - public isBlocked(): boolean { - return this.verified == DeviceVerification.Blocked; - } - - /** - * Returns true if this device is verified - * - * @returns true if verified - */ - public isVerified(): boolean { - return this.verified == DeviceVerification.Verified; - } - - /** - * Returns true if this device is unverified - * - * @returns true if unverified - */ - public isUnverified(): boolean { - return this.verified == DeviceVerification.Unverified; - } - - /** - * Returns true if the user knows about this device's existence - * - * @returns true if known - */ - public isKnown(): boolean { - return this.known === true; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/index.ts deleted file mode 100644 index 68df6ca..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/index.ts +++ /dev/null @@ -1,3936 +0,0 @@ -/* -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. -*/ - -import anotherjson from "another-json"; -import { v4 as uuidv4 } from "uuid"; - -import type { IDeviceKeys, IEventDecryptionResult, IMegolmSessionData, IOneTimeKey } from "../@types/crypto"; -import type { PkDecryption, PkSigning } from "@matrix-org/olm"; -import { EventType, ToDeviceMessageId } from "../@types/event"; -import { TypedReEmitter } from "../ReEmitter"; -import { logger } from "../logger"; -import { IExportedDevice, OlmDevice } from "./OlmDevice"; -import { IOlmDevice } from "./algorithms/megolm"; -import * as olmlib from "./olmlib"; -import { DeviceInfoMap, DeviceList } from "./DeviceList"; -import { DeviceInfo, IDevice } from "./deviceinfo"; -import type { DecryptionAlgorithm, EncryptionAlgorithm } from "./algorithms"; -import * as algorithms from "./algorithms"; -import { createCryptoStoreCacheCallbacks, CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from "./CrossSigning"; -import { EncryptionSetupBuilder } from "./EncryptionSetup"; -import { - IAccountDataClient, - ISecretRequest, - SECRET_STORAGE_ALGORITHM_V1_AES, - SecretStorage, - SecretStorageKeyObject, - SecretStorageKeyTuple, -} from "./SecretStorage"; -import { - IAddSecretStorageKeyOpts, - ICreateSecretStorageOpts, - IEncryptedEventInfo, - IImportRoomKeysOpts, - IRecoveryKey, -} from "./api"; -import { OutgoingRoomKeyRequestManager } from "./OutgoingRoomKeyRequestManager"; -import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store"; -import { VerificationBase } from "./verification/Base"; -import { ReciprocateQRCode, SCAN_QR_CODE_METHOD, SHOW_QR_CODE_METHOD } from "./verification/QRCode"; -import { SAS as SASVerification } from "./verification/SAS"; -import { keyFromPassphrase } from "./key_passphrase"; -import { decodeRecoveryKey, encodeRecoveryKey } from "./recoverykey"; -import { VerificationRequest } from "./verification/request/VerificationRequest"; -import { InRoomChannel, InRoomRequests } from "./verification/request/InRoomChannel"; -import { ToDeviceChannel, ToDeviceRequests, Request } from "./verification/request/ToDeviceChannel"; -import { IllegalMethod } from "./verification/IllegalMethod"; -import { KeySignatureUploadError } from "../errors"; -import { calculateKeyCheck, decryptAES, encryptAES } from "./aes"; -import { DehydrationManager } from "./dehydration"; -import { BackupManager } from "./backup"; -import { IStore } from "../store"; -import { Room, RoomEvent } from "../models/room"; -import { RoomMember, RoomMemberEvent } from "../models/room-member"; -import { EventStatus, IEvent, MatrixEvent, MatrixEventEvent } from "../models/event"; -import { ToDeviceBatch } from "../models/ToDeviceMessage"; -import { - ClientEvent, - ICrossSigningKey, - IKeysUploadResponse, - ISignedKey, - IUploadKeySignaturesResponse, - MatrixClient, -} from "../client"; -import type { IRoomEncryption, RoomList } from "./RoomList"; -import { IKeyBackupInfo } from "./keybackup"; -import { ISyncStateData } from "../sync"; -import { CryptoStore } from "./store/base"; -import { IVerificationChannel } from "./verification/request/Channel"; -import { TypedEventEmitter } from "../models/typed-event-emitter"; -import { IContent } from "../models/event"; -import { ISyncResponse, IToDeviceEvent } from "../sync-accumulator"; -import { ISignatures } from "../@types/signed"; -import { IMessage } from "./algorithms/olm"; -import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend"; -import { RoomState, RoomStateEvent } from "../models/room-state"; -import { MapWithDefault, recursiveMapToObject } from "../utils"; -import { SecretStorageKeyDescription } from "../secret-storage"; - -const DeviceVerification = DeviceInfo.DeviceVerification; - -const defaultVerificationMethods = { - [ReciprocateQRCode.NAME]: ReciprocateQRCode, - [SASVerification.NAME]: SASVerification, - - // These two can't be used for actual verification, but we do - // need to be able to define them here for the verification flows - // to start. - [SHOW_QR_CODE_METHOD]: IllegalMethod, - [SCAN_QR_CODE_METHOD]: IllegalMethod, -} as const; - -/** - * verification method names - */ -// legacy export identifier -export const verificationMethods = { - RECIPROCATE_QR_CODE: ReciprocateQRCode.NAME, - SAS: SASVerification.NAME, -} as const; - -export type VerificationMethod = keyof typeof verificationMethods | string; - -export function isCryptoAvailable(): boolean { - return Boolean(global.Olm); -} - -const MIN_FORCE_SESSION_INTERVAL_MS = 60 * 60 * 1000; - -interface IInitOpts { - exportedOlmDevice?: IExportedDevice; - pickleKey?: string; -} - -export interface IBootstrapCrossSigningOpts { - /** Optional. Reset even if keys already exist. */ - setupNewCrossSigning?: boolean; - /** - * 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. - */ - authUploadDeviceSigningKeys?(makeRequest: (authData: any) => Promise<{}>): Promise<void>; -} - -export interface ICryptoCallbacks { - getCrossSigningKey?: (keyType: string, pubKey: string) => Promise<Uint8Array | null>; - saveCrossSigningKeys?: (keys: Record<string, Uint8Array>) => void; - shouldUpgradeDeviceVerifications?: (users: Record<string, any>) => Promise<string[]>; - getSecretStorageKey?: ( - keys: { keys: Record<string, SecretStorageKeyDescription> }, - name: string, - ) => Promise<[string, Uint8Array] | null>; - cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void; - onSecretRequested?: ( - userId: string, - deviceId: string, - requestId: string, - secretName: string, - deviceTrust: DeviceTrustLevel, - ) => Promise<string | undefined>; - getDehydrationKey?: ( - keyInfo: SecretStorageKeyDescription, - checkFunc: (key: Uint8Array) => void, - ) => Promise<Uint8Array>; - getBackupKey?: () => Promise<Uint8Array>; -} - -/* eslint-disable camelcase */ -interface IRoomKey { - room_id: string; - algorithm: string; -} - -/** - * 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. - */ -export interface IRoomKeyRequestBody extends IRoomKey { - session_id: string; - sender_key: string; -} - -/* eslint-enable camelcase */ - -interface IDeviceVerificationUpgrade { - devices: DeviceInfo[]; - crossSigningInfo: CrossSigningInfo; -} - -export interface ICheckOwnCrossSigningTrustOpts { - allowPrivateKeyRequests?: boolean; -} - -interface IUserOlmSession { - deviceIdKey: string; - sessions: { - sessionId: string; - hasReceivedMessage: boolean; - }[]; -} - -export interface IRoomKeyRequestRecipient { - userId: string; - deviceId: string; -} - -interface ISignableObject { - signatures?: ISignatures; - unsigned?: object; -} - -export interface IRequestsMap { - getRequest(event: MatrixEvent): VerificationRequest | undefined; - getRequestByChannel(channel: IVerificationChannel): VerificationRequest | undefined; - setRequest(event: MatrixEvent, request: VerificationRequest): void; - setRequestByChannel(channel: IVerificationChannel, request: VerificationRequest): void; -} - -/* eslint-disable camelcase */ -export interface IOlmEncryptedContent { - algorithm: typeof olmlib.OLM_ALGORITHM; - sender_key: string; - ciphertext: Record<string, IMessage>; - [ToDeviceMessageId]?: string; -} - -export interface IMegolmEncryptedContent { - algorithm: typeof olmlib.MEGOLM_ALGORITHM; - sender_key: string; - session_id: string; - device_id: string; - ciphertext: string; - [ToDeviceMessageId]?: string; -} -/* eslint-enable camelcase */ - -export type IEncryptedContent = IOlmEncryptedContent | IMegolmEncryptedContent; - -export enum CryptoEvent { - DeviceVerificationChanged = "deviceVerificationChanged", - UserTrustStatusChanged = "userTrustStatusChanged", - UserCrossSigningUpdated = "userCrossSigningUpdated", - RoomKeyRequest = "crypto.roomKeyRequest", - RoomKeyRequestCancellation = "crypto.roomKeyRequestCancellation", - KeyBackupStatus = "crypto.keyBackupStatus", - KeyBackupFailed = "crypto.keyBackupFailed", - KeyBackupSessionsRemaining = "crypto.keyBackupSessionsRemaining", - KeySignatureUploadFailure = "crypto.keySignatureUploadFailure", - VerificationRequest = "crypto.verification.request", - Warning = "crypto.warning", - WillUpdateDevices = "crypto.willUpdateDevices", - DevicesUpdated = "crypto.devicesUpdated", - KeysChanged = "crossSigning.keysChanged", -} - -export type CryptoEventHandlerMap = { - /** - * Fires when a device is marked as verified/unverified/blocked/unblocked by - * {@link MatrixClient#setDeviceVerified|MatrixClient.setDeviceVerified} or - * {@link MatrixClient#setDeviceBlocked|MatrixClient.setDeviceBlocked}. - * - * @param userId - the owner of the verified device - * @param deviceId - the id of the verified device - * @param deviceInfo - updated device information - */ - [CryptoEvent.DeviceVerificationChanged]: (userId: string, deviceId: string, device: DeviceInfo) => void; - /** - * Fires when the trust status of a user changes - * If userId is the userId of the logged-in user, this indicated a change - * in the trust status of the cross-signing data on the account. - * - * The cross-signing API is currently UNSTABLE and may change without notice. - * @experimental - * - * @param userId - the userId of the user in question - * @param trustLevel - The new trust level of the user - */ - [CryptoEvent.UserTrustStatusChanged]: (userId: string, trustLevel: UserTrustLevel) => void; - /** - * Fires when we receive a room key request - * - * @param req - request details - */ - [CryptoEvent.RoomKeyRequest]: (request: IncomingRoomKeyRequest) => void; - /** - * Fires when we receive a room key request cancellation - */ - [CryptoEvent.RoomKeyRequestCancellation]: (request: IncomingRoomKeyRequestCancellation) => void; - /** - * Fires whenever the status of e2e key backup changes, as returned by getKeyBackupEnabled() - * @param enabled - true if key backup has been enabled, otherwise false - * @example - * ``` - * matrixClient.on("crypto.keyBackupStatus", function(enabled){ - * if (enabled) { - * [...] - * } - * }); - * ``` - */ - [CryptoEvent.KeyBackupStatus]: (enabled: boolean) => void; - [CryptoEvent.KeyBackupFailed]: (errcode: string) => void; - [CryptoEvent.KeyBackupSessionsRemaining]: (remaining: number) => void; - [CryptoEvent.KeySignatureUploadFailure]: ( - failures: IUploadKeySignaturesResponse["failures"], - source: "checkOwnCrossSigningTrust" | "afterCrossSigningLocalKeyChange" | "setDeviceVerification", - upload: (opts: { shouldEmit: boolean }) => Promise<void>, - ) => void; - /** - * Fires when a key verification is requested. - */ - [CryptoEvent.VerificationRequest]: (request: VerificationRequest<any>) => void; - /** - * Fires when the app may wish to warn the user about something related - * the end-to-end crypto. - * - * @param type - One of the strings listed above - */ - [CryptoEvent.Warning]: (type: string) => void; - /** - * Fires when the user's cross-signing keys have changed or cross-signing - * has been enabled/disabled. The client can use getStoredCrossSigningForUser - * with the user ID of the logged in user to check if cross-signing is - * enabled on the account. If enabled, it can test whether the current key - * is trusted using with checkUserTrust with the user ID of the logged - * in user. The checkOwnCrossSigningTrust function may be used to reconcile - * the trust in the account key. - * - * The cross-signing API is currently UNSTABLE and may change without notice. - * @experimental - */ - [CryptoEvent.KeysChanged]: (data: {}) => void; - /** - * Fires whenever the stored devices for a user will be updated - * @param users - A list of user IDs that will be updated - * @param initialFetch - If true, the store is empty (apart - * from our own device) and is being seeded. - */ - [CryptoEvent.WillUpdateDevices]: (users: string[], initialFetch: boolean) => void; - /** - * Fires whenever the stored devices for a user have changed - * @param users - A list of user IDs that were updated - * @param initialFetch - If true, the store was empty (apart - * from our own device) and has been seeded. - */ - [CryptoEvent.DevicesUpdated]: (users: string[], initialFetch: boolean) => void; - [CryptoEvent.UserCrossSigningUpdated]: (userId: string) => void; -}; - -export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap> implements CryptoBackend { - /** - * @returns The version of Olm. - */ - public static getOlmVersion(): [number, number, number] { - return OlmDevice.getOlmVersion(); - } - - public readonly backupManager: BackupManager; - public readonly crossSigningInfo: CrossSigningInfo; - public readonly olmDevice: OlmDevice; - public readonly deviceList: DeviceList; - public readonly dehydrationManager: DehydrationManager; - public readonly secretStorage: SecretStorage; - - private readonly reEmitter: TypedReEmitter<CryptoEvent, CryptoEventHandlerMap>; - private readonly verificationMethods: Map<VerificationMethod, typeof VerificationBase>; - public readonly supportedAlgorithms: string[]; - private readonly outgoingRoomKeyRequestManager: OutgoingRoomKeyRequestManager; - private readonly toDeviceVerificationRequests: ToDeviceRequests; - public readonly inRoomVerificationRequests: InRoomRequests; - - private trustCrossSignedDevices = true; - // the last time we did a check for the number of one-time-keys on the server. - private lastOneTimeKeyCheck: number | null = null; - private oneTimeKeyCheckInProgress = false; - - // EncryptionAlgorithm instance for each room - private roomEncryptors = new Map<string, EncryptionAlgorithm>(); - // map from algorithm to DecryptionAlgorithm instance, for each room - private roomDecryptors = new Map<string, Map<string, DecryptionAlgorithm>>(); - - private deviceKeys: Record<string, string> = {}; // type: key - - public globalBlacklistUnverifiedDevices = false; - public globalErrorOnUnknownDevices = true; - - // list of IncomingRoomKeyRequests/IncomingRoomKeyRequestCancellations - // we received in the current sync. - private receivedRoomKeyRequests: IncomingRoomKeyRequest[] = []; - private receivedRoomKeyRequestCancellations: IncomingRoomKeyRequestCancellation[] = []; - // true if we are currently processing received room key requests - private processingRoomKeyRequests = false; - // controls whether device tracking is delayed - // until calling encryptEvent or trackRoomDevices, - // or done immediately upon enabling room encryption. - private 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. - private roomDeviceTrackingState: { [roomId: string]: Promise<void> } = {}; - - // 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 - private lastNewSessionForced: MapWithDefault<string, MapWithDefault<string, number>> = new MapWithDefault( - () => new 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. - private sendKeyRequestsImmediately = false; - - private oneTimeKeyCount?: number; - private needsNewFallback?: boolean; - private fallbackCleanup?: ReturnType<typeof setTimeout>; - - /** - * 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. - */ - public constructor( - public readonly baseApis: MatrixClient, - public readonly userId: string, - private readonly deviceId: string, - private readonly clientStore: IStore, - public readonly cryptoStore: CryptoStore, - private readonly roomList: RoomList, - verificationMethods: Array<VerificationMethod | (typeof VerificationBase & { NAME: string })>, - ) { - super(); - this.reEmitter = new TypedReEmitter(this); - - if (verificationMethods) { - this.verificationMethods = new Map(); - for (const method of verificationMethods) { - if (typeof method === "string") { - if (defaultVerificationMethods[method]) { - this.verificationMethods.set( - method, - <typeof VerificationBase>defaultVerificationMethods[method], - ); - } - } else if (method["NAME"]) { - this.verificationMethods.set(method["NAME"], method as typeof VerificationBase); - } else { - logger.warn(`Excluding unknown verification method ${method}`); - } - } - } else { - this.verificationMethods = new Map(Object.entries(defaultVerificationMethods)) as Map< - VerificationMethod, - typeof VerificationBase - >; - } - - this.backupManager = new 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.getSecret("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.getSecretStorageKey(); - await this.storeSecret("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(cryptoStore); - this.deviceList = new DeviceList(baseApis, cryptoStore, this.olmDevice); - - // XXX: This isn't removed at any point, but then none of the event listeners - // this class sets seem to be removed at any point... :/ - this.deviceList.on(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( - baseApis, - this.deviceId, - this.cryptoStore, - ); - - this.toDeviceVerificationRequests = new ToDeviceRequests(); - this.inRoomVerificationRequests = new InRoomRequests(); - - const cryptoCallbacks = this.baseApis.cryptoCallbacks || {}; - const cacheCallbacks = createCryptoStoreCacheCallbacks(cryptoStore, this.olmDevice); - - this.crossSigningInfo = new CrossSigningInfo(userId, cryptoCallbacks, cacheCallbacks); - // Yes, we pass the client twice here: see SecretStorage - this.secretStorage = new SecretStorage(baseApis as IAccountDataClient, cryptoCallbacks, baseApis); - this.dehydrationManager = new DehydrationManager(this); - - // Assuming no app-supplied callback, default to getting from SSSS. - if (!cryptoCallbacks.getCrossSigningKey && cryptoCallbacks.getSecretStorageKey) { - cryptoCallbacks.getCrossSigningKey = async (type): Promise<Uint8Array | null> => { - return 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. - */ - public async init({ exportedOlmDevice, pickleKey }: IInitOpts = {}): Promise<void> { - logger.log("Crypto: initialising Olm..."); - await global.Olm.init(); - logger.log( - exportedOlmDevice - ? "Crypto: initialising Olm device from exported device..." - : "Crypto: initialising Olm device...", - ); - await this.olmDevice.init({ fromExportedDevice: exportedOlmDevice, pickleKey }); - logger.log("Crypto: loading device list..."); - await this.deviceList.load(); - - // build our device keys: these will later be uploaded - this.deviceKeys["ed25519:" + this.deviceId] = this.olmDevice.deviceEd25519Key!; - this.deviceKeys["curve25519:" + this.deviceId] = this.olmDevice.deviceCurve25519Key!; - - logger.log("Crypto: fetching own devices..."); - let myDevices = this.deviceList.getRawStoredDevicesForUser(this.userId); - - if (!myDevices) { - myDevices = {}; - } - - if (!myDevices[this.deviceId]) { - // add our own deviceinfo to the cryptoStore - logger.log("Crypto: adding this device to the store..."); - const deviceInfo = { - keys: this.deviceKeys, - algorithms: this.supportedAlgorithms, - verified: DeviceVerification.VERIFIED, - known: true, - }; - - myDevices[this.deviceId] = deviceInfo; - this.deviceList.storeDevicesForUser(this.userId, myDevices); - this.deviceList.saveIfDirty(); - } - - await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.cryptoStore.getCrossSigningKeys(txn, (keys) => { - // can be an empty object after resetting cross-signing keys, see storeTrustedSelfKeys - if (keys && Object.keys(keys).length !== 0) { - logger.log("Loaded cross-signing public keys from crypto store"); - this.crossSigningInfo.setKeys(keys); - } - }); - }); - // make sure we are keeping track of our own devices - // (this is important for key backups & things) - this.deviceList.startTrackingDeviceList(this.userId); - - logger.log("Crypto: checking for key backup..."); - this.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 - */ - public getCryptoTrustCrossSignedDevices(): boolean { - return this.trustCrossSignedDevices; - } - - /** - * See getCryptoTrustCrossSignedDevices - - * This may be set before initCrypto() is called to ensure no races occur. - * - * @param val - True to trust cross-signed devices - */ - public setCryptoTrustCrossSignedDevices(val: boolean): void { - 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); - } - } - } - } - - /** - * 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. - */ - public async createRecoveryKeyFromPassphrase(password?: string): Promise<IRecoveryKey> { - const decryption = new global.Olm.PkDecryption(); - try { - const keyInfo: Partial<IRecoveryKey["keyInfo"]> = {}; - if (password) { - const derivation = await keyFromPassphrase(password); - keyInfo.passphrase = { - algorithm: "m.pbkdf2", - iterations: derivation.iterations, - salt: derivation.salt, - }; - keyInfo.pubkey = decryption.init_with_private_key(derivation.key); - } else { - keyInfo.pubkey = decryption.generate_key(); - } - const privateKey = decryption.get_private_key(); - const encodedPrivateKey = encodeRecoveryKey(privateKey); - return { - keyInfo: keyInfo as IRecoveryKey["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 - */ - public async userHasCrossSigningKeys(): Promise<boolean> { - 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 - */ - public async isCrossSigningReady(): Promise<boolean> { - 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 - */ - public async isSecretStorageReady(): Promise<boolean> { - 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. - */ - public async bootstrapCrossSigning({ - authUploadDeviceSigningKeys, - setupNewCrossSigning, - }: IBootstrapCrossSigningOpts = {}): Promise<void> { - logger.log("Bootstrapping cross-signing"); - - const delegateCryptoCallbacks = this.baseApis.cryptoCallbacks; - const builder = new EncryptionSetupBuilder(this.baseApis.store.accountData, delegateCryptoCallbacks); - const crossSigningInfo = new CrossSigningInfo( - this.userId, - builder.crossSigningCallbacks, - builder.crossSigningCallbacks, - ); - - // Reset the cross-signing keys - const resetCrossSigning = async (): Promise<void> => { - 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.log({ - setupNewCrossSigning, - publicKeysOnDevice, - privateKeysInCache, - privateKeysInStorage, - privateKeysExistSomewhere, - }); - - if (!privateKeysExistSomewhere || setupNewCrossSigning) { - logger.log("Cross-signing private keys not found locally or in secret storage, " + "creating new keys"); - // If a user has multiple devices, it important to only call bootstrap - // as part of some UI flow (and not silently during startup), as they - // may have setup cross-signing on a platform which has not saved keys - // to secret storage, and this would reset them. In such a case, you - // should prompt the user to verify any existing devices first (and - // request private keys from those devices) before calling bootstrap. - await resetCrossSigning(); - } else if (publicKeysOnDevice && privateKeysInCache) { - logger.log("Cross-signing public keys trusted and private keys found locally"); - } else if (privateKeysInStorage) { - logger.log( - "Cross-signing private keys not found locally, but they are available " + - "in secret storage, reading storage and caching locally", - ); - await this.checkOwnCrossSigningTrust({ - allowPrivateKeyRequests: true, - }); - } - - // Assuming no app-supplied callback, default to storing new private keys in - // secret storage if it exists. If it does not, it is assumed this will be - // done as part of setting up secret storage later. - const crossSigningPrivateKeys = builder.crossSigningCallbacks.privateKeys; - if (crossSigningPrivateKeys.size && !this.baseApis.cryptoCallbacks.saveCrossSigningKeys) { - const secretStorage = new SecretStorage( - builder.accountDataClientAdapter, - builder.ssssCryptoCallbacks, - undefined, - ); - if (await secretStorage.hasKey()) { - logger.log("Storing new cross-signing private keys in secret storage"); - // This is writing to in-memory account data in - // builder.accountDataClientAdapter so won't fail - await CrossSigningInfo.storeInSecretStorage(crossSigningPrivateKeys, secretStorage); - } - } - - const operation = builder.buildOperation(); - await operation.apply(this); - // This persists private keys and public keys as trusted, - // only do this if apply succeeded for now as retry isn't in place yet - await builder.persist(this); - - logger.log("Cross-signing ready"); - } - - /** - * Bootstrap Secure Secret Storage if needed by creating a default key. If everything is - * already set up, then no changes are made, so this is safe to run to ensure secret - * storage is ready for use. - * - * This function - * - creates a new Secure Secret Storage key if no default key exists - * - if a key backup exists, it is migrated to store the key in the Secret - * Storage - * - creates a backup if none exists, and one is requested - * - migrates Secure Secret Storage to use the latest algorithm, if an outdated - * algorithm is found - * - * The Secure Secret Storage API is currently UNSTABLE and may change without notice. - * - * @param 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 - public async bootstrapSecretStorage({ - createSecretStorageKey = async (): Promise<IRecoveryKey> => ({} as IRecoveryKey), - keyBackupInfo, - setupNewKeyBackup, - setupNewSecretStorage, - getKeyBackupPassphrase, - }: ICreateSecretStorageOpts = {}): Promise<void> { - logger.log("Bootstrapping Secure Secret Storage"); - const delegateCryptoCallbacks = this.baseApis.cryptoCallbacks; - const builder = new EncryptionSetupBuilder(this.baseApis.store.accountData, delegateCryptoCallbacks); - const secretStorage = new SecretStorage( - builder.accountDataClientAdapter, - builder.ssssCryptoCallbacks, - undefined, - ); - - // the ID of the new SSSS key, if we create one - let newKeyId: string | null = null; - - // create a new SSSS key and set it as default - const createSSSS = async (opts: IAddSecretStorageKeyOpts, privateKey?: Uint8Array): Promise<string> => { - if (privateKey) { - opts.key = privateKey; - } - - const { keyId, keyInfo } = await secretStorage.addKey(SECRET_STORAGE_ALGORITHM_V1_AES, opts); - - if (privateKey) { - // make the private key available to encrypt 4S secrets - builder.ssssCryptoCallbacks.addPrivateKey(keyId, keyInfo, privateKey); - } - - await secretStorage.setDefaultKeyId(keyId); - return keyId; - }; - - const ensureCanCheckPassphrase = async (keyId: string, keyInfo: SecretStorageKeyDescription): Promise<void> => { - 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 calculateKeyCheck(privateKey); - keyInfo.iv = iv; - keyInfo.mac = mac; - - await builder.setAccountData(`m.secret_storage.key.${keyId}`, keyInfo); - } - } - }; - - const signKeyBackupWithCrossSigning = async (keyBackupAuthData: IKeyBackupInfo["auth_data"]): Promise<void> => { - if (this.crossSigningInfo.getId() && (await this.crossSigningInfo.isStoredInKeyCache("master"))) { - try { - logger.log("Adding cross-signing signature to key backup"); - await this.crossSigningInfo.signObject(keyBackupAuthData, "master"); - } catch (e) { - // This step is not critical (just helpful), so we catch here - // and continue if it fails. - logger.error("Signing key backup with cross-signing keys failed", e); - } - } else { - logger.warn("Cross-signing keys not available, skipping signature on key backup"); - } - }; - - const oldSSSSKey = await this.getSecretStorageKey(); - const [oldKeyId, oldKeyInfo] = oldSSSSKey || [null, null]; - const storageExists = - !setupNewSecretStorage && oldKeyInfo && oldKeyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES; - - // Log all relevant state for easier parsing of debug logs. - logger.log({ - keyBackupInfo, - setupNewKeyBackup, - setupNewSecretStorage, - storageExists, - oldKeyInfo, - }); - - if (!storageExists && !keyBackupInfo) { - // either we don't have anything, or we've been asked to restart - // from scratch - logger.log("Secret storage does not exist, creating new storage key"); - - // if we already have a usable default SSSS key and aren't resetting - // SSSS just use it. otherwise, create a new one - // Note: we leave the old SSSS key in place: there could be other - // secrets using it, in theory. We could move them to the new key but a) - // that would mean we'd need to prompt for the old passphrase, and b) - // it's not clear that would be the right thing to do anyway. - const { keyInfo = {} as IAddSecretStorageKeyOpts, privateKey } = await createSecretStorageKey(); - newKeyId = await createSSSS(keyInfo, privateKey); - } else if (!storageExists && keyBackupInfo) { - // we have an existing backup, but no SSSS - logger.log("Secret storage does not exist, using key backup key"); - - // if we have the backup key already cached, use it; otherwise use the - // callback to prompt for the key - const backupKey = (await this.getSessionBackupPrivateKey()) || (await getKeyBackupPassphrase?.()); - - // create a new SSSS key and use the backup key as the new SSSS key - const opts = {} as IAddSecretStorageKeyOpts; - - 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.log("Secret storage exists"); - - if (oldKeyInfo && oldKeyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { - // make sure that the default key has the information needed to - // check the passphrase - await ensureCanCheckPassphrase(oldKeyId, oldKeyInfo); - } - } - - // If we have cross-signing private keys cached, store them in secret - // storage if they are not there already. - if ( - !this.baseApis.cryptoCallbacks.saveCrossSigningKeys && - (await this.isCrossSigningReady()) && - (newKeyId || !(await this.crossSigningInfo.isStoredInSecretStorage(secretStorage))) - ) { - logger.log("Copying cross-signing private keys from cache to secret storage"); - const crossSigningPrivateKeys = await this.crossSigningInfo.getCrossSigningKeysFromCache(); - // This is writing to in-memory account data in - // builder.accountDataClientAdapter so won't fail - await CrossSigningInfo.storeInSecretStorage(crossSigningPrivateKeys, secretStorage); - } - - if (setupNewKeyBackup && !keyBackupInfo) { - logger.log("Creating new message key backup version"); - const info = await this.baseApis.prepareKeyBackupVersion( - null /* random key */, - // don't write to secret storage, as it will write to this.secretStorage. - // Here, we want to capture all the side-effects of bootstrapping, - // and want to write to the local secretStorage object - { secureSecretStorage: false }, - ); - // write the key ourselves to 4S - const privateKey = decodeRecoveryKey(info.recovery_key); - await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(privateKey)); - - // create keyBackupInfo object to add to builder - const data: IKeyBackupInfo = { - algorithm: info.algorithm, - auth_data: info.auth_data, - }; - - // Sign with cross-signing master key - await signKeyBackupWithCrossSigning(data.auth_data); - - // sign with the device fingerprint - await this.signObject(data.auth_data); - - builder.addSessionBackup(data); - } - - // Cache the session backup key - const sessionBackupKey = await secretStorage.get("m.megolm_backup.v1"); - if (sessionBackupKey) { - logger.info("Got session backup key from secret storage: caching"); - // fix up the backup key if it's in the wrong format, and replace - // in secret storage - const fixedBackupKey = fixBackupKey(sessionBackupKey); - if (fixedBackupKey) { - 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.error("Key backup is enabled but couldn't get key backup key!"); - return; - } - 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.log("Secure Secret Storage ready"); - } - - public addSecretStorageKey( - algorithm: string, - opts: IAddSecretStorageKeyOpts, - keyID?: string, - ): Promise<SecretStorageKeyObject> { - return this.secretStorage.addKey(algorithm, opts, keyID); - } - - public hasSecretStorageKey(keyID?: string): Promise<boolean> { - return this.secretStorage.hasKey(keyID); - } - - public getSecretStorageKey(keyID?: string): Promise<SecretStorageKeyTuple | null> { - return this.secretStorage.getKey(keyID); - } - - public storeSecret(name: string, secret: string, keys?: string[]): Promise<void> { - return this.secretStorage.store(name, secret, keys); - } - - public getSecret(name: string): Promise<string | undefined> { - return this.secretStorage.get(name); - } - - public isSecretStored(name: string): Promise<Record<string, SecretStorageKeyDescription> | null> { - return this.secretStorage.isStored(name); - } - - public requestSecret(name: string, devices: string[]): ISecretRequest { - if (!devices) { - devices = Object.keys(this.deviceList.getRawStoredDevicesForUser(this.userId)); - } - return this.secretStorage.request(name, devices); - } - - public getDefaultSecretStorageKeyId(): Promise<string | null> { - return this.secretStorage.getDefaultKeyId(); - } - - public setDefaultSecretStorageKeyId(k: string): Promise<void> { - return this.secretStorage.setDefaultKeyId(k); - } - - public checkSecretStorageKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise<boolean> { - 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 - */ - public checkSecretStoragePrivateKey(privateKey: Uint8Array, expectedPublicKey: string): boolean { - let decryption: PkDecryption | null = 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 - */ - public async getSessionBackupPrivateKey(): Promise<Uint8Array | null> { - let key = await new Promise<any>((resolve) => { - // TODO types - this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.cryptoStore.getSecretStorePrivateKey(txn, resolve, "m.megolm_backup.v1"); - }); - }); - - // make sure we have a Uint8Array, rather than a string - if (key && typeof key === "string") { - key = new Uint8Array(olmlib.decodeBase64(fixBackupKey(key) || key)); - await this.storeSessionBackupPrivateKey(key); - } - if (key && key.ciphertext) { - const pickleKey = Buffer.from(this.olmDevice.pickleKey); - const decrypted = await decryptAES(key, pickleKey, "m.megolm_backup.v1"); - key = olmlib.decodeBase64(decrypted); - } - return key; - } - - /** - * Stores the session backup key to the cache - * @param key - the private key - * @returns a promise so you can catch failures - */ - public async storeSessionBackupPrivateKey(key: ArrayLike<number>): Promise<void> { - 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 encryptAES(olmlib.encodeBase64(key), pickleKey, "m.megolm_backup.v1"); - return this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.cryptoStore.storeSecretStorePrivateKey(txn, "m.megolm_backup.v1", 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 - */ - public checkCrossSigningPrivateKey(privateKey: Uint8Array, expectedPublicKey: string): boolean { - let signing: PkSigning | null = 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. - */ - private async afterCrossSigningLocalKeyChange(): Promise<void> { - logger.info("Starting cross-signing key change post-processing"); - - // sign the current device with the new key, and upload to the server - const device = this.deviceList.getStoredDevice(this.userId, this.deviceId)!; - const signedDevice = await this.crossSigningInfo.signDevice(this.userId, device); - logger.info(`Starting background key sig upload for ${this.deviceId}`); - - const upload = ({ shouldEmit = false }): Promise<void> => { - 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 KeySignatureUploadError("Key upload failed", { failures }); - } - logger.info(`Finished background key sig upload for ${this.deviceId}`); - }) - .catch((e) => { - logger.error(`Error during background key sig upload for ${this.deviceId}`, e); - }); - }; - upload({ shouldEmit: true }); - - const shouldUpgradeCb = this.baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications; - if (shouldUpgradeCb) { - logger.info("Starting device verification upgrade"); - - // Check all users for signatures if upgrade callback present - // FIXME: do this in batches - const users: Record<string, IDeviceVerificationUpgrade> = {}; - for (const [userId, crossSigningInfo] of Object.entries(this.deviceList.crossSigningInfo)) { - const upgradeInfo = await this.checkForDeviceVerificationUpgrade( - userId, - CrossSigningInfo.fromStorage(crossSigningInfo, userId), - ); - if (upgradeInfo) { - users[userId] = upgradeInfo; - } - } - - if (Object.keys(users).length > 0) { - logger.info(`Found ${Object.keys(users).length} verif users to upgrade`); - try { - const usersToUpgrade = await shouldUpgradeCb({ users: users }); - if (usersToUpgrade) { - for (const userId of usersToUpgrade) { - if (userId in users) { - await this.baseApis.setDeviceVerified(userId, users[userId].crossSigningInfo.getId()!); - } - } - } - } catch (e) { - logger.log("shouldUpgradeDeviceVerifications threw an error: not upgrading", e); - } - } - - logger.info("Finished device verification upgrade"); - } - - logger.info("Finished cross-signing key change post-processing"); - } - - /** - * Check if a user's cross-signing key is a candidate for upgrading from device - * verification. - * - * @param userId - the user whose cross-signing information is to be checked - * @param crossSigningInfo - the cross-signing information to check - */ - private async checkForDeviceVerificationUpgrade( - userId: string, - crossSigningInfo: CrossSigningInfo, - ): Promise<IDeviceVerificationUpgrade | undefined> { - // 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.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 - */ - private async checkForValidDeviceSignature( - userId: string, - key: ICrossSigningKey, - devices: Record<string, IDevice>, - ): Promise<string[]> { - const deviceIds: string[] = []; - 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 - */ - public getCrossSigningId(type: string): string | null { - 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. - */ - public getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null { - return this.deviceList.getStoredCrossSigningForUser(userId); - } - - /** - * Check whether a given user is trusted. - * - * @param userId - The ID of the user to check. - * - * @returns - */ - public checkUserTrust(userId: string): UserTrustLevel { - const userCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId); - if (!userCrossSigning) { - return new UserTrustLevel(false, false, false); - } - return this.crossSigningInfo.checkUserTrust(userCrossSigning); - } - - /** - * Check whether a given device is trusted. - * - * @param userId - The ID of the user whose devices is to be checked. - * @param deviceId - The ID of the device to check - * - * @returns - */ - public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel { - 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 - * - * @returns - */ - public checkDeviceInfoTrust(userId: string, device?: DeviceInfo): DeviceTrustLevel { - 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 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 - */ - public checkIfOwnDeviceCrossSigned(deviceId: string): boolean { - 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 - ); - } - - /* - * Event handler for DeviceList's userNewDevices event - */ - private onDeviceListUserCrossSigningUpdated = async (userId: string): Promise<void> => { - 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)); - } - }; - - /** - * 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. - */ - public async checkOwnCrossSigningTrust({ - allowPrivateKeyRequests = false, - }: ICheckOwnCrossSigningTrustOpts = {}): Promise<void> { - const userId = this.userId; - - // Before proceeding, ensure our cross-signing public keys have been - // downloaded via the device list. - await this.downloadKeys([this.userId]); - - // Also check which private keys are locally cached. - const crossSigningPrivateKeys = await this.crossSigningInfo.getCrossSigningKeysFromCache(); - - // If we see an update to our own master key, check it against the master - // key we have and, if it matches, mark it as verified - - // First, get the new cross-signing info - const newCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId); - if (!newCrossSigning) { - logger.error( - "Got cross-signing update event for user " + userId + " but no new cross-signing information found!", - ); - return; - } - - const seenPubkey = newCrossSigning.getId()!; - const masterChanged = this.crossSigningInfo.getId() !== seenPubkey; - const masterExistsNotLocallyCached = newCrossSigning.getId() && !crossSigningPrivateKeys.has("master"); - if (masterChanged) { - logger.info("Got new master public key", seenPubkey); - } - if (allowPrivateKeyRequests && (masterChanged || masterExistsNotLocallyCached)) { - logger.info("Attempting to retrieve cross-signing master private key"); - let signing: PkSigning | null = null; - // It's important for control flow that we leave any errors alone for - // higher levels to handle so that e.g. cancelling access properly - // aborts any larger operation as well. - try { - const ret = await this.crossSigningInfo.getCrossSigningKey("master", seenPubkey); - signing = ret[1]; - logger.info("Got cross-signing master private key"); - } finally { - 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: Record<string, ISignedKey> = {}; - - if (selfSigningChanged) { - logger.info("Got new self-signing key", newCrossSigning.getId("self_signing")); - } - if (allowPrivateKeyRequests && (selfSigningChanged || selfSigningExistsNotLocallyCached)) { - logger.info("Attempting to retrieve cross-signing self-signing private key"); - let signing: PkSigning | null = null; - try { - const ret = await this.crossSigningInfo.getCrossSigningKey( - "self_signing", - newCrossSigning.getId("self_signing")!, - ); - signing = ret[1]; - logger.info("Got cross-signing self-signing private key"); - } finally { - signing?.free(); - } - - const device = this.deviceList.getStoredDevice(this.userId, this.deviceId)!; - const signedDevice = await this.crossSigningInfo.signDevice(this.userId, device); - keySignatures[this.deviceId] = signedDevice!; - } - if (userSigningChanged) { - logger.info("Got new user-signing key", newCrossSigning.getId("user_signing")); - } - if (allowPrivateKeyRequests && (userSigningChanged || userSigningExistsNotLocallyCached)) { - logger.info("Attempting to retrieve cross-signing user-signing private key"); - let signing: PkSigning | null = null; - try { - const ret = await this.crossSigningInfo.getCrossSigningKey( - "user_signing", - newCrossSigning.getId("user_signing")!, - ); - signing = ret[1]; - logger.info("Got cross-signing user-signing private key"); - } finally { - 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({} as ISignedKey, masterKey, { - signatures: { - [this.userId]: { - ["ed25519:" + this.deviceId]: deviceSig, - }, - }, - }); - } - - const keysToUpload = Object.keys(keySignatures); - if (keysToUpload.length) { - const upload = ({ shouldEmit = false }): Promise<void> => { - logger.info(`Starting background key sig upload for ${keysToUpload}`); - return this.baseApis - .uploadKeySignatures({ [this.userId]: keySignatures }) - .then((response) => { - const { failures } = response || {}; - logger.info(`Finished background key sig upload for ${keysToUpload}`); - if (Object.keys(failures || []).length > 0) { - if (shouldEmit) { - this.baseApis.emit( - CryptoEvent.KeySignatureUploadFailure, - failures, - "checkOwnCrossSigningTrust", - upload, - ); - } - throw new KeySignatureUploadError("Key upload failed", { failures }); - } - }) - .catch((e) => { - logger.error(`Error during background key sig upload for ${keysToUpload}`, e); - }); - }; - upload({ shouldEmit: true }); - } - - this.emit(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 - */ - private async storeTrustedSelfKeys(keys: Record<string, ICrossSigningKey> | null): Promise<void> { - if (keys) { - this.crossSigningInfo.setKeys(keys); - } else { - this.crossSigningInfo.clearKeys(); - } - await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.cryptoStore.storeCrossSigningKeys(txn, this.crossSigningInfo.keys); - }); - } - - /** - * Check if the master key is signed by a verified device, and if so, prompt - * the application to mark it as verified. - * - * @param userId - the user ID whose key should be checked - */ - private async checkDeviceVerifications(userId: string): Promise<void> { - const shouldUpgradeCb = this.baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications; - if (!shouldUpgradeCb) { - // Upgrading skipped when callback is not present. - return; - } - logger.info(`Starting device verification upgrade for ${userId}`); - if (this.crossSigningInfo.keys.user_signing) { - const crossSigningInfo = this.deviceList.getStoredCrossSigningForUser(userId); - if (crossSigningInfo) { - const upgradeInfo = await this.checkForDeviceVerificationUpgrade(userId, crossSigningInfo); - if (upgradeInfo) { - const usersToUpgrade = await shouldUpgradeCb({ - users: { - [userId]: upgradeInfo, - }, - }); - if (usersToUpgrade.includes(userId)) { - await this.baseApis.setDeviceVerified(userId, crossSigningInfo.getId()!); - } - } - } - } - logger.info(`Finished device verification upgrade for ${userId}`); - } - - /** - */ - public enableLazyLoading(): void { - 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 - */ - public registerEventHandlers( - eventEmitter: TypedEventEmitter< - RoomMemberEvent.Membership | ClientEvent.ToDeviceEvent | RoomEvent.Timeline | MatrixEventEvent.Decrypted, - any - >, - ): void { - eventEmitter.on(RoomMemberEvent.Membership, this.onMembership); - eventEmitter.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); - eventEmitter.on(RoomEvent.Timeline, this.onTimelineEvent); - eventEmitter.on(MatrixEventEvent.Decrypted, this.onTimelineEvent); - } - - /** - * @deprecated this does nothing and will be removed in a future version - */ - public start(): void { - logger.warn("MatrixClient.crypto.start() is deprecated"); - } - - /** Stop background processes related to crypto */ - public stop(): void { - this.outgoingRoomKeyRequestManager.stop(); - this.deviceList.stop(); - this.dehydrationManager.stop(); - } - - /** - * Get the Ed25519 key for this device - * - * @returns base64-encoded ed25519 key. - */ - public getDeviceEd25519Key(): string | null { - return this.olmDevice.deviceEd25519Key; - } - - /** - * Get the Curve25519 key for this device - * - * @returns base64-encoded curve25519 key. - */ - public getDeviceCurve25519Key(): string | null { - 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 For external code, use {@link MatrixClient#setGlobalBlacklistUnverifiedDevices}. For - * internal code, set {@link MatrixClient#globalBlacklistUnverifiedDevices} directly. - */ - public setGlobalBlacklistUnverifiedDevices(value: boolean): void { - this.globalBlacklistUnverifiedDevices = value; - } - - /** - * @returns whether to blacklist all unverified devices by default - * - * @deprecated For external code, use {@link MatrixClient#getGlobalBlacklistUnverifiedDevices}. For - * internal code, reference {@link MatrixClient#globalBlacklistUnverifiedDevices} directly. - */ - public getGlobalBlacklistUnverifiedDevices(): boolean { - return this.globalBlacklistUnverifiedDevices; - } - - /** - * Upload the device keys to the homeserver. - * @returns A promise that will resolve when the keys are uploaded. - */ - public uploadDeviceKeys(): Promise<IKeysUploadResponse> { - 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 as Required<IDeviceKeys>, - }); - }); - } - - /** - * Stores the current one_time_key count which will be handled later (in a call of - * onSyncCompleted). The count is e.g. coming from a /sync response. - * - * @param currentCount - The current count of one_time_keys to be stored - */ - public updateOneTimeKeyCount(currentCount: number): void { - if (isFinite(currentCount)) { - this.oneTimeKeyCount = currentCount; - } else { - throw new TypeError("Parameter for updateOneTimeKeyCount has to be a number"); - } - } - - public setNeedsNewFallback(needsNewFallback: boolean): void { - this.needsNewFallback = needsNewFallback; - } - - public getNeedsNewFallback(): boolean { - return !!this.needsNewFallback; - } - - // check if it's time to upload one-time keys, and do so if so. - private maybeUploadOneTimeKeys(): void { - // 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: number): Promise<void> => { - while (keyLimit > keyCount || this.getNeedsNewFallback()) { - // Ask olm to generate new one time keys, then upload them to synapse. - if (keyLimit > keyCount) { - logger.info("generating oneTimeKeys"); - const keysThisLoop = Math.min(keyLimit - keyCount, maxKeysPerCycle); - await 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.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.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.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 - private async uploadOneTimeKeys(): Promise<IKeysUploadResponse> { - const promises: Promise<unknown>[] = []; - - let fallbackJson: Record<string, IOneTimeKey> | undefined; - 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.setNeedsNewFallback(false); - } - - const oneTimeKeys = await this.olmDevice.getOneTimeKeys(); - const oneTimeJson: Record<string, { key: string }> = {}; - - 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: Record<string, any> = { - 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}`. - */ - public downloadKeys(userIds: string[], forceDownload?: boolean): Promise<DeviceInfoMap> { - 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. - */ - public getStoredDevicesForUser(userId: string): Array<DeviceInfo> | null { - return this.deviceList.getStoredDevicesForUser(userId); - } - - /** - * Get the stored keys for a single device - * - * - * @returns device, or undefined - * if we don't know about this device - */ - public getStoredDevice(userId: string, deviceId: string): DeviceInfo | undefined { - 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. - */ - public saveDeviceList(delay: number): Promise<boolean> { - 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 - */ - public async setDeviceVerification( - userId: string, - deviceId: string, - verified: boolean | null = null, - blocked: boolean | null = null, - known: boolean | null = null, - keys?: Record<string, string>, - ): Promise<DeviceInfo | CrossSigningInfo> { - // 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.info("Master key " + xsk.getId() + " for " + userId + " marked verified. Signing..."); - const device = await this.crossSigningInfo.signUser(xsk); - if (device) { - const upload = async ({ shouldEmit = false }): Promise<void> => { - 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 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 as any; // 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.info("Own device " + deviceId + " marked verified: signing"); - - // Signing only needed if other device not already signed - let device: ISignedKey | undefined; - const deviceTrust = this.checkDeviceTrust(userId, deviceId); - if (deviceTrust.isCrossSigningVerified()) { - logger.log(`Own device ${deviceId} already cross-signing verified`); - } else { - device = (await this.crossSigningInfo.signDevice(userId, DeviceInfo.fromStorage(dev, deviceId)))!; - } - - if (device) { - const upload = async ({ shouldEmit = false }): Promise<void> => { - 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 KeySignatureUploadError("Key upload failed", { failures }); - } - }; - await upload({ shouldEmit: true }); - // XXX: we'll need to wait for the device list to be updated - } - } - - const deviceObj = DeviceInfo.fromStorage(dev, deviceId); - this.emit(CryptoEvent.DeviceVerificationChanged, userId, deviceId, deviceObj); - return deviceObj; - } - - public findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined { - return this.inRoomVerificationRequests.findRequestInProgress(roomId); - } - - public getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[] { - return this.toDeviceVerificationRequests.getRequestsInProgress(userId); - } - - public requestVerificationDM(userId: string, roomId: string): Promise<VerificationRequest> { - const existingRequest = this.inRoomVerificationRequests.findRequestInProgress(roomId); - if (existingRequest) { - return Promise.resolve(existingRequest); - } - const channel = new InRoomChannel(this.baseApis, roomId, userId); - return this.requestVerificationWithChannel(userId, channel, this.inRoomVerificationRequests); - } - - public requestVerification(userId: string, devices?: string[]): Promise<VerificationRequest> { - if (!devices) { - devices = Object.keys(this.deviceList.getRawStoredDevicesForUser(userId)); - } - const existingRequest = this.toDeviceVerificationRequests.findRequestInProgress(userId, devices); - if (existingRequest) { - return Promise.resolve(existingRequest); - } - const channel = new ToDeviceChannel(this.baseApis, userId, devices, ToDeviceChannel.makeTransactionId()); - return this.requestVerificationWithChannel(userId, channel, this.toDeviceVerificationRequests); - } - - private async requestVerificationWithChannel( - userId: string, - channel: IVerificationChannel, - requestsMap: IRequestsMap, - ): Promise<VerificationRequest> { - let request = new VerificationRequest(channel, this.verificationMethods, this.baseApis); - // if transaction id is already known, add request - if (channel.transactionId) { - requestsMap.setRequestByChannel(channel, request); - } - await request.sendRequest(); - // don't replace the request created by a racing remote echo - const racingRequest = requestsMap.getRequestByChannel(channel); - if (racingRequest) { - request = racingRequest; - } else { - logger.log( - `Crypto: adding new request to ` + `requestsByTxnId with id ${channel.transactionId} ${channel.roomId}`, - ); - requestsMap.setRequestByChannel(channel, request); - } - return request; - } - - public beginKeyVerification( - method: string, - userId: string, - deviceId: string, - transactionId: string | null = null, - ): VerificationBase<any, any> { - let request: Request | undefined; - if (transactionId) { - request = this.toDeviceVerificationRequests.getRequestBySenderAndTxnId(userId, transactionId); - if (!request) { - throw new Error(`No request found for user ${userId} with ` + `transactionId ${transactionId}`); - } - } else { - transactionId = ToDeviceChannel.makeTransactionId(); - const channel = new ToDeviceChannel(this.baseApis, userId, [deviceId], transactionId, deviceId); - request = new VerificationRequest(channel, this.verificationMethods, this.baseApis); - this.toDeviceVerificationRequests.setRequestBySenderAndTxnId(userId, transactionId, request); - } - return request.beginKeyVerification(method, { userId, deviceId }); - } - - public async legacyDeviceVerification( - userId: string, - deviceId: string, - method: VerificationMethod, - ): Promise<VerificationRequest> { - const transactionId = ToDeviceChannel.makeTransactionId(); - const channel = new ToDeviceChannel(this.baseApis, userId, [deviceId], transactionId, deviceId); - const request = new VerificationRequest(channel, this.verificationMethods, this.baseApis); - this.toDeviceVerificationRequests.setRequestBySenderAndTxnId(userId, transactionId, request); - const verifier = request.beginKeyVerification(method, { userId, deviceId }); - // either reject by an error from verify() while sending .start - // or resolve when the request receives the - // local (fake remote) echo for sending the .start event - await Promise.race([verifier.verify(), request.waitFor((r) => r.started)]); - return request; - } - - /** - * Get information on the active olm sessions with a user - * <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 - */ - public async getOlmSessionsForUser(userId: string): Promise<Record<string, IUserOlmSession>> { - const devices = this.getStoredDevicesForUser(userId) || []; - const result: { [deviceId: string]: IUserOlmSession } = {}; - 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 - */ - public getEventSenderDeviceInfo(event: MatrixEvent): DeviceInfo | null { - 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.warn("Event " + event.getId() + " claims no ed25519 key: " + "cannot verify sending device"); - return null; - } - - if (claimedKey !== device.getFingerprint()) { - logger.warn( - "Event " + - event.getId() + - " claims ed25519 key " + - claimedKey + - " but sender device has key " + - device.getFingerprint(), - ); - return null; - } - - return device; - } - - /** - * Get information about the encryption of an event - * - * @param 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) - */ - public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo { - const ret: Partial<IEncryptedEventInfo> = {}; - - ret.senderKey = event.getSenderKey() ?? undefined; - ret.algorithm = event.getWireContent().algorithm; - - if (!ret.senderKey || !ret.algorithm) { - ret.encrypted = false; - return ret as IEncryptedEventInfo; - } - 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.warn("Event " + event.getId() + " claims no ed25519 key: " + "cannot verify sending device"); - ret.mismatchedSender = true; - } - - if (ret.sender && claimedKey !== ret.sender.getFingerprint()) { - logger.warn( - "Event " + - event.getId() + - " claims ed25519 key " + - claimedKey + - "but sender device has key " + - ret.sender.getFingerprint(), - ); - ret.mismatchedSender = true; - } - - return ret as IEncryptedEventInfo; - } - - /** - * 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. - */ - public forceDiscardSession(roomId: string): Promise<void> { - 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). - */ - public async setRoomEncryption( - roomId: string, - config: IRoomEncryption, - inhibitDeviceQuery?: boolean, - ): Promise<void> { - 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. - */ - private async setRoomEncryptionImpl(room: Room, config: IRoomEncryption): Promise<void> { - 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.log("Ignoring setRoomEncryption with no algorithm"); - return; - } - - // if state is being replayed from storage, we might already have a configuration - // for this room as they are persisted as well. - // We just need to make sure the algorithm is initialized in this case. - // However, if the new config is different, - // we should bail out as room encryption can't be changed once set. - const existingConfig = this.roomList.getRoomEncryption(roomId); - if (existingConfig) { - if (JSON.stringify(existingConfig) != JSON.stringify(config)) { - logger.error("Ignoring m.room.encryption event which requests " + "a change of config in " + roomId); - return; - } - } - // if we already have encryption in this room, we should ignore this event, - // as it would reset the encryption algorithm. - // This is at least expected to be called twice, as sync calls onCryptoEvent - // for both the timeline and state sections in the /sync response, - // the encryption event would appear in both. - // If it's called more than twice though, - // it signals a bug on client or server. - const existingAlg = this.roomEncryptors.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: Promise<void> | null = 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.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: RoomState): void => { - room.off(RoomStateEvent.Update, onState); - if (room.membersLoaded()) { - this.trackRoomDevicesImpl(room).catch((e) => { - logger.error(`Error enabling device tracking in ${roomId}`, e); - }); - } - }; - room.on(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. - */ - public trackRoomDevices(roomId: string): Promise<void> { - 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 - */ - private trackRoomDevicesImpl(room: Room): Promise<void> { - const roomId = room.roomId; - const trackMembers = async (): Promise<void> => { - // not an encrypted room - if (!this.roomEncryptors.has(roomId)) { - return; - } - 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 - * {@link OlmSessionResult} - */ - public ensureOlmSessionsForUsers( - users: string[], - force?: boolean, - ): Promise<Map<string, Map<string, olmlib.IOlmSessionResult>>> { - // map user Id → DeviceInfo[] - const devicesByUser: Map<string, DeviceInfo[]> = new Map(); - - for (const userId of users) { - const userDevices: DeviceInfo[] = []; - 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 - */ - public async exportRoomKeys(): Promise<IMegolmSessionData[]> { - const exportedSessions: IMegolmSessionData[] = []; - await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => { - this.cryptoStore.getAllEndToEndInboundGroupSessions(txn, (s) => { - if (s === null) return; - - const sess = this.olmDevice.exportInboundGroupSession(s.senderKey, s.sessionId, s.sessionData!); - delete sess.first_known_index; - sess.algorithm = olmlib.MEGOLM_ALGORITHM; - exportedSessions.push(sess); - }); - }); - - return exportedSessions; - } - - /** - * Import a list of room keys previously exported by exportRoomKeys - * - * @param keys - a list of session export objects - * @returns a promise which resolves once the keys have been imported - */ - public importRoomKeys(keys: IMegolmSessionData[], opts: IImportRoomKeysOpts = {}): Promise<void> { - let successes = 0; - let failures = 0; - const total = keys.length; - - function updateProgress(): void { - opts.progressCallback?.({ - stage: "load_keys", - successes, - failures, - total, - }); - } - - return Promise.all( - keys.map((key) => { - if (!key.room_id || !key.algorithm) { - logger.warn("ignoring room key entry with missing fields", key); - failures++; - if (opts.progressCallback) { - updateProgress(); - } - return null; - } - - const alg = this.getRoomDecryptor(key.room_id, key.algorithm); - return alg.importRoomKey(key, opts).finally(() => { - 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 - */ - public countSessionsNeedingBackup(): Promise<number> { - 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 - */ - public prepareToEncrypt(room: Room): void { - 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 - */ - public async encryptEvent(event: MatrixEvent, room: Room): Promise<void> { - 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)) as IContent; - - 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. - */ - public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> { - 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 MatrixEvent({ - room_id: event.getRoomId(), - ...event.getUnsigned().redacted_because, - }); - let redactedBecause: IEvent = event.getUnsigned().redacted_because!; - if (redactionEvent.isEncrypted()) { - try { - const decryptedEvent = await this.decryptEvent(redactionEvent); - redactedBecause = decryptedEvent.clearEvent as IEvent; - } catch (e) { - 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 or /keys/changes that device lists have - * been changed. - * - * @param syncData - Object containing sync tokens associated with this sync - * @param syncDeviceLists - device_lists field from /sync, or response from - * /keys/changes - */ - public async handleDeviceListChanges( - syncData: ISyncStateData, - syncDeviceLists: Required<ISyncResponse>["device_lists"], - ): Promise<void> { - // Initial syncs don't have device change lists. We'll either get the complete list - // of changes for the interval or will have invalidated everything in willProcessSync - if (!syncData.oldSyncToken) return; - - // Here, we're relying on the fact that we only ever save the sync data after - // sucessfully saving the device list data, so we're guaranteed that the device - // list store is at least as fresh as the sync token from the sync store, ie. - // any device changes received in sync tokens prior to the 'next' token here - // have been processed and are reflected in the current device list. - // If we didn't make this assumption, we'd have to use the /keys/changes API - // to get key changes between the sync token in the device list and the 'old' - // sync token used here to make sure we didn't miss any. - await this.evalDeviceListChanges(syncDeviceLists); - } - - /** - * Send a request for some room keys, if we have not already done so - * - * @param resend - whether to resend the key request if there is - * already one - * - * @returns a promise that resolves when the key request is queued - */ - public requestRoomKey( - requestBody: IRoomKeyRequestBody, - recipients: IRoomKeyRequestRecipient[], - resend = false, - ): Promise<void> { - return this.outgoingRoomKeyRequestManager - .queueRoomKeyRequest(requestBody, recipients, resend) - .then(() => { - if (this.sendKeyRequestsImmediately) { - this.outgoingRoomKeyRequestManager.sendQueuedRequests(); - } - }) - .catch((e) => { - // this normally means we couldn't talk to the store - logger.error("Error requesting key for event", e); - }); - } - - /** - * Cancel any earlier room key request - * - * @param requestBody - parameters to match for cancellation - */ - public cancelRoomKeyRequest(requestBody: IRoomKeyRequestBody): void { - this.outgoingRoomKeyRequestManager.cancelRoomKeyRequest(requestBody).catch((e) => { - logger.warn("Error clearing pending room key requests", e); - }); - } - - /** - * Re-send any outgoing key requests, eg after verification - * @returns - */ - public async cancelAndResendAllOutgoingKeyRequests(): Promise<void> { - 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 - */ - public async onCryptoEvent(room: Room, event: MatrixEvent): Promise<void> { - const content = event.getContent<IRoomEncryption>(); - await this.setRoomEncryptionImpl(room, content); - } - - /** - * Called before the result of a sync is processed - * - * @param syncData - the data from the 'MatrixClient.sync' event - */ - public async onSyncWillProcess(syncData: ISyncStateData): Promise<void> { - if (!syncData.oldSyncToken) { - // If there is no old sync token, we start all our tracking from - // scratch, so mark everything as untracked. onCryptoEvent will - // be called for all e2e rooms during the processing of the sync, - // at which point we'll start tracking all the users of that room. - logger.log("Initial sync performed - resetting device tracking state"); - this.deviceList.stopTrackingAllDeviceLists(); - // we always track our own device list (for key backups etc) - this.deviceList.startTrackingDeviceList(this.userId); - this.roomDeviceTrackingState = {}; - } - - this.sendKeyRequestsImmediately = false; - } - - /** - * handle the completion of a /sync - * - * This is called after the processing of each successful /sync response. - * It is an opportunity to do a batch process on the information received. - * - * @param syncData - the data from the 'MatrixClient.sync' event - */ - public async onSyncCompleted(syncData: OnSyncCompletedData): Promise<void> { - 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 - */ - private async evalDeviceListChanges(deviceLists: Required<ISyncResponse>["device_lists"]): Promise<void> { - 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 - */ - private async getTrackedE2eUsers(): Promise<string[]> { - const e2eUserIds: string[] = []; - 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 - */ - private getTrackedE2eRooms(): Room[] { - 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. - */ - public async encryptAndSendToDevices(userDeviceInfoArr: IOlmDevice<DeviceInfo>[], payload: object): Promise<void> { - const toDeviceBatch: ToDeviceBatch = { - eventType: EventType.RoomMessageEncrypted, - batch: [], - }; - - try { - await Promise.all( - userDeviceInfoArr.map(async ({ userId, deviceInfo }) => { - const deviceId = deviceInfo.deviceId; - const encryptedContent: IEncryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key!, - ciphertext: {}, - [ToDeviceMessageId]: uuidv4(), - }; - - 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.log(`No ciphertext for device ${msg.userId}:${msg.deviceId}: pruning`); - return false; - } - }); - - try { - await this.baseApis.queueToDevice(toDeviceBatch); - } catch (e) { - logger.error("sendToDevice failed", e); - throw e; - } - } catch (e) { - logger.error("encryptAndSendToDevices promises failed", e); - throw e; - } - } - - private onMembership = (event: MatrixEvent, member: RoomMember, oldMembership?: string): void => { - try { - this.onRoomMembership(event, member, oldMembership); - } catch (e) { - logger.error("Error handling membership change:", e); - } - }; - - public async preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]> { - // 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 === EventType.RoomMessageEncrypted && - !["m.olm.v1.curve25519-aes-sha2"].includes(toDevice.content?.algorithm) - ) { - logger.log("Ignoring invalid encrypted to-device event from " + toDevice.sender); - return false; - } - return true; - }); - } - - public preprocessOneTimeKeyCounts(oneTimeKeysCounts: Map<string, number>): Promise<void> { - const currentCount = oneTimeKeysCounts.get("signed_curve25519") || 0; - this.updateOneTimeKeyCount(currentCount); - return Promise.resolve(); - } - - public preprocessUnusedFallbackKeys(unusedFallbackKeys: Set<string>): Promise<void> { - this.setNeedsNewFallback(!unusedFallbackKeys.has("signed_curve25519")); - return Promise.resolve(); - } - - private onToDeviceEvent = (event: MatrixEvent): void => { - try { - logger.log( - `received to-device ${event.getType()} from: ` + - `${event.getSender()} id: ${event.getContent()[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(MatrixEventEvent.Decrypted, (ev) => { - this.onToDeviceEvent(ev); - }); - } - } catch (e) { - logger.error("Error handling toDeviceEvent:", e); - } - }; - - /** - * Handle a key event - * - * @internal - * @param event - key event - */ - private onRoomKeyEvent(event: MatrixEvent): void { - const content = event.getContent(); - - if (!content.room_id || !content.algorithm) { - 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 - */ - private onRoomKeyWithheldEvent(event: MatrixEvent): void { - const content = event.getContent(); - - if ( - (content.code !== "m.no_olm" && (!content.room_id || !content.session_id)) || - !content.algorithm || - !content.sender_key - ) { - logger.error("key withheld event is missing fields"); - return; - } - - logger.info( - `Got room key withheld event from ${event.getSender()} ` + - `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 - */ - private onKeyVerificationMessage(event: MatrixEvent): void { - if (!ToDeviceChannel.validateEvent(event, this.baseApis)) { - return; - } - const createRequest = (event: MatrixEvent): VerificationRequest | undefined => { - if (!ToDeviceChannel.canCreateRequest(ToDeviceChannel.getEventType(event))) { - return; - } - const content = event.getContent(); - const deviceId = content && content.from_device; - if (!deviceId) { - return; - } - const userId = event.getSender()!; - const channel = new ToDeviceChannel(this.baseApis, userId, [deviceId]); - return new VerificationRequest(channel, this.verificationMethods, this.baseApis); - }; - this.handleVerificationEvent(event, this.toDeviceVerificationRequests, createRequest); - } - - /** - * Handle key verification requests sent as timeline events - * - * @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 - */ - private onTimelineEvent = ( - event: MatrixEvent, - room: Room, - atStart: boolean, - removed: boolean, - { liveEvent = true } = {}, - ): void => { - if (!InRoomChannel.validateEvent(event, this.baseApis)) { - return; - } - const createRequest = (event: MatrixEvent): VerificationRequest => { - const channel = new InRoomChannel(this.baseApis, event.getRoomId()!); - return new VerificationRequest(channel, this.verificationMethods, this.baseApis); - }; - this.handleVerificationEvent(event, this.inRoomVerificationRequests, createRequest, liveEvent); - }; - - private async handleVerificationEvent( - event: MatrixEvent, - requestsMap: IRequestsMap, - createRequest: (event: MatrixEvent) => VerificationRequest | undefined, - isLiveEvent = true, - ): Promise<void> { - // Wait for event to get its final ID with pendingEventOrdering: "chronological", since DM channels depend on it. - if (event.isSending() && event.status != EventStatus.SENT) { - let eventIdListener: () => void; - let statusListener: () => void; - try { - await new Promise<void>((resolve, reject) => { - eventIdListener = resolve; - statusListener = (): void => { - if (event.status == EventStatus.CANCELLED) { - reject(new Error("Event status set to CANCELLED.")); - } - }; - event.once(MatrixEventEvent.LocalEventIdReplaced, eventIdListener); - event.on(MatrixEventEvent.Status, statusListener); - }); - } catch (err) { - logger.error("error while waiting for the verification event to be sent: ", err); - return; - } finally { - event.removeListener(MatrixEventEvent.LocalEventIdReplaced, eventIdListener!); - event.removeListener(MatrixEventEvent.Status, statusListener!); - } - } - let request: VerificationRequest | undefined = requestsMap.getRequest(event); - let isNewRequest = false; - if (!request) { - request = createRequest(event); - // a request could not be made from this event, so ignore event - if (!request) { - logger.log( - `Crypto: could not find VerificationRequest for ` + - `${event.getType()}, and could not create one, so ignoring.`, - ); - return; - } - isNewRequest = true; - requestsMap.setRequest(event, request); - } - event.setVerificationRequest(request); - try { - await request.channel.handleEvent(event, request, isLiveEvent); - } catch (err) { - logger.error("error while handling verification event", err); - } - 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 - */ - private async onToDeviceBadEncrypted(event: MatrixEvent): Promise<void> { - const content = event.getWireContent(); - const sender = event.getSender(); - const algorithm = content.algorithm; - const deviceKey = content.sender_key; - - this.baseApis.emit(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 = (): void => { - 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.debug( - "New session already forced with device " + - sender + - ":" + - deviceKey + - " at " + - lastNewSessionForced + - ": not forcing another", - ); - await this.olmDevice.recordSessionProblem(deviceKey, "wedged", true); - retryDecryption(); - return; - } - - // establish a new olm session with this device since we're failing to decrypt messages - // on a current session. - // Note that an undecryptable message from another device could easily be spoofed - - // is there anything we can do to mitigate this? - let device = this.deviceList.getDeviceByIdentityKey(algorithm, deviceKey); - if (!device) { - // if we don't know about the device, fetch the user's devices again - // and retry before giving up - await this.downloadKeys([sender], false); - device = this.deviceList.getDeviceByIdentityKey(algorithm, deviceKey); - if (!device) { - logger.info("Couldn't find device for identity key " + deviceKey + ": not re-establishing session"); - await this.olmDevice.recordSessionProblem(deviceKey, "wedged", false); - retryDecryption(); - return; - } - } - const devicesByUser = 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: IEncryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key!, - ciphertext: {}, - [ToDeviceMessageId]: uuidv4(), - }; - 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 - */ - private onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void { - // 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.log("Join event for " + member.userId + " in " + roomId); - // make sure we are tracking the deviceList for this user - this.deviceList.startTrackingDeviceList(member.userId); - } else if ( - member.membership == "invite" && - this.clientStore.getRoom(roomId)?.shouldEncryptForInvitedMembers() - ) { - logger.log("Invite event for " + member.userId + " in " + roomId); - this.deviceList.startTrackingDeviceList(member.userId); - } - } - - alg.onRoomMembership(event, member, oldMembership); - } - - /** - * Called when we get an m.room_key_request event. - * - * @internal - * @param event - key request event - */ - private onRoomKeyRequestEvent(event: MatrixEvent): void { - 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 - */ - private async processReceivedRoomKeyRequests(): Promise<void> { - if (this.processingRoomKeyRequests) { - // we're still processing last time's requests; keep queuing new ones - // up for now. - return; - } - this.processingRoomKeyRequests = true; - - try { - // we need to grab and clear the queues in the synchronous bit of this method, - // so that we don't end up racing with the next /sync. - const requests = this.receivedRoomKeyRequests; - this.receivedRoomKeyRequests = []; - const cancellations = this.receivedRoomKeyRequestCancellations; - this.receivedRoomKeyRequestCancellations = []; - - // Process all of the requests, *then* all of the cancellations. - // - // This makes sure that if we get a request and its cancellation in the - // same /sync result, then we process the request before the - // cancellation (and end up with a cancelled request), rather than the - // cancellation before the request (and end up with an outstanding - // request which should have been cancelled.) - await Promise.all(requests.map((req) => this.processReceivedRoomKeyRequest(req))); - await Promise.all( - cancellations.map((cancellation) => this.processReceivedRoomKeyRequestCancellation(cancellation)), - ); - } catch (e) { - logger.error(`Error processing room key requsts: ${e}`); - } finally { - this.processingRoomKeyRequests = false; - } - } - - /** - * Helper for processReceivedRoomKeyRequests - * - */ - private async processReceivedRoomKeyRequest(req: IncomingRoomKeyRequest): Promise<void> { - const userId = req.userId; - const deviceId = req.deviceId; - - const body = req.requestBody; - const roomId = body.room_id; - const alg = body.algorithm; - - logger.log( - `m.room_key_request from ${userId}:${deviceId}` + - ` for ${roomId} / ${body.session_id} (id ${req.requestId})`, - ); - - if (userId !== this.userId) { - if (!this.roomEncryptors.get(roomId)) { - logger.debug(`room key request for unencrypted room ${roomId}`); - return; - } - const encryptor = this.roomEncryptors.get(roomId)!; - const device = this.deviceList.getStoredDevice(userId, deviceId); - if (!device) { - logger.debug(`Ignoring keyshare for unknown device ${userId}:${deviceId}`); - return; - } - - try { - await encryptor.reshareKeyWithDevice!(body.sender_key, body.session_id, userId, device); - } catch (e) { - logger.warn( - "Failed to re-share keys for session " + - body.session_id + - " with device " + - userId + - ":" + - device.deviceId, - e, - ); - } - return; - } - - if (deviceId === this.deviceId) { - // We'll always get these because we send room key requests to - // '*' (ie. 'all devices') which includes the sending device, - // so ignore requests from ourself because apart from it being - // very silly, it won't work because an Olm session cannot send - // messages to itself. - // The log here is probably superfluous since we know this will - // always happen, but let's log anyway for now just in case it - // causes issues. - logger.log("Ignoring room key request from ourselves"); - return; - } - - // todo: should we queue up requests we don't yet have keys for, - // in case they turn up later? - - // if we don't have a decryptor for this room/alg, we don't have - // the keys for the requested events, and can drop the requests. - if (!this.roomDecryptors.has(roomId)) { - logger.log(`room key request for unencrypted room ${roomId}`); - return; - } - - const decryptor = this.roomDecryptors.get(roomId)!.get(alg); - if (!decryptor) { - logger.log(`room key request for unknown alg ${alg} in room ${roomId}`); - return; - } - - if (!(await decryptor.hasKeysForKeyRequest(req))) { - logger.log(`room key request for unknown session ${roomId} / ` + body.session_id); - return; - } - - req.share = (): void => { - decryptor.shareKeysWithDevice(req); - }; - - // if the device is verified already, share the keys - if (this.checkDeviceTrust(userId, deviceId).isVerified()) { - logger.log("device is already verified: sharing keys"); - req.share(); - return; - } - - this.emit(CryptoEvent.RoomKeyRequest, req); - } - - /** - * Helper for processReceivedRoomKeyRequests - * - */ - private async processReceivedRoomKeyRequestCancellation( - cancellation: IncomingRoomKeyRequestCancellation, - ): Promise<void> { - 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 {@link DecryptionError} if the algorithm is unknown - */ - public getRoomDecryptor(roomId: string | null, algorithm: string): DecryptionAlgorithm { - let decryptors: Map<string, DecryptionAlgorithm> | undefined; - let alg: DecryptionAlgorithm | undefined; - - if (roomId) { - decryptors = this.roomDecryptors.get(roomId); - if (!decryptors) { - decryptors = new Map<string, DecryptionAlgorithm>(); - 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 - */ - private getRoomDecryptors(algorithm: string): DecryptionAlgorithm[] { - const decryptors: DecryptionAlgorithm[] = []; - 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 - */ - public async signObject<T extends ISignableObject & object>(obj: T): Promise<void> { - 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.stringify(obj)); - obj.signatures = 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. - * - */ -export function fixBackupKey(key?: string): string | null { - 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 - */ -export class IncomingRoomKeyRequest { - /** user requesting the key */ - public readonly userId: string; - /** device requesting the key */ - public readonly deviceId: string; - /** unique id for the request */ - public readonly requestId: string; - public readonly requestBody: IRoomKeyRequestBody; - /** - * callback which, when called, will ask - * the relevant crypto algorithm implementation to share the keys for - * this request. - */ - public share: () => void; - - public constructor(event: MatrixEvent) { - 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 = (): void => { - throw new Error("don't know how to share keys for this request yet"); - }; - } -} - -/** - * Represents a received m.room_key_request cancellation - */ -class IncomingRoomKeyRequestCancellation { - /** user requesting the cancellation */ - public readonly userId: string; - /** device requesting the cancellation */ - public readonly deviceId: string; - /** unique id for the request to be cancelled */ - public readonly requestId: string; - - public constructor(event: MatrixEvent) { - 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. -export type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto"; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/key_passphrase.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/key_passphrase.ts deleted file mode 100644 index f6fe7b6..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/key_passphrase.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* -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. -*/ - -import { randomString } from "../randomstring"; -import { subtleCrypto, TextEncoder } from "./crypto"; - -const DEFAULT_ITERATIONS = 500000; - -const DEFAULT_BITSIZE = 256; - -/* eslint-disable camelcase */ -interface IAuthData { - private_key_salt?: string; - private_key_iterations?: number; - private_key_bits?: number; -} -/* eslint-enable camelcase */ - -interface IKey { - key: Uint8Array; - salt: string; - iterations: number; -} - -export function keyFromAuthData(authData: IAuthData, password: string): Promise<Uint8Array> { - 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, - ); -} - -export async function keyFromPassphrase(password: string): Promise<IKey> { - if (!global.Olm) { - throw new Error("Olm is not available"); - } - - const salt = randomString(32); - - const key = await deriveKey(password, salt, DEFAULT_ITERATIONS, DEFAULT_BITSIZE); - - return { key, salt, iterations: DEFAULT_ITERATIONS }; -} - -export async function deriveKey( - password: string, - salt: string, - iterations: number, - numBits = DEFAULT_BITSIZE, -): Promise<Uint8Array> { - if (!subtleCrypto || !TextEncoder) { - throw new Error("Password-based backup is not available on this platform"); - } - - const key = await subtleCrypto.importKey("raw", new TextEncoder().encode(password), { name: "PBKDF2" }, false, [ - "deriveBits", - ]); - - const keybits = await subtleCrypto.deriveBits( - { - name: "PBKDF2", - salt: new TextEncoder().encode(salt), - iterations: iterations, - hash: "SHA-512", - }, - key, - numBits, - ); - - return new Uint8Array(keybits); -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/keybackup.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/keybackup.ts deleted file mode 100644 index 67e213c..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/keybackup.ts +++ /dev/null @@ -1,77 +0,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. -*/ - -import { ISigned } from "../@types/signed"; -import { IEncryptedPayload } from "./aes"; - -export interface Curve25519SessionData { - ciphertext: string; - ephemeral: string; - mac: string; -} - -/* eslint-disable camelcase */ -export interface IKeyBackupSession<T = Curve25519SessionData | IEncryptedPayload> { - first_message_index: number; - forwarded_count: number; - is_verified: boolean; - session_data: T; -} - -export interface IKeyBackupRoomSessions { - [sessionId: string]: IKeyBackupSession; -} - -export interface ICurve25519AuthData { - public_key: string; - private_key_salt?: string; - private_key_iterations?: number; - private_key_bits?: number; -} - -export interface IAes256AuthData { - iv: string; - mac: string; - private_key_salt?: string; - private_key_iterations?: number; -} - -export interface IKeyBackupInfo { - algorithm: string; - auth_data: ISigned & (ICurve25519AuthData | IAes256AuthData); - count?: number; - etag?: string; - version?: string; // number contained within -} -/* eslint-enable camelcase */ - -export interface IKeyBackupPrepareOpts { - /** - * Whether to use Secure Secret Storage to store the key encrypting key backups. - * Optional, defaults to false. - */ - secureSecretStorage: boolean; -} - -export interface IKeyBackupRestoreResult { - total: number; - imported: number; -} - -export interface IKeyBackupRestoreOpts { - cacheCompleteCallback?: () => void; - progressCallback?: (progress: { stage: string }) => void; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/olmlib.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/olmlib.ts deleted file mode 100644 index c37b7f0..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/olmlib.ts +++ /dev/null @@ -1,566 +0,0 @@ -/* -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 - */ - -import anotherjson from "another-json"; - -import type { PkSigning } from "@matrix-org/olm"; -import type { IOneTimeKey } from "../@types/crypto"; -import { OlmDevice } from "./OlmDevice"; -import { DeviceInfo } from "./deviceinfo"; -import { logger } from "../logger"; -import { IClaimOTKsResult, MatrixClient } from "../client"; -import { ISignatures } from "../@types/signed"; -import { MatrixEvent } from "../models/event"; -import { EventType } from "../@types/event"; -import { IMessage } from "./algorithms/olm"; -import { MapWithDefault } from "../utils"; - -enum Algorithm { - Olm = "m.olm.v1.curve25519-aes-sha2", - Megolm = "m.megolm.v1.aes-sha2", - MegolmBackup = "m.megolm_backup.v1.curve25519-aes-sha2", -} - -/** - * matrix algorithm tag for olm - */ -export const OLM_ALGORITHM = Algorithm.Olm; - -/** - * matrix algorithm tag for megolm - */ -export const MEGOLM_ALGORITHM = Algorithm.Megolm; - -/** - * matrix algorithm tag for megolm backups - */ -export const MEGOLM_BACKUP_ALGORITHM = Algorithm.MegolmBackup; - -export interface IOlmSessionResult { - /** device info */ - device: DeviceInfo; - /** base64 olm session id; null if no session could be established */ - sessionId: string | null; -} - -/** - * 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` - */ -export async function encryptMessageForDevice( - resultsObject: Record<string, IMessage>, - ourUserId: string, - ourDeviceId: string | undefined, - olmDevice: OlmDevice, - recipientUserId: string, - recipientDevice: DeviceInfo, - payloadFields: Record<string, any>, -): Promise<void> { - 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.log( - `[olmlib.encryptMessageForDevice] Unable to find Olm session for device ` + - `${recipientUserId}:${recipientDevice.deviceId}`, - ); - return; - } - - logger.log( - `[olmlib.encryptMessageForDevice] Using Olm session ${sessionId} for device ` + - `${recipientUserId}:${recipientDevice.deviceId}`, - ); - - const payload = { - 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)); -} - -interface IExistingOlmSession { - device: DeviceInfo; - sessionId: string | null; -} - -/** - * 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} - */ -export async function getExistingOlmSessions( - olmDevice: OlmDevice, - baseApis: MatrixClient, - devicesByUser: Record<string, DeviceInfo[]>, -): Promise<[Map<string, DeviceInfo[]>, Map<string, Map<string, IExistingOlmSession>>]> { - // map user Id → DeviceInfo[] - const devicesWithoutSession: MapWithDefault<string, DeviceInfo[]> = new MapWithDefault(() => []); - // map user Id → device Id → IExistingOlmSession - const sessions: MapWithDefault<string, Map<string, IExistingOlmSession>> = new MapWithDefault(() => new Map()); - - const promises: Promise<void>[] = []; - - for (const [userId, devices] of Object.entries(devicesByUser)) { - for (const deviceInfo of devices) { - const deviceId = deviceInfo.deviceId; - const key = deviceInfo.getIdentityKey(); - promises.push( - (async (): Promise<void> => { - 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} - */ -export async function ensureOlmSessionsForDevices( - olmDevice: OlmDevice, - baseApis: MatrixClient, - devicesByUser: Map<string, DeviceInfo[]>, - force = false, - otkTimeout?: number, - failedServers?: string[], - log = logger, -): Promise<Map<string, Map<string, IOlmSessionResult>>> { - const devicesWithoutSession: [string, string][] = [ - // [userId, deviceId], ... - ]; - // map user Id → device Id → IExistingOlmSession - const result: Map<string, Map<string, IExistingOlmSession>> = new Map(); - // map device key → resolve session fn - const resolveSession: Map<string, (sessionId?: string) => void> = 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: any): void => { - 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: IClaimOTKsResult; - 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 || ({} as IClaimOTKsResult["one_time_keys"]); - const promises: Promise<void>[] = []; - 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: IOneTimeKey | null = 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: OlmDevice, - oneTimeKey: IOneTimeKey, - userId: string, - deviceInfo: DeviceInfo, -): Promise<string | null> { - const deviceId = deviceInfo.deviceId; - try { - await verifySignature(olmDevice, oneTimeKey, userId, deviceId, deviceInfo.getFingerprint()); - } catch (e) { - logger.error("Unable to verify signature on one-time key for device " + userId + ":" + deviceId + ":", e); - return null; - } - - let sid; - try { - sid = await olmDevice.createOutboundSession(deviceInfo.getIdentityKey(), oneTimeKey.key); - } catch (e) { - // possibly a bad key - logger.error("Error starting olm session with device " + userId + ":" + deviceId + ": " + e); - return null; - } - - logger.log("Started new olm sessionid " + sid + " for device " + userId + ":" + deviceId); - return sid; -} - -export interface IObject { - unsigned?: object; - signatures?: ISignatures; -} - -/** - * 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. - */ -export async function verifySignature( - olmDevice: OlmDevice, - obj: IOneTimeKey | IObject, - signingUserId: string, - signingDeviceId: string, - signingKey: string, -): Promise<void> { - 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.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 - */ -export function pkSign(obj: object & IObject, key: Uint8Array | PkSigning, userId: string, pubKey: string): string { - let createdKey = false; - if (key instanceof Uint8Array) { - const keyObj = new global.Olm.PkSigning(); - pubKey = keyObj.init_with_seed(key); - key = keyObj; - createdKey = true; - } - const sigs = obj.signatures || {}; - delete obj.signatures; - const unsigned = obj.unsigned; - if (obj.unsigned) delete obj.unsigned; - try { - const mysigs = sigs[userId] || {}; - sigs[userId] = mysigs; - - return (mysigs["ed25519:" + pubKey] = key.sign(anotherjson.stringify(obj))); - } finally { - obj.signatures = sigs; - if (unsigned) obj.unsigned = unsigned; - if (createdKey) { - key.free(); - } - } -} - -/** - * Verify a signed JSON object - * @param obj - Object to verify - * @param pubKey - The public key to use to verify - * @param userId - The user ID who signed the object - */ -export function pkVerify(obj: IObject, pubKey: string, userId: string): void { - const keyId = "ed25519:" + pubKey; - if (!(obj.signatures && obj.signatures[userId] && obj.signatures[userId][keyId])) { - throw new Error("No signature"); - } - const signature = obj.signatures[userId][keyId]; - const util = new global.Olm.Utility(); - const sigs = obj.signatures; - delete obj.signatures; - const unsigned = obj.unsigned; - if (obj.unsigned) delete obj.unsigned; - try { - util.ed25519_verify(pubKey, anotherjson.stringify(obj), signature); - } finally { - obj.signatures = sigs; - if (unsigned) obj.unsigned = unsigned; - util.free(); - } -} - -/** - * Check that an event was encrypted using olm. - */ -export function isOlmEncrypted(event: MatrixEvent): boolean { - if (!event.getSenderKey()) { - logger.error("Event has no sender key (not encrypted?)"); - return false; - } - if ( - event.getWireType() !== EventType.RoomMessageEncrypted || - !["m.olm.v1.curve25519-aes-sha2"].includes(event.getWireContent().algorithm) - ) { - 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. - */ -export function encodeBase64(uint8Array: ArrayBuffer | Uint8Array): string { - 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. - */ -export function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string { - 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. - */ -export function decodeBase64(base64: string): Uint8Array { - return Buffer.from(base64, "base64"); -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/recoverykey.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/recoverykey.ts deleted file mode 100644 index 4107b76..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/recoverykey.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* -Copyright 2018 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import * as bs58 from "bs58"; - -// picked arbitrarily but to try & avoid clashing with any bitcoin ones -// (which are also base58 encoded, but bitcoin's involve a lot more hashing) -const OLM_RECOVERY_KEY_PREFIX = [0x8b, 0x01]; - -export function encodeRecoveryKey(key: ArrayLike<number>): string | undefined { - 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(" "); -} - -export function decodeRecoveryKey(recoveryKey: string): Uint8Array { - 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), - ); -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/base.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/base.ts deleted file mode 100644 index 4c88ec2..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/base.ts +++ /dev/null @@ -1,226 +0,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. -*/ - -import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index"; -import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager"; -import { ICrossSigningKey } from "../../client"; -import { IOlmDevice } from "../algorithms/megolm"; -import { TrackingStatus } from "../DeviceList"; -import { IRoomEncryption } from "../RoomList"; -import { IDevice } from "../deviceinfo"; -import { ICrossSigningInfo } from "../CrossSigning"; -import { PrefixedLogger } from "../../logger"; -import { InboundGroupSessionData } from "../OlmDevice"; -import { MatrixEvent } from "../../models/event"; -import { DehydrationManager } from "../dehydration"; -import { IEncryptedPayload } from "../aes"; - -/** - * Internal module. Definitions for storage for the crypto module - */ - -export interface SecretStorePrivateKeys { - "dehydration": { - keyInfo: DehydrationManager["keyInfo"]; - key: IEncryptedPayload; - deviceDisplayName: string; - time: number; - } | null; - "m.megolm_backup.v1": IEncryptedPayload; -} - -/** - * Abstraction of things that can store data required for end-to-end encryption - */ -export interface CryptoStore { - startup(): Promise<CryptoStore>; - deleteAllData(): Promise<void>; - getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest>; - getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null>; - getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null>; - getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]>; - getOutgoingRoomKeyRequestsByTarget( - userId: string, - deviceId: string, - wantedStates: number[], - ): Promise<OutgoingRoomKeyRequest[]>; - updateOutgoingRoomKeyRequest( - requestId: string, - expectedState: number, - updates: Partial<OutgoingRoomKeyRequest>, - ): Promise<OutgoingRoomKeyRequest | null>; - deleteOutgoingRoomKeyRequest(requestId: string, expectedState: number): Promise<OutgoingRoomKeyRequest | null>; - - // Olm Account - getAccount(txn: unknown, func: (accountPickle: string | null) => void): void; - storeAccount(txn: unknown, accountPickle: string): void; - getCrossSigningKeys(txn: unknown, func: (keys: Record<string, ICrossSigningKey> | null) => void): void; - getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>( - txn: unknown, - func: (key: SecretStorePrivateKeys[K] | null) => void, - type: K, - ): void; - storeCrossSigningKeys(txn: unknown, keys: Record<string, ICrossSigningKey>): void; - storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>( - txn: unknown, - type: K, - key: SecretStorePrivateKeys[K], - ): void; - - // Olm Sessions - countEndToEndSessions(txn: unknown, func: (count: number) => void): void; - getEndToEndSession( - deviceKey: string, - sessionId: string, - txn: unknown, - func: (session: ISessionInfo | null) => void, - ): void; - getEndToEndSessions( - deviceKey: string, - txn: unknown, - func: (sessions: { [sessionId: string]: ISessionInfo }) => void, - ): void; - getAllEndToEndSessions(txn: unknown, func: (session: ISessionInfo | null) => void): void; - storeEndToEndSession(deviceKey: string, sessionId: string, sessionInfo: ISessionInfo, txn: unknown): void; - storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void>; - getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null>; - filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]>; - - // Inbound Group Sessions - getEndToEndInboundGroupSession( - senderCurve25519Key: string, - sessionId: string, - txn: unknown, - func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void, - ): void; - getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void; - addEndToEndInboundGroupSession( - senderCurve25519Key: string, - sessionId: string, - sessionData: InboundGroupSessionData, - txn: unknown, - ): void; - storeEndToEndInboundGroupSession( - senderCurve25519Key: string, - sessionId: string, - sessionData: InboundGroupSessionData, - txn: unknown, - ): void; - storeEndToEndInboundGroupSessionWithheld( - senderCurve25519Key: string, - sessionId: string, - sessionData: IWithheld, - txn: unknown, - ): void; - - // Device Data - getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void; - storeEndToEndDeviceData(deviceData: IDeviceData, txn: unknown): void; - storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: unknown): void; - getEndToEndRooms(txn: unknown, func: (rooms: Record<string, IRoomEncryption>) => void): void; - getSessionsNeedingBackup(limit: number): Promise<ISession[]>; - countSessionsNeedingBackup(txn?: unknown): Promise<number>; - unmarkSessionsNeedingBackup(sessions: ISession[], txn?: unknown): Promise<void>; - markSessionsNeedingBackup(sessions: ISession[], txn?: unknown): Promise<void>; - addSharedHistoryInboundGroupSession(roomId: string, senderKey: string, sessionId: string, txn?: unknown): void; - getSharedHistoryInboundGroupSessions( - roomId: string, - txn?: unknown, - ): Promise<[senderKey: string, sessionId: string][]>; - addParkedSharedHistory(roomId: string, data: ParkedSharedHistory, txn?: unknown): void; - takeParkedSharedHistory(roomId: string, txn?: unknown): Promise<ParkedSharedHistory[]>; - - // Session key backups - doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn: unknown) => T, log?: PrefixedLogger): Promise<T>; -} - -export type Mode = "readonly" | "readwrite"; - -export interface ISession { - senderKey: string; - sessionId: string; - sessionData?: InboundGroupSessionData; -} - -export interface ISessionInfo { - deviceKey?: string; - sessionId?: string; - session?: string; - lastReceivedMessageTs?: number; -} - -export interface IDeviceData { - devices: { - [userId: string]: { - [deviceId: string]: IDevice; - }; - }; - trackingStatus: { - [userId: string]: TrackingStatus; - }; - crossSigningInfo?: Record<string, ICrossSigningInfo>; - syncToken?: string; -} - -export interface IProblem { - type: string; - fixed: boolean; - time: number; -} - -export interface IWithheld { - // eslint-disable-next-line camelcase - room_id: string; - code: string; - reason: string; -} - -/** - * Represents an outgoing room key request - */ -export interface OutgoingRoomKeyRequest { - /** - * Unique id for this request. Used for both an id within the request for later pairing with a cancellation, - * and for the transaction id when sending the to_device messages to our local server. - */ - requestId: string; - requestTxnId?: string; - /** - * Transaction id for the cancellation, if any - */ - cancellationTxnId?: string; - /** - * List of recipients for the request - */ - recipients: IRoomKeyRequestRecipient[]; - /** - * Parameters for the request - */ - requestBody: IRoomKeyRequestBody; - /** - * current state of this request (states are defined in {@link OutgoingRoomKeyRequestManager}) - */ - state: RoomKeyRequestState; -} - -export interface ParkedSharedHistory { - senderId: string; - senderKey: string; - sessionId: string; - sessionKey: string; - keysClaimed: ReturnType<MatrixEvent["getKeysClaimed"]>; // XXX: Less type dependence on MatrixEvent - forwardingCurve25519KeyChain: string[]; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store-backend.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store-backend.ts deleted file mode 100644 index 7827697..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store-backend.ts +++ /dev/null @@ -1,1062 +0,0 @@ -/* -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. -*/ - -import { logger, PrefixedLogger } from "../../logger"; -import * as utils from "../../utils"; -import { - CryptoStore, - IDeviceData, - IProblem, - ISession, - ISessionInfo, - IWithheld, - Mode, - OutgoingRoomKeyRequest, - ParkedSharedHistory, - SecretStorePrivateKeys, -} from "./base"; -import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index"; -import { ICrossSigningKey } from "../../client"; -import { IOlmDevice } from "../algorithms/megolm"; -import { IRoomEncryption } from "../RoomList"; -import { InboundGroupSessionData } from "../OlmDevice"; - -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. - */ -export class Backend implements CryptoStore { - private nextTxnId = 0; - - /** - */ - public constructor(private db: IDBDatabase) { - // 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 = (): void => { - logger.log(`versionchange for indexeddb ${this.db.name}: closing`); - db.close(); - }; - } - - public async startup(): Promise<CryptoStore> { - // No work to do, as the startup is done by the caller (e.g IndexedDBCryptoStore) - // by passing us a ready IDBDatabase instance - return this; - } - public async deleteAllData(): Promise<void> { - 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. - */ - public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest> { - const requestBody = request.requestBody; - - return new Promise((resolve, reject) => { - const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite"); - txn.onerror = reject; - - // first see if we already have an entry for this request. - this._getOutgoingRoomKeyRequest(txn, requestBody, (existing) => { - if (existing) { - // this entry matches the request - return it. - logger.log( - `already have key request outstanding for ` + - `${requestBody.room_id} / ${requestBody.session_id}: ` + - `not sending another`, - ); - resolve(existing); - return; - } - - // we got to the end of the list without finding a match - // - add the new request. - logger.log(`enqueueing key request for ${requestBody.room_id} / ` + requestBody.session_id); - txn.oncomplete = (): void => { - 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 - */ - public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null> { - 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 - private _getOutgoingRoomKeyRequest( - txn: IDBTransaction, - requestBody: IRoomKeyRequestBody, - callback: (req: OutgoingRoomKeyRequest | null) => void, - ): void { - const store = txn.objectStore("outgoingRoomKeyRequests"); - - const idx = store.index("session"); - const cursorReq = idx.openCursor([requestBody.room_id, requestBody.session_id]); - - cursorReq.onsuccess = (): void => { - const cursor = cursorReq.result; - if (!cursor) { - // no match found - callback(null); - return; - } - - const existing = cursor.value; - - if (utils.deepCompare(existing.requestBody, requestBody)) { - // got a match - callback(existing); - return; - } - - // look at the next entry in the index - cursor.continue(); - }; - } - - /** - * Look for room key requests by state - * - * @param 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. - */ - public getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null> { - 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: OutgoingRoomKeyRequest; - - function onsuccess(this: IDBRequest<IDBCursorWithValue | null>): void { - 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 as IDBIndex).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 - */ - public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]> { - 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 = (): void => resolve(request.result); - request.onerror = (): void => reject(request.error); - }); - } - - public getOutgoingRoomKeyRequestsByTarget( - userId: string, - deviceId: string, - wantedStates: number[], - ): Promise<OutgoingRoomKeyRequest[]> { - let stateIndex = 0; - const results: OutgoingRoomKeyRequest[] = []; - - function onsuccess(this: IDBRequest<IDBCursorWithValue | null>): void { - const cursor = this.result; - if (cursor) { - const keyReq = cursor.value; - if ( - keyReq.recipients.some( - (recipient: IRoomKeyRequestRecipient) => - 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 as IDBIndex).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 - */ - public updateOutgoingRoomKeyRequest( - requestId: string, - expectedState: number, - updates: Partial<OutgoingRoomKeyRequest>, - ): Promise<OutgoingRoomKeyRequest | null> { - let result: OutgoingRoomKeyRequest | null = null; - - function onsuccess(this: IDBRequest<IDBCursorWithValue | null>): void { - const cursor = this.result; - if (!cursor) { - return; - } - const data = cursor.value; - if (data.state != expectedState) { - logger.warn( - `Cannot update room key request from ${expectedState} ` + - `as it was already updated to ${data.state}`, - ); - return; - } - Object.assign(data, updates); - cursor.update(data); - result = data; - } - - const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite"); - const cursorReq = txn.objectStore("outgoingRoomKeyRequests").openCursor(requestId); - cursorReq.onsuccess = onsuccess; - return promiseifyTxn(txn).then(() => result); - } - - /** - * Look for an existing room key request by id and state, and delete it if - * found - * - * @param requestId - ID of request to update - * @param expectedState - state we expect to find the request in - * - * @returns resolves once the operation is completed - */ - public deleteOutgoingRoomKeyRequest( - requestId: string, - expectedState: number, - ): Promise<OutgoingRoomKeyRequest | null> { - const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite"); - const cursorReq = txn.objectStore("outgoingRoomKeyRequests").openCursor(requestId); - cursorReq.onsuccess = (): void => { - const cursor = cursorReq.result; - if (!cursor) { - return; - } - const data = cursor.value; - if (data.state != expectedState) { - logger.warn(`Cannot delete room key request in state ${data.state} ` + `(expected ${expectedState})`); - return; - } - cursor.delete(); - }; - return promiseifyTxn<OutgoingRoomKeyRequest | null>(txn); - } - - // Olm Account - - public getAccount(txn: IDBTransaction, func: (accountPickle: string | null) => void): void { - const objectStore = txn.objectStore("account"); - const getReq = objectStore.get("-"); - getReq.onsuccess = function (): void { - try { - func(getReq.result || null); - } catch (e) { - abortWithException(txn, <Error>e); - } - }; - } - - public storeAccount(txn: IDBTransaction, accountPickle: string): void { - const objectStore = txn.objectStore("account"); - objectStore.put(accountPickle, "-"); - } - - public getCrossSigningKeys( - txn: IDBTransaction, - func: (keys: Record<string, ICrossSigningKey> | null) => void, - ): void { - const objectStore = txn.objectStore("account"); - const getReq = objectStore.get("crossSigningKeys"); - getReq.onsuccess = function (): void { - try { - func(getReq.result || null); - } catch (e) { - abortWithException(txn, <Error>e); - } - }; - } - - public getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>( - txn: IDBTransaction, - func: (key: SecretStorePrivateKeys[K] | null) => void, - type: K, - ): void { - const objectStore = txn.objectStore("account"); - const getReq = objectStore.get(`ssss_cache:${type}`); - getReq.onsuccess = function (): void { - try { - func(getReq.result || null); - } catch (e) { - abortWithException(txn, <Error>e); - } - }; - } - - public storeCrossSigningKeys(txn: IDBTransaction, keys: Record<string, ICrossSigningKey>): void { - const objectStore = txn.objectStore("account"); - objectStore.put(keys, "crossSigningKeys"); - } - - public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>( - txn: IDBTransaction, - type: K, - key: SecretStorePrivateKeys[K], - ): void { - const objectStore = txn.objectStore("account"); - objectStore.put(key, `ssss_cache:${type}`); - } - - // Olm Sessions - - public countEndToEndSessions(txn: IDBTransaction, func: (count: number) => void): void { - const objectStore = txn.objectStore("sessions"); - const countReq = objectStore.count(); - countReq.onsuccess = function (): void { - try { - func(countReq.result); - } catch (e) { - abortWithException(txn, <Error>e); - } - }; - } - - public getEndToEndSessions( - deviceKey: string, - txn: IDBTransaction, - func: (sessions: { [sessionId: string]: ISessionInfo }) => void, - ): void { - const objectStore = txn.objectStore("sessions"); - const idx = objectStore.index("deviceKey"); - const getReq = idx.openCursor(deviceKey); - const results: Parameters<Parameters<Backend["getEndToEndSessions"]>[2]>[0] = {}; - getReq.onsuccess = function (): void { - 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, <Error>e); - } - } - }; - } - - public getEndToEndSession( - deviceKey: string, - sessionId: string, - txn: IDBTransaction, - func: (session: ISessionInfo | null) => void, - ): void { - const objectStore = txn.objectStore("sessions"); - const getReq = objectStore.get([deviceKey, sessionId]); - getReq.onsuccess = function (): void { - try { - if (getReq.result) { - func({ - session: getReq.result.session, - lastReceivedMessageTs: getReq.result.lastReceivedMessageTs, - }); - } else { - func(null); - } - } catch (e) { - abortWithException(txn, <Error>e); - } - }; - } - - public getAllEndToEndSessions(txn: IDBTransaction, func: (session: ISessionInfo | null) => void): void { - const objectStore = txn.objectStore("sessions"); - const getReq = objectStore.openCursor(); - getReq.onsuccess = function (): void { - try { - const cursor = getReq.result; - if (cursor) { - func(cursor.value); - cursor.continue(); - } else { - func(null); - } - } catch (e) { - abortWithException(txn, <Error>e); - } - }; - } - - public storeEndToEndSession( - deviceKey: string, - sessionId: string, - sessionInfo: ISessionInfo, - txn: IDBTransaction, - ): void { - const objectStore = txn.objectStore("sessions"); - objectStore.put({ - deviceKey, - sessionId, - session: sessionInfo.session, - lastReceivedMessageTs: sessionInfo.lastReceivedMessageTs, - }); - } - - public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> { - 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); - } - - public async getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> { - let result: IProblem | null = 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 = (): void => { - 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 - public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> { - const txn = this.db.transaction("notified_error_devices", "readwrite"); - const objectStore = txn.objectStore("notified_error_devices"); - - const ret: IOlmDevice[] = []; - - await Promise.all( - devices.map((device) => { - return new Promise<void>((resolve) => { - const { userId, deviceInfo } = device; - const getReq = objectStore.get([userId, deviceInfo.deviceId]); - getReq.onsuccess = function (): void { - if (!getReq.result) { - objectStore.put({ userId, deviceId: deviceInfo.deviceId }); - ret.push(device); - } - resolve(); - }; - }); - }), - ); - - return ret; - } - - // Inbound group sessions - - public getEndToEndInboundGroupSession( - senderCurve25519Key: string, - sessionId: string, - txn: IDBTransaction, - func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void, - ): void { - let session: InboundGroupSessionData | null | boolean = false; - let withheld: IWithheld | null | boolean = false; - const objectStore = txn.objectStore("inbound_group_sessions"); - const getReq = objectStore.get([senderCurve25519Key, sessionId]); - getReq.onsuccess = function (): void { - try { - if (getReq.result) { - session = getReq.result.session; - } else { - session = null; - } - if (withheld !== false) { - func(session as InboundGroupSessionData, withheld as IWithheld); - } - } catch (e) { - abortWithException(txn, <Error>e); - } - }; - - const withheldObjectStore = txn.objectStore("inbound_group_sessions_withheld"); - const withheldGetReq = withheldObjectStore.get([senderCurve25519Key, sessionId]); - withheldGetReq.onsuccess = function (): void { - try { - if (withheldGetReq.result) { - withheld = withheldGetReq.result.session; - } else { - withheld = null; - } - if (session !== false) { - func(session as InboundGroupSessionData, withheld as IWithheld); - } - } catch (e) { - abortWithException(txn, <Error>e); - } - }; - } - - public getAllEndToEndInboundGroupSessions(txn: IDBTransaction, func: (session: ISession | null) => void): void { - const objectStore = txn.objectStore("inbound_group_sessions"); - const getReq = objectStore.openCursor(); - getReq.onsuccess = function (): void { - const cursor = getReq.result; - if (cursor) { - try { - func({ - senderKey: cursor.value.senderCurve25519Key, - sessionId: cursor.value.sessionId, - sessionData: cursor.value.session, - }); - } catch (e) { - abortWithException(txn, <Error>e); - } - cursor.continue(); - } else { - try { - func(null); - } catch (e) { - abortWithException(txn, <Error>e); - } - } - }; - } - - public addEndToEndInboundGroupSession( - senderCurve25519Key: string, - sessionId: string, - sessionData: InboundGroupSessionData, - txn: IDBTransaction, - ): void { - const objectStore = txn.objectStore("inbound_group_sessions"); - const addReq = objectStore.add({ - senderCurve25519Key, - sessionId, - session: sessionData, - }); - addReq.onerror = (ev): void => { - if (addReq.error?.name === "ConstraintError") { - // This stops the error from triggering the txn's onerror - ev.stopPropagation(); - // ...and this stops it from aborting the transaction - ev.preventDefault(); - logger.log("Ignoring duplicate inbound group session: " + senderCurve25519Key + " / " + sessionId); - } else { - abortWithException(txn, new Error("Failed to add inbound group session: " + addReq.error)); - } - }; - } - - public storeEndToEndInboundGroupSession( - senderCurve25519Key: string, - sessionId: string, - sessionData: InboundGroupSessionData, - txn: IDBTransaction, - ): void { - const objectStore = txn.objectStore("inbound_group_sessions"); - objectStore.put({ - senderCurve25519Key, - sessionId, - session: sessionData, - }); - } - - public storeEndToEndInboundGroupSessionWithheld( - senderCurve25519Key: string, - sessionId: string, - sessionData: IWithheld, - txn: IDBTransaction, - ): void { - const objectStore = txn.objectStore("inbound_group_sessions_withheld"); - objectStore.put({ - senderCurve25519Key, - sessionId, - session: sessionData, - }); - } - - public getEndToEndDeviceData(txn: IDBTransaction, func: (deviceData: IDeviceData | null) => void): void { - const objectStore = txn.objectStore("device_data"); - const getReq = objectStore.get("-"); - getReq.onsuccess = function (): void { - try { - func(getReq.result || null); - } catch (e) { - abortWithException(txn, <Error>e); - } - }; - } - - public storeEndToEndDeviceData(deviceData: IDeviceData, txn: IDBTransaction): void { - const objectStore = txn.objectStore("device_data"); - objectStore.put(deviceData, "-"); - } - - public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: IDBTransaction): void { - const objectStore = txn.objectStore("rooms"); - objectStore.put(roomInfo, roomId); - } - - public getEndToEndRooms(txn: IDBTransaction, func: (rooms: Record<string, IRoomEncryption>) => void): void { - const rooms: Parameters<Parameters<Backend["getEndToEndRooms"]>[1]>[0] = {}; - const objectStore = txn.objectStore("rooms"); - const getReq = objectStore.openCursor(); - getReq.onsuccess = function (): void { - const cursor = getReq.result; - if (cursor) { - rooms[cursor.key as string] = cursor.value; - cursor.continue(); - } else { - try { - func(rooms); - } catch (e) { - abortWithException(txn, <Error>e); - } - } - }; - } - - // session backups - - public getSessionsNeedingBackup(limit: number): Promise<ISession[]> { - return new Promise((resolve, reject) => { - const sessions: ISession[] = []; - - const txn = this.db.transaction(["sessions_needing_backup", "inbound_group_sessions"], "readonly"); - txn.onerror = reject; - txn.oncomplete = function (): void { - resolve(sessions); - }; - const objectStore = txn.objectStore("sessions_needing_backup"); - const sessionStore = txn.objectStore("inbound_group_sessions"); - const getReq = objectStore.openCursor(); - getReq.onsuccess = function (): void { - const cursor = getReq.result; - if (cursor) { - const sessionGetReq = sessionStore.get(cursor.key); - sessionGetReq.onsuccess = function (): void { - sessions.push({ - senderKey: sessionGetReq.result.senderCurve25519Key, - sessionId: sessionGetReq.result.sessionId, - sessionData: sessionGetReq.result.session, - }); - }; - if (!limit || sessions.length < limit) { - cursor.continue(); - } - } - }; - }); - } - - public countSessionsNeedingBackup(txn?: IDBTransaction): Promise<number> { - 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 = (): void => resolve(req.result); - }); - } - - public async unmarkSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise<void> { - 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; - }); - }), - ); - } - - public async markSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise<void> { - 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; - }); - }), - ); - } - - public addSharedHistoryInboundGroupSession( - roomId: string, - senderKey: string, - sessionId: string, - txn?: IDBTransaction, - ): void { - 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 = (): void => { - const { sessions } = req.result || { sessions: [] }; - sessions.push([senderKey, sessionId]); - objectStore.put({ roomId, sessions }); - }; - } - - public getSharedHistoryInboundGroupSessions( - roomId: string, - txn?: IDBTransaction, - ): Promise<[senderKey: string, sessionId: string][]> { - 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 = (): void => { - const { sessions } = req.result || { sessions: [] }; - resolve(sessions); - }; - req.onerror = reject; - }); - } - - public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory, txn?: IDBTransaction): void { - if (!txn) { - txn = this.db.transaction("parked_shared_history", "readwrite"); - } - const objectStore = txn.objectStore("parked_shared_history"); - const req = objectStore.get([roomId]); - req.onsuccess = (): void => { - const { parked } = req.result || { parked: [] }; - parked.push(parkedData); - objectStore.put({ roomId, parked }); - }; - } - - public takeParkedSharedHistory(roomId: string, txn?: IDBTransaction): Promise<ParkedSharedHistory[]> { - 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 = (): void => { - const cursor = cursorReq.result; - if (!cursor) { - resolve([]); - return; - } - const data = cursor.value; - cursor.delete(); - resolve(data); - }; - cursorReq.onerror = reject; - }); - } - - public doTxn<T>( - mode: Mode, - stores: string | string[], - func: (txn: IDBTransaction) => T, - log: PrefixedLogger = logger, - ): Promise<T> { - let startTime: number; - let description: string; - 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; - }); - } -} - -type DbMigration = (db: IDBDatabase) => void; -const DB_MIGRATIONS: DbMigration[] = [ - (db): void => { - createDatabase(db); - }, - (db): void => { - db.createObjectStore("account"); - }, - (db): void => { - const sessionsStore = db.createObjectStore("sessions", { - keyPath: ["deviceKey", "sessionId"], - }); - sessionsStore.createIndex("deviceKey", "deviceKey"); - }, - (db): void => { - db.createObjectStore("inbound_group_sessions", { - keyPath: ["senderCurve25519Key", "sessionId"], - }); - }, - (db): void => { - db.createObjectStore("device_data"); - }, - (db): void => { - db.createObjectStore("rooms"); - }, - (db): void => { - db.createObjectStore("sessions_needing_backup", { - keyPath: ["senderCurve25519Key", "sessionId"], - }); - }, - (db): void => { - db.createObjectStore("inbound_group_sessions_withheld", { - keyPath: ["senderCurve25519Key", "sessionId"], - }); - }, - (db): void => { - const problemsStore = db.createObjectStore("session_problems", { - keyPath: ["deviceKey", "time"], - }); - problemsStore.createIndex("deviceKey", "deviceKey"); - - db.createObjectStore("notified_error_devices", { - keyPath: ["userId", "deviceId"], - }); - }, - (db): void => { - db.createObjectStore("shared_history_inbound_group_sessions", { - keyPath: ["roomId"], - }); - }, - (db): void => { - db.createObjectStore("parked_shared_history", { - keyPath: ["roomId"], - }); - }, - // Expand as needed. -]; -export const VERSION = DB_MIGRATIONS.length; - -export function upgradeDatabase(db: IDBDatabase, oldVersion: number): void { - logger.log(`Upgrading IndexedDBCryptoStore from version ${oldVersion}` + ` to ${VERSION}`); - DB_MIGRATIONS.forEach((migration, index) => { - if (oldVersion <= index) migration(db); - }); -} - -function createDatabase(db: IDBDatabase): void { - 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"); -} - -interface IWrappedIDBTransaction extends IDBTransaction { - _mx_abortexception: Error; // eslint-disable-line camelcase -} - -/* - * Aborts a transaction with a given exception - * The transaction promise will be rejected with this exception. - */ -function abortWithException(txn: IDBTransaction, e: Error): void { - // 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 as IWrappedIDBTransaction)._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<T>(txn: IDBTransaction): Promise<T | null> { - return new Promise((resolve, reject) => { - txn.oncomplete = (): void => { - if ((txn as IWrappedIDBTransaction)._mx_abortexception !== undefined) { - reject((txn as IWrappedIDBTransaction)._mx_abortexception); - } - resolve(null); - }; - txn.onerror = (event): void => { - if ((txn as IWrappedIDBTransaction)._mx_abortexception !== undefined) { - reject((txn as IWrappedIDBTransaction)._mx_abortexception); - } else { - logger.log("Error performing indexeddb txn", event); - reject(txn.error); - } - }; - txn.onabort = (event): void => { - if ((txn as IWrappedIDBTransaction)._mx_abortexception !== undefined) { - reject((txn as IWrappedIDBTransaction)._mx_abortexception); - } else { - logger.log("Error performing indexeddb txn", event); - reject(txn.error); - } - }; - }); -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store.ts deleted file mode 100644 index 320235f..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store.ts +++ /dev/null @@ -1,708 +0,0 @@ -/* -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. -*/ - -import { logger, PrefixedLogger } from "../../logger"; -import { LocalStorageCryptoStore } from "./localStorage-crypto-store"; -import { MemoryCryptoStore } from "./memory-crypto-store"; -import * as IndexedDBCryptoStoreBackend from "./indexeddb-crypto-store-backend"; -import { InvalidCryptoStoreError, InvalidCryptoStoreState } from "../../errors"; -import * as IndexedDBHelpers from "../../indexeddb-helpers"; -import { - CryptoStore, - IDeviceData, - IProblem, - ISession, - ISessionInfo, - IWithheld, - Mode, - OutgoingRoomKeyRequest, - ParkedSharedHistory, - SecretStorePrivateKeys, -} from "./base"; -import { IRoomKeyRequestBody } from "../index"; -import { ICrossSigningKey } from "../../client"; -import { IOlmDevice } from "../algorithms/megolm"; -import { IRoomEncryption } from "../RoomList"; -import { InboundGroupSessionData } from "../OlmDevice"; - -/** - * Internal module. indexeddb storage for e2e. - */ - -/** - * An implementation of CryptoStore, which is normally backed by an indexeddb, - * but with fallback to MemoryCryptoStore. - */ -export class IndexedDBCryptoStore implements CryptoStore { - public static STORE_ACCOUNT = "account"; - public static STORE_SESSIONS = "sessions"; - public static STORE_INBOUND_GROUP_SESSIONS = "inbound_group_sessions"; - public static STORE_INBOUND_GROUP_SESSIONS_WITHHELD = "inbound_group_sessions_withheld"; - public static STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS = "shared_history_inbound_group_sessions"; - public static STORE_PARKED_SHARED_HISTORY = "parked_shared_history"; - public static STORE_DEVICE_DATA = "device_data"; - public static STORE_ROOMS = "rooms"; - public static STORE_BACKUP = "sessions_needing_backup"; - - public static exists(indexedDB: IDBFactory, dbName: string): Promise<boolean> { - return IndexedDBHelpers.exists(indexedDB, dbName); - } - - private backendPromise?: Promise<CryptoStore>; - private backend?: CryptoStore; - - /** - * Create a new IndexedDBCryptoStore - * - * @param indexedDB - global indexedDB instance - * @param dbName - name of db to connect to - */ - public constructor(private readonly indexedDB: IDBFactory, private readonly dbName: string) {} - - /** - * 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 - */ - public startup(): Promise<CryptoStore> { - if (this.backendPromise) { - return this.backendPromise; - } - - this.backendPromise = new Promise<CryptoStore>((resolve, reject) => { - if (!this.indexedDB) { - reject(new Error("no indexeddb support available")); - return; - } - - logger.log(`connecting to indexeddb ${this.dbName}`); - - const req = this.indexedDB.open(this.dbName, IndexedDBCryptoStoreBackend.VERSION); - - req.onupgradeneeded = (ev): void => { - const db = req.result; - const oldVersion = ev.oldVersion; - IndexedDBCryptoStoreBackend.upgradeDatabase(db, oldVersion); - }; - - req.onblocked = (): void => { - logger.log(`can't yet open IndexedDBCryptoStore because it is open elsewhere`); - }; - - req.onerror = (ev): void => { - logger.log("Error connecting to indexeddb", ev); - reject(req.error); - }; - - req.onsuccess = (): void => { - const db = req.result; - - logger.log(`connected to indexeddb ${this.dbName}`); - resolve(new IndexedDBCryptoStoreBackend.Backend(db)); - }; - }) - .then((backend) => { - // Edge has IndexedDB but doesn't support compund keys which we use fairly extensively. - // Try a dummy query which will fail if the browser doesn't support compund keys, so - // we can fall back to a different backend. - return backend - .doTxn( - "readonly", - [ - IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, - IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, - ], - (txn) => { - backend.getEndToEndInboundGroupSession("", "", txn, () => {}); - }, - ) - .then(() => backend); - }) - .catch((e) => { - if (e.name === "VersionError") { - logger.warn("Crypto DB is too new for us to use!", e); - // don't fall back to a different store: the user has crypto data - // in this db so we should use it or nothing at all. - throw new InvalidCryptoStoreError(InvalidCryptoStoreState.TooNew); - } - logger.warn( - `unable to connect to indexeddb ${this.dbName}` + `: falling back to localStorage store: ${e}`, - ); - - try { - return new LocalStorageCryptoStore(global.localStorage); - } catch (e) { - logger.warn(`unable to open localStorage: falling back to in-memory store: ${e}`); - return new MemoryCryptoStore(); - } - }) - .then((backend) => { - this.backend = backend; - return backend; - }); - - return this.backendPromise; - } - - /** - * Delete all data from this store. - * - * @returns resolves when the store has been cleared. - */ - public deleteAllData(): Promise<void> { - return new Promise<void>((resolve, reject) => { - if (!this.indexedDB) { - reject(new Error("no indexeddb support available")); - return; - } - - logger.log(`Removing indexeddb instance: ${this.dbName}`); - const req = this.indexedDB.deleteDatabase(this.dbName); - - req.onblocked = (): void => { - logger.log(`can't yet delete IndexedDBCryptoStore because it is open elsewhere`); - }; - - req.onerror = (ev): void => { - logger.log("Error deleting data from indexeddb", ev); - reject(req.error); - }; - - req.onsuccess = (): void => { - logger.log(`Removed indexeddb instance: ${this.dbName}`); - resolve(); - }; - }).catch((e) => { - // in firefox, with indexedDB disabled, this fails with a - // DOMError. We treat this as non-fatal, so that people can - // still use the app. - logger.warn(`unable to delete IndexedDBCryptoStore: ${e}`); - }); - } - - /** - * Look for an existing outgoing room key request, and if none is found, - * add a new one - * - * - * @returns resolves to - * {@link OutgoingRoomKeyRequest}: either the - * same instance as passed in, or the existing one. - */ - public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest> { - 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 - */ - public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null> { - 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. - */ - public getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null> { - 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 - */ - public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]> { - 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} - */ - public getOutgoingRoomKeyRequestsByTarget( - userId: string, - deviceId: string, - wantedStates: number[], - ): Promise<OutgoingRoomKeyRequest[]> { - 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 - */ - public updateOutgoingRoomKeyRequest( - requestId: string, - expectedState: number, - updates: Partial<OutgoingRoomKeyRequest>, - ): Promise<OutgoingRoomKeyRequest | null> { - 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 - */ - public deleteOutgoingRoomKeyRequest( - requestId: string, - expectedState: number, - ): Promise<OutgoingRoomKeyRequest | null> { - 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 - */ - public getAccount(txn: IDBTransaction, func: (accountPickle: string | null) => void): void { - 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. - */ - public storeAccount(txn: IDBTransaction, accountPickle: string): void { - 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 - */ - public getCrossSigningKeys( - txn: IDBTransaction, - func: (keys: Record<string, ICrossSigningKey> | null) => void, - ): void { - this.backend!.getCrossSigningKeys(txn, func); - } - - /** - * @param txn - An active transaction. See doTxn(). - * @param func - Called with the private key - * @param type - A key type - */ - public getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>( - txn: IDBTransaction, - func: (key: SecretStorePrivateKeys[K] | null) => void, - type: K, - ): void { - 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() - */ - public storeCrossSigningKeys(txn: IDBTransaction, keys: Record<string, ICrossSigningKey>): void { - 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() - */ - public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>( - txn: IDBTransaction, - type: K, - key: SecretStorePrivateKeys[K], - ): void { - 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 - */ - public countEndToEndSessions(txn: IDBTransaction, func: (count: number) => void): void { - 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. - */ - public getEndToEndSession( - deviceKey: string, - sessionId: string, - txn: IDBTransaction, - func: (session: ISessionInfo | null) => void, - ): void { - 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. - */ - public getEndToEndSessions( - deviceKey: string, - txn: IDBTransaction, - func: (sessions: { [sessionId: string]: ISessionInfo }) => void, - ): void { - 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. - */ - public getAllEndToEndSessions(txn: IDBTransaction, func: (session: ISessionInfo | null) => void): void { - 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(). - */ - public storeEndToEndSession( - deviceKey: string, - sessionId: string, - sessionInfo: ISessionInfo, - txn: IDBTransaction, - ): void { - this.backend!.storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn); - } - - public storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> { - return this.backend!.storeEndToEndSessionProblem(deviceKey, type, fixed); - } - - public getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> { - return this.backend!.getEndToEndSessionProblem(deviceKey, timestamp); - } - - public filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> { - 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. - */ - public getEndToEndInboundGroupSession( - senderCurve25519Key: string, - sessionId: string, - txn: IDBTransaction, - func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void, - ): void { - 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. - */ - public getAllEndToEndInboundGroupSessions(txn: IDBTransaction, func: (session: ISession | null) => void): void { - 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(). - */ - public addEndToEndInboundGroupSession( - senderCurve25519Key: string, - sessionId: string, - sessionData: InboundGroupSessionData, - txn: IDBTransaction, - ): void { - 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(). - */ - public storeEndToEndInboundGroupSession( - senderCurve25519Key: string, - sessionId: string, - sessionData: InboundGroupSessionData, - txn: IDBTransaction, - ): void { - this.backend!.storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn); - } - - public storeEndToEndInboundGroupSessionWithheld( - senderCurve25519Key: string, - sessionId: string, - sessionData: IWithheld, - txn: IDBTransaction, - ): void { - 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(). - */ - public storeEndToEndDeviceData(deviceData: IDeviceData, txn: IDBTransaction): void { - 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 - */ - public getEndToEndDeviceData(txn: IDBTransaction, func: (deviceData: IDeviceData | null) => void): void { - 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(). - */ - public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: IDBTransaction): void { - 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 - */ - public getEndToEndRooms(txn: IDBTransaction, func: (rooms: Record<string, IRoomEncryption>) => void): void { - 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 - */ - public getSessionsNeedingBackup(limit: number): Promise<ISession[]> { - 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 - */ - public countSessionsNeedingBackup(txn?: IDBTransaction): Promise<number> { - 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 - */ - public unmarkSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise<void> { - 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 - */ - public markSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise<void> { - 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) - */ - public addSharedHistoryInboundGroupSession( - roomId: string, - senderKey: string, - sessionId: string, - txn?: IDBTransaction, - ): void { - 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] - */ - public getSharedHistoryInboundGroupSessions( - roomId: string, - txn?: IDBTransaction, - ): Promise<[senderKey: string, sessionId: string][]> { - return this.backend!.getSharedHistoryInboundGroupSessions(roomId, txn); - } - - /** - * Park a shared-history group session for a room we may be invited to later. - */ - public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory, txn?: IDBTransaction): void { - this.backend!.addParkedSharedHistory(roomId, parkedData, txn); - } - - /** - * Pop out all shared-history group sessions for a room. - */ - public takeParkedSharedHistory(roomId: string, txn?: IDBTransaction): Promise<ParkedSharedHistory[]> { - 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. - */ - public doTxn<T>( - mode: Mode, - stores: Iterable<string>, - func: (txn: IDBTransaction) => T, - log?: PrefixedLogger, - ): Promise<T> { - return this.backend!.doTxn<T>(mode, stores, func as (txn: unknown) => T, log); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/localStorage-crypto-store.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/localStorage-crypto-store.ts deleted file mode 100644 index 5552540..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/localStorage-crypto-store.ts +++ /dev/null @@ -1,403 +0,0 @@ -/* -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. -*/ - -import { logger } from "../../logger"; -import { MemoryCryptoStore } from "./memory-crypto-store"; -import { IDeviceData, IProblem, ISession, ISessionInfo, IWithheld, Mode, SecretStorePrivateKeys } from "./base"; -import { IOlmDevice } from "../algorithms/megolm"; -import { IRoomEncryption } from "../RoomList"; -import { ICrossSigningKey } from "../../client"; -import { InboundGroupSessionData } from "../OlmDevice"; -import { safeSet } from "../../utils"; - -/** - * 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: string): string { - return E2E_PREFIX + "sessions/" + deviceKey; -} - -function keyEndToEndSessionProblems(deviceKey: string): string { - return E2E_PREFIX + "session.problems/" + deviceKey; -} - -function keyEndToEndInboundGroupSession(senderKey: string, sessionId: string): string { - return KEY_INBOUND_SESSION_PREFIX + senderKey + "/" + sessionId; -} - -function keyEndToEndInboundGroupSessionWithheld(senderKey: string, sessionId: string): string { - return KEY_INBOUND_SESSION_WITHHELD_PREFIX + senderKey + "/" + sessionId; -} - -function keyEndToEndRoomsPrefix(roomId: string): string { - return KEY_ROOMS_PREFIX + roomId; -} - -export class LocalStorageCryptoStore extends MemoryCryptoStore { - public static exists(store: Storage): boolean { - const length = store.length; - for (let i = 0; i < length; i++) { - if (store.key(i)?.startsWith(E2E_PREFIX)) { - return true; - } - } - return false; - } - - public constructor(private readonly store: Storage) { - super(); - } - - // Olm Sessions - - public countEndToEndSessions(txn: unknown, func: (count: number) => void): void { - 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 - private _getEndToEndSessions(deviceKey: string): Record<string, ISessionInfo> { - const sessions = getJsonItem(this.store, keyEndToEndSessions(deviceKey)); - const fixedSessions: Record<string, ISessionInfo> = {}; - - // 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; - } - - public getEndToEndSession( - deviceKey: string, - sessionId: string, - txn: unknown, - func: (session: ISessionInfo) => void, - ): void { - const sessions = this._getEndToEndSessions(deviceKey); - func(sessions[sessionId] || {}); - } - - public getEndToEndSessions( - deviceKey: string, - txn: unknown, - func: (sessions: { [sessionId: string]: ISessionInfo }) => void, - ): void { - func(this._getEndToEndSessions(deviceKey) || {}); - } - - public getAllEndToEndSessions(txn: unknown, func: (session: ISessionInfo) => void): void { - 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); - } - } - } - } - - public storeEndToEndSession(deviceKey: string, sessionId: string, sessionInfo: ISessionInfo, txn: unknown): void { - const sessions = this._getEndToEndSessions(deviceKey) || {}; - sessions[sessionId] = sessionInfo; - setJsonItem(this.store, keyEndToEndSessions(deviceKey), sessions); - } - - public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> { - const key = keyEndToEndSessionProblems(deviceKey); - const problems = getJsonItem<IProblem[]>(this.store, key) || []; - problems.push({ type, fixed, time: Date.now() }); - problems.sort((a, b) => { - return a.time - b.time; - }); - setJsonItem(this.store, key, problems); - } - - public async getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> { - const key = keyEndToEndSessionProblems(deviceKey); - const problems = getJsonItem<IProblem[]>(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; - } - } - - public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> { - const notifiedErrorDevices = - getJsonItem<MemoryCryptoStore["notifiedErrorDevices"]>(this.store, KEY_NOTIFIED_ERROR_DEVICES) || {}; - const ret: IOlmDevice[] = []; - - for (const device of devices) { - const { userId, deviceInfo } = device; - if (userId in notifiedErrorDevices) { - if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) { - ret.push(device); - safeSet(notifiedErrorDevices[userId], deviceInfo.deviceId, true); - } - } else { - ret.push(device); - safeSet(notifiedErrorDevices, userId, { [deviceInfo.deviceId]: true }); - } - } - - setJsonItem(this.store, KEY_NOTIFIED_ERROR_DEVICES, notifiedErrorDevices); - - return ret; - } - - // Inbound Group Sessions - - public getEndToEndInboundGroupSession( - senderCurve25519Key: string, - sessionId: string, - txn: unknown, - func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void, - ): void { - func( - getJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId)), - getJsonItem(this.store, keyEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId)), - ); - } - - public getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void { - 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); - } - - public addEndToEndInboundGroupSession( - senderCurve25519Key: string, - sessionId: string, - sessionData: InboundGroupSessionData, - txn: unknown, - ): void { - const existing = getJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId)); - if (!existing) { - this.storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn); - } - } - - public storeEndToEndInboundGroupSession( - senderCurve25519Key: string, - sessionId: string, - sessionData: InboundGroupSessionData, - txn: unknown, - ): void { - setJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId), sessionData); - } - - public storeEndToEndInboundGroupSessionWithheld( - senderCurve25519Key: string, - sessionId: string, - sessionData: IWithheld, - txn: unknown, - ): void { - setJsonItem(this.store, keyEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId), sessionData); - } - - public getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void { - func(getJsonItem(this.store, KEY_DEVICE_DATA)); - } - - public storeEndToEndDeviceData(deviceData: IDeviceData, txn: unknown): void { - setJsonItem(this.store, KEY_DEVICE_DATA, deviceData); - } - - public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: unknown): void { - setJsonItem(this.store, keyEndToEndRoomsPrefix(roomId), roomInfo); - } - - public getEndToEndRooms(txn: unknown, func: (rooms: Record<string, IRoomEncryption>) => void): void { - const result: Record<string, IRoomEncryption> = {}; - 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); - } - - public getSessionsNeedingBackup(limit: number): Promise<ISession[]> { - const sessionsNeedingBackup = getJsonItem<string[]>(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; - const sessions: ISession[] = []; - - 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); - } - - public countSessionsNeedingBackup(): Promise<number> { - const sessionsNeedingBackup = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; - return Promise.resolve(Object.keys(sessionsNeedingBackup).length); - } - - public unmarkSessionsNeedingBackup(sessions: ISession[]): Promise<void> { - const sessionsNeedingBackup = - getJsonItem<{ - [senderKeySessionId: string]: string; - }>(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(); - } - - public markSessionsNeedingBackup(sessions: ISession[]): Promise<void> { - const sessionsNeedingBackup = - getJsonItem<{ - [senderKeySessionId: string]: boolean; - }>(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. - */ - public deleteAllData(): Promise<void> { - this.store.removeItem(KEY_END_TO_END_ACCOUNT); - return Promise.resolve(); - } - - // Olm account - - public getAccount(txn: unknown, func: (accountPickle: string | null) => void): void { - const accountPickle = getJsonItem<string>(this.store, KEY_END_TO_END_ACCOUNT); - func(accountPickle); - } - - public storeAccount(txn: unknown, accountPickle: string): void { - setJsonItem(this.store, KEY_END_TO_END_ACCOUNT, accountPickle); - } - - public getCrossSigningKeys(txn: unknown, func: (keys: Record<string, ICrossSigningKey> | null) => void): void { - const keys = getJsonItem<Record<string, ICrossSigningKey>>(this.store, KEY_CROSS_SIGNING_KEYS); - func(keys); - } - - public getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>( - txn: unknown, - func: (key: SecretStorePrivateKeys[K] | null) => void, - type: K, - ): void { - const key = getJsonItem<SecretStorePrivateKeys[K]>(this.store, E2E_PREFIX + `ssss_cache.${type}`); - func(key); - } - - public storeCrossSigningKeys(txn: unknown, keys: Record<string, ICrossSigningKey>): void { - setJsonItem(this.store, KEY_CROSS_SIGNING_KEYS, keys); - } - - public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>( - txn: unknown, - type: K, - key: SecretStorePrivateKeys[K], - ): void { - setJsonItem(this.store, E2E_PREFIX + `ssss_cache.${type}`, key); - } - - public doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn: unknown) => T): Promise<T> { - return Promise.resolve(func(null)); - } -} - -function getJsonItem<T>(store: Storage, key: string): T | null { - try { - // if the key is absent, store.getItem() returns null, and - // JSON.parse(null) === null, so this returns null. - return JSON.parse(store.getItem(key)!); - } catch (e) { - logger.log("Error: Failed to get key %s: %s", key, (<Error>e).message); - logger.log((<Error>e).stack); - } - return null; -} - -function setJsonItem<T>(store: Storage, key: string, val: T): void { - store.setItem(key, JSON.stringify(val)); -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/memory-crypto-store.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/memory-crypto-store.ts deleted file mode 100644 index 29ae81b..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/memory-crypto-store.ts +++ /dev/null @@ -1,533 +0,0 @@ -/* -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. -*/ - -import { logger } from "../../logger"; -import * as utils from "../../utils"; -import { - CryptoStore, - IDeviceData, - IProblem, - ISession, - ISessionInfo, - IWithheld, - Mode, - OutgoingRoomKeyRequest, - ParkedSharedHistory, - SecretStorePrivateKeys, -} from "./base"; -import { IRoomKeyRequestBody } from "../index"; -import { ICrossSigningKey } from "../../client"; -import { IOlmDevice } from "../algorithms/megolm"; -import { IRoomEncryption } from "../RoomList"; -import { InboundGroupSessionData } from "../OlmDevice"; -import { safeSet } from "../../utils"; - -/** - * Internal module. in-memory storage for e2e. - */ - -export class MemoryCryptoStore implements CryptoStore { - private outgoingRoomKeyRequests: OutgoingRoomKeyRequest[] = []; - private account: string | null = null; - private crossSigningKeys: Record<string, ICrossSigningKey> | null = null; - private privateKeys: Partial<SecretStorePrivateKeys> = {}; - - private sessions: { [deviceKey: string]: { [sessionId: string]: ISessionInfo } } = {}; - private sessionProblems: { [deviceKey: string]: IProblem[] } = {}; - private notifiedErrorDevices: { [userId: string]: { [deviceId: string]: boolean } } = {}; - private inboundGroupSessions: { [sessionKey: string]: InboundGroupSessionData } = {}; - private inboundGroupSessionsWithheld: Record<string, IWithheld> = {}; - // Opaque device data object - private deviceData: IDeviceData | null = null; - private rooms: { [roomId: string]: IRoomEncryption } = {}; - private sessionsNeedingBackup: { [sessionKey: string]: boolean } = {}; - private sharedHistoryInboundGroupSessions: { [roomId: string]: [senderKey: string, sessionId: string][] } = {}; - private parkedSharedHistory = new Map<string, ParkedSharedHistory[]>(); // 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. - */ - public async startup(): Promise<CryptoStore> { - // 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. - */ - public deleteAllData(): Promise<void> { - 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. - */ - public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest> { - const requestBody = request.requestBody; - - return utils.promiseTry(() => { - // first see if we already have an entry for this request. - const existing = this._getOutgoingRoomKeyRequest(requestBody); - - if (existing) { - // this entry matches the request - return it. - logger.log( - `already have key request outstanding for ` + - `${requestBody.room_id} / ${requestBody.session_id}: ` + - `not sending another`, - ); - return existing; - } - - // we got to the end of the list without finding a match - // - add the new request. - logger.log(`enqueueing key request for ${requestBody.room_id} / ` + requestBody.session_id); - this.outgoingRoomKeyRequests.push(request); - return request; - }); - } - - /** - * Look for an existing room key request - * - * @param requestBody - existing request to look for - * - * @returns resolves to the matching - * {@link OutgoingRoomKeyRequest}, or null if - * not found - */ - public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null> { - 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 - private _getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): OutgoingRoomKeyRequest | null { - for (const existing of this.outgoingRoomKeyRequests) { - if (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 - */ - public getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null> { - 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 - */ - public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]> { - return Promise.resolve(this.outgoingRoomKeyRequests.filter((r) => r.state == wantedState)); - } - - public getOutgoingRoomKeyRequestsByTarget( - userId: string, - deviceId: string, - wantedStates: number[], - ): Promise<OutgoingRoomKeyRequest[]> { - const results: OutgoingRoomKeyRequest[] = []; - - 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 - */ - public updateOutgoingRoomKeyRequest( - requestId: string, - expectedState: number, - updates: Partial<OutgoingRoomKeyRequest>, - ): Promise<OutgoingRoomKeyRequest | null> { - for (const req of this.outgoingRoomKeyRequests) { - if (req.requestId !== requestId) { - continue; - } - - if (req.state !== expectedState) { - logger.warn( - `Cannot update room key request from ${expectedState} ` + - `as it was already updated to ${req.state}`, - ); - return Promise.resolve(null); - } - Object.assign(req, updates); - return Promise.resolve(req); - } - - return Promise.resolve(null); - } - - /** - * Look for an existing room key request by id and state, and delete it if - * found - * - * @param requestId - ID of request to update - * @param expectedState - state we expect to find the request in - * - * @returns resolves once the operation is completed - */ - public deleteOutgoingRoomKeyRequest( - requestId: string, - expectedState: number, - ): Promise<OutgoingRoomKeyRequest | null> { - for (let i = 0; i < this.outgoingRoomKeyRequests.length; i++) { - const req = this.outgoingRoomKeyRequests[i]; - - if (req.requestId !== requestId) { - continue; - } - - if (req.state != expectedState) { - logger.warn(`Cannot delete room key request in state ${req.state} ` + `(expected ${expectedState})`); - return Promise.resolve(null); - } - - this.outgoingRoomKeyRequests.splice(i, 1); - return Promise.resolve(req); - } - - return Promise.resolve(null); - } - - // Olm Account - - public getAccount(txn: unknown, func: (accountPickle: string | null) => void): void { - func(this.account); - } - - public storeAccount(txn: unknown, accountPickle: string): void { - this.account = accountPickle; - } - - public getCrossSigningKeys(txn: unknown, func: (keys: Record<string, ICrossSigningKey> | null) => void): void { - func(this.crossSigningKeys); - } - - public getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>( - txn: unknown, - func: (key: SecretStorePrivateKeys[K] | null) => void, - type: K, - ): void { - const result = this.privateKeys[type] as SecretStorePrivateKeys[K] | undefined; - func(result || null); - } - - public storeCrossSigningKeys(txn: unknown, keys: Record<string, ICrossSigningKey>): void { - this.crossSigningKeys = keys; - } - - public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>( - txn: unknown, - type: K, - key: SecretStorePrivateKeys[K], - ): void { - this.privateKeys[type] = key; - } - - // Olm Sessions - - public countEndToEndSessions(txn: unknown, func: (count: number) => void): void { - func(Object.keys(this.sessions).length); - } - - public getEndToEndSession( - deviceKey: string, - sessionId: string, - txn: unknown, - func: (session: ISessionInfo) => void, - ): void { - const deviceSessions = this.sessions[deviceKey] || {}; - func(deviceSessions[sessionId] || null); - } - - public getEndToEndSessions( - deviceKey: string, - txn: unknown, - func: (sessions: { [sessionId: string]: ISessionInfo }) => void, - ): void { - func(this.sessions[deviceKey] || {}); - } - - public getAllEndToEndSessions(txn: unknown, func: (session: ISessionInfo) => void): void { - Object.entries(this.sessions).forEach(([deviceKey, deviceSessions]) => { - Object.entries(deviceSessions).forEach(([sessionId, session]) => { - func({ - ...session, - deviceKey, - sessionId, - }); - }); - }); - } - - public storeEndToEndSession(deviceKey: string, sessionId: string, sessionInfo: ISessionInfo, txn: unknown): void { - let deviceSessions = this.sessions[deviceKey]; - if (deviceSessions === undefined) { - deviceSessions = {}; - this.sessions[deviceKey] = deviceSessions; - } - deviceSessions[sessionId] = sessionInfo; - } - - public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> { - const problems = (this.sessionProblems[deviceKey] = this.sessionProblems[deviceKey] || []); - problems.push({ type, fixed, time: Date.now() }); - problems.sort((a, b) => { - return a.time - b.time; - }); - } - - public async getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> { - 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; - } - } - - public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> { - const notifiedErrorDevices = this.notifiedErrorDevices; - const ret: IOlmDevice[] = []; - - for (const device of devices) { - const { userId, deviceInfo } = device; - if (userId in notifiedErrorDevices) { - if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) { - ret.push(device); - safeSet(notifiedErrorDevices[userId], deviceInfo.deviceId, true); - } - } else { - ret.push(device); - safeSet(notifiedErrorDevices, userId, { [deviceInfo.deviceId]: true }); - } - } - - return ret; - } - - // Inbound Group Sessions - - public getEndToEndInboundGroupSession( - senderCurve25519Key: string, - sessionId: string, - txn: unknown, - func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void, - ): void { - const k = senderCurve25519Key + "/" + sessionId; - func(this.inboundGroupSessions[k] || null, this.inboundGroupSessionsWithheld[k] || null); - } - - public getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void { - 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); - } - - public addEndToEndInboundGroupSession( - senderCurve25519Key: string, - sessionId: string, - sessionData: InboundGroupSessionData, - txn: unknown, - ): void { - const k = senderCurve25519Key + "/" + sessionId; - if (this.inboundGroupSessions[k] === undefined) { - this.inboundGroupSessions[k] = sessionData; - } - } - - public storeEndToEndInboundGroupSession( - senderCurve25519Key: string, - sessionId: string, - sessionData: InboundGroupSessionData, - txn: unknown, - ): void { - this.inboundGroupSessions[senderCurve25519Key + "/" + sessionId] = sessionData; - } - - public storeEndToEndInboundGroupSessionWithheld( - senderCurve25519Key: string, - sessionId: string, - sessionData: IWithheld, - txn: unknown, - ): void { - const k = senderCurve25519Key + "/" + sessionId; - this.inboundGroupSessionsWithheld[k] = sessionData; - } - - // Device Data - - public getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void { - func(this.deviceData); - } - - public storeEndToEndDeviceData(deviceData: IDeviceData, txn: unknown): void { - this.deviceData = deviceData; - } - - // E2E rooms - - public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: unknown): void { - this.rooms[roomId] = roomInfo; - } - - public getEndToEndRooms(txn: unknown, func: (rooms: Record<string, IRoomEncryption>) => void): void { - func(this.rooms); - } - - public getSessionsNeedingBackup(limit: number): Promise<ISession[]> { - const sessions: ISession[] = []; - 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); - } - - public countSessionsNeedingBackup(): Promise<number> { - return Promise.resolve(Object.keys(this.sessionsNeedingBackup).length); - } - - public unmarkSessionsNeedingBackup(sessions: ISession[]): Promise<void> { - for (const session of sessions) { - const sessionKey = session.senderKey + "/" + session.sessionId; - delete this.sessionsNeedingBackup[sessionKey]; - } - return Promise.resolve(); - } - - public markSessionsNeedingBackup(sessions: ISession[]): Promise<void> { - for (const session of sessions) { - const sessionKey = session.senderKey + "/" + session.sessionId; - this.sessionsNeedingBackup[sessionKey] = true; - } - return Promise.resolve(); - } - - public addSharedHistoryInboundGroupSession(roomId: string, senderKey: string, sessionId: string): void { - const sessions = this.sharedHistoryInboundGroupSessions[roomId] || []; - sessions.push([senderKey, sessionId]); - this.sharedHistoryInboundGroupSessions[roomId] = sessions; - } - - public getSharedHistoryInboundGroupSessions(roomId: string): Promise<[senderKey: string, sessionId: string][]> { - return Promise.resolve(this.sharedHistoryInboundGroupSessions[roomId] || []); - } - - public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory): void { - const parked = this.parkedSharedHistory.get(roomId) ?? []; - parked.push(parkedData); - this.parkedSharedHistory.set(roomId, parked); - } - - public takeParkedSharedHistory(roomId: string): Promise<ParkedSharedHistory[]> { - const parked = this.parkedSharedHistory.get(roomId) ?? []; - this.parkedSharedHistory.delete(roomId); - return Promise.resolve(parked); - } - - // Session key backups - - public doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn?: unknown) => T): Promise<T> { - return Promise.resolve(func(null)); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Base.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Base.ts deleted file mode 100644 index 89c700c..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Base.ts +++ /dev/null @@ -1,369 +0,0 @@ -/* -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. - */ - -import { MatrixEvent } from "../../models/event"; -import { EventType } from "../../@types/event"; -import { logger } from "../../logger"; -import { DeviceInfo } from "../deviceinfo"; -import { newTimeoutError } from "./Error"; -import { KeysDuringVerification, requestKeysDuringVerification } from "../CrossSigning"; -import { IVerificationChannel } from "./request/Channel"; -import { MatrixClient } from "../../client"; -import { VerificationRequest } from "./request/VerificationRequest"; -import { ListenerMap, TypedEventEmitter } from "../../models/typed-event-emitter"; - -const timeoutException = new Error("Verification timed out"); - -export class SwitchStartEventError extends Error { - public constructor(public readonly startEvent: MatrixEvent | null) { - super(); - } -} - -export type KeyVerifier = (keyId: string, device: DeviceInfo, keyInfo: string) => void; - -export enum VerificationEvent { - Cancel = "cancel", -} - -export type VerificationEventHandlerMap = { - [VerificationEvent.Cancel]: (e: Error | MatrixEvent) => void; -}; - -export class VerificationBase< - Events extends string, - Arguments extends ListenerMap<Events | VerificationEvent>, -> extends TypedEventEmitter<Events | VerificationEvent, Arguments, VerificationEventHandlerMap> { - private cancelled = false; - private _done = false; - private promise: Promise<void> | null = null; - private transactionTimeoutTimer: ReturnType<typeof setTimeout> | null = null; - protected expectedEvent?: string; - private resolve?: () => void; - private reject?: (e: Error | MatrixEvent) => void; - private resolveEvent?: (e: MatrixEvent) => void; - private rejectEvent?: (e: Error) => void; - private started?: boolean; - - /** - * 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 - */ - public constructor( - public readonly channel: IVerificationChannel, - public readonly baseApis: MatrixClient, - public readonly userId: string, - public readonly deviceId: string, - public startEvent: MatrixEvent | null, - public readonly request: VerificationRequest, - ) { - super(); - } - - public get initiatedByMe(): boolean { - // 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(); - } - - public get hasBeenCancelled(): boolean { - return this.cancelled; - } - - private resetTimer(): void { - logger.info("Refreshing/starting the verification transaction timeout timer"); - if (this.transactionTimeoutTimer !== null) { - clearTimeout(this.transactionTimeoutTimer); - } - this.transactionTimeoutTimer = setTimeout(() => { - if (!this._done && !this.cancelled) { - logger.info("Triggering verification timeout"); - this.cancel(timeoutException); - } - }, 10 * 60 * 1000); // 10 minutes - } - - private endTimer(): void { - if (this.transactionTimeoutTimer !== null) { - clearTimeout(this.transactionTimeoutTimer); - this.transactionTimeoutTimer = null; - } - } - - protected send(type: string, uncompletedContent: Record<string, any>): Promise<void> { - return this.channel.send(type, uncompletedContent); - } - - protected waitForEvent(type: string): Promise<MatrixEvent> { - 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; - }); - } - - public canSwitchStartEvent(event: MatrixEvent): boolean { - return false; - } - - public switchStartEvent(event: MatrixEvent): void { - if (this.canSwitchStartEvent(event)) { - logger.log("Verification Base: switching verification start event", { restartingFlow: !!this.rejectEvent }); - if (this.rejectEvent) { - const reject = this.rejectEvent; - this.rejectEvent = undefined; - reject(new SwitchStartEventError(event)); - } else { - this.startEvent = event; - } - } - } - - public handleEvent(e: MatrixEvent): void { - 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 !== EventType.KeyVerificationDone) { - this.expectedEvent = undefined; - this.rejectEvent = undefined; - this.resetTimer(); - this.resolveEvent?.(e); - } - } else if (e.getType() === 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); - } - } - - public async done(): Promise<KeysDuringVerification | void> { - this.endTimer(); // always kill the activity timer - if (!this._done) { - this.request.onVerifierFinished(); - this.resolve?.(); - return requestKeysDuringVerification(this.baseApis, this.userId, this.deviceId); - } - } - - public cancel(e: Error | MatrixEvent): void { - this.endTimer(); // always kill the activity timer - if (!this._done) { - this.cancelled = true; - this.request.onVerifierCancelled(); - if (this.userId && this.deviceId) { - // send a cancellation to the other user (if it wasn't - // cancelled by the other user) - if (e === timeoutException) { - const timeoutEvent = newTimeoutError(); - this.send(timeoutEvent.getType(), timeoutEvent.getContent()); - } else if (e instanceof MatrixEvent) { - const sender = e.getSender(); - if (sender !== this.userId) { - const content = e.getContent(); - if (e.getType() === EventType.KeyVerificationCancel) { - content.code = content.code || "m.unknown"; - content.reason = content.reason || content.body || "Unknown reason"; - this.send(EventType.KeyVerificationCancel, content); - } else { - this.send(EventType.KeyVerificationCancel, { - code: "m.unknown", - reason: content.body || "Unknown reason", - }); - } - } - } else { - this.send(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. - */ - public verify(): Promise<void> { - if (this.promise) return this.promise; - - this.promise = new Promise((resolve, reject) => { - this.resolve = (...args): void => { - this._done = true; - this.endTimer(); - resolve(...args); - }; - this.reject = (e: Error | MatrixEvent): void => { - this._done = true; - this.endTimer(); - reject(e); - }; - }); - if (this.doVerification && !this.started) { - this.started = true; - this.resetTimer(); // restart the timeout - new Promise<void>((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; - } - - protected doVerification?: () => Promise<void>; - - protected async verifyKeys(userId: string, keys: Record<string, string>, verifier: KeyVerifier): Promise<void> { - // 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: [string, string, string][] = []; - - 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.fromStorage( - { - keys: { - [keyId]: deviceId, - }, - }, - deviceId, - ), - keyInfo, - ); - verifiedDevices.push([deviceId, keyId, deviceId]); - } else { - logger.warn(`verification: Could not find device ${deviceId} to verify`); - } - } - } - - // if none of the keys could be verified, then error because the app - // should be informed about that - if (!verifiedDevices.length) { - throw new Error("No devices could be verified"); - } - - logger.info("Verification completed! Marking devices verified: ", verifiedDevices); - // TODO: There should probably be a batch version of this, otherwise it's going - // to upload each signature in a separate API call which is silly because the - // API supports as many signatures as you like. - for (const [deviceId, 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(); - } - } - - public get events(): string[] | undefined { - return undefined; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Error.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Error.ts deleted file mode 100644 index da73ebb..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Error.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* -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. - */ - -import { MatrixEvent } from "../../models/event"; -import { EventType } from "../../@types/event"; - -export function newVerificationError(code: string, reason: string, extraData?: Record<string, any>): MatrixEvent { - const content = Object.assign({}, { code, reason }, extraData); - return new MatrixEvent({ - type: EventType.KeyVerificationCancel, - content, - }); -} - -export function errorFactory(code: string, reason: string): (extraData?: Record<string, any>) => MatrixEvent { - return function (extraData?: Record<string, any>) { - return newVerificationError(code, reason, extraData); - }; -} - -/** - * The verification was cancelled by the user. - */ -export const newUserCancelledError = errorFactory("m.user", "Cancelled by user"); - -/** - * The verification timed out. - */ -export const newTimeoutError = errorFactory("m.timeout", "Timed out"); - -/** - * An unknown method was selected. - */ -export const newUnknownMethodError = errorFactory("m.unknown_method", "Unknown method"); - -/** - * An unexpected message was sent. - */ -export const newUnexpectedMessageError = errorFactory("m.unexpected_message", "Unexpected message"); - -/** - * The key does not match. - */ -export const newKeyMismatchError = errorFactory("m.key_mismatch", "Key mismatch"); - -/** - * An invalid message was sent. - */ -export const newInvalidMessageError = errorFactory("m.invalid_message", "Invalid message"); - -export function errorFromEvent(event: MatrixEvent): { code: string; reason: string } { - const content = event.getContent(); - if (content) { - const { code, reason } = content; - return { code, reason }; - } else { - return { code: "Unknown error", reason: "m.unknown" }; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/IllegalMethod.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/IllegalMethod.ts deleted file mode 100644 index c437e0c..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/IllegalMethod.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* -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). - */ - -import { VerificationBase as Base, VerificationEvent, VerificationEventHandlerMap } from "./Base"; -import { IVerificationChannel } from "./request/Channel"; -import { MatrixClient } from "../../client"; -import { MatrixEvent } from "../../models/event"; -import { VerificationRequest } from "./request/VerificationRequest"; - -export class IllegalMethod extends Base<VerificationEvent, VerificationEventHandlerMap> { - public static factory( - channel: IVerificationChannel, - baseApis: MatrixClient, - userId: string, - deviceId: string, - startEvent: MatrixEvent, - request: VerificationRequest, - ): IllegalMethod { - return new IllegalMethod(channel, baseApis, userId, deviceId, startEvent, request); - } - - // eslint-disable-next-line @typescript-eslint/naming-convention - public static get NAME(): string { - // Typically the name will be something else, but to complete - // the contract we offer a default one here. - return "org.matrix.illegal_method"; - } - - protected doVerification = async (): Promise<void> => { - throw new Error("Verification is not possible with this method"); - }; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/QRCode.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/QRCode.ts deleted file mode 100644 index bfb532e..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/QRCode.ts +++ /dev/null @@ -1,311 +0,0 @@ -/* -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. - */ - -import { VerificationBase as Base, VerificationEventHandlerMap } from "./Base"; -import { newKeyMismatchError, newUserCancelledError } from "./Error"; -import { decodeBase64, encodeUnpaddedBase64 } from "../olmlib"; -import { logger } from "../../logger"; -import { VerificationRequest } from "./request/VerificationRequest"; -import { MatrixClient } from "../../client"; -import { IVerificationChannel } from "./request/Channel"; -import { MatrixEvent } from "../../models/event"; - -export const SHOW_QR_CODE_METHOD = "m.qr_code.show.v1"; -export const SCAN_QR_CODE_METHOD = "m.qr_code.scan.v1"; - -interface IReciprocateQr { - confirm(): void; - cancel(): void; -} - -export enum QrCodeEvent { - ShowReciprocateQr = "show_reciprocate_qr", -} - -type EventHandlerMap = { - [QrCodeEvent.ShowReciprocateQr]: (qr: IReciprocateQr) => void; -} & VerificationEventHandlerMap; - -export class ReciprocateQRCode extends Base<QrCodeEvent, EventHandlerMap> { - public reciprocateQREvent?: IReciprocateQr; - - public static factory( - channel: IVerificationChannel, - baseApis: MatrixClient, - userId: string, - deviceId: string, - startEvent: MatrixEvent, - request: VerificationRequest, - ): ReciprocateQRCode { - return new ReciprocateQRCode(channel, baseApis, userId, deviceId, startEvent, request); - } - - // eslint-disable-next-line @typescript-eslint/naming-convention - public static get NAME(): string { - return "m.reciprocate.v1"; - } - - protected doVerification = async (): Promise<void> => { - if (!this.startEvent) { - // TODO: Support scanning QR codes - throw new Error("It is not currently possible to start verification" + "with this method yet."); - } - - const { qrCodeData } = this.request; - // 1. check the secret - if (this.startEvent.getContent()["secret"] !== qrCodeData?.encodedSharedSecret) { - throw newKeyMismatchError(); - } - - // 2. ask if other user shows shield as well - await new Promise<void>((resolve, reject) => { - this.reciprocateQREvent = { - confirm: resolve, - cancel: () => reject(newUserCancelledError()), - }; - this.emit(QrCodeEvent.ShowReciprocateQr, this.reciprocateQREvent); - }); - - // 3. determine key to sign / mark as trusted - const keys: Record<string, string> = {}; - - 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 newKeyMismatchError(); - - if (keyInfo !== targetKey) { - logger.error("key ID from key info does not match"); - throw newKeyMismatchError(); - } - for (const deviceKeyId in device.keys) { - if (!deviceKeyId.startsWith("ed25519")) continue; - const deviceTargetKey = keys[deviceKeyId]; - if (!deviceTargetKey) throw newKeyMismatchError(); - if (device.keys[deviceKeyId] !== deviceTargetKey) { - logger.error("master key does not match"); - throw newKeyMismatchError(); - } - } - }); - }; -} - -const CODE_VERSION = 0x02; // the version of binary QR codes we support -const BINARY_PREFIX = "MATRIX"; // ASCII, used to prefix the binary format - -enum Mode { - VerifyOtherUser = 0x00, // Verifying someone who isn't us - VerifySelfTrusted = 0x01, // We trust the master key - VerifySelfUntrusted = 0x02, // We do not trust the master key -} - -interface IQrData { - prefix: string; - version: number; - mode: Mode; - transactionId?: string; - firstKeyB64: string; - secondKeyB64: string; - secretB64: string; -} - -export class QRCodeData { - public constructor( - public readonly mode: Mode, - private readonly sharedSecret: string, - // only set when mode is MODE_VERIFY_OTHER_USER, master key of other party at time of generating QR code - public readonly otherUserMasterKey: string | null, - // only set when mode is MODE_VERIFY_SELF_TRUSTED, device key of other party at time of generating QR code - public readonly otherDeviceKey: string | null, - // only set when mode is MODE_VERIFY_SELF_UNTRUSTED, own master key at time of generating QR code - public readonly myMasterKey: string | null, - private readonly buffer: Buffer, - ) {} - - public static async create(request: VerificationRequest, client: MatrixClient): Promise<QRCodeData> { - const sharedSecret = QRCodeData.generateSharedSecret(); - const mode = QRCodeData.determineMode(request, client); - let otherUserMasterKey: string | null = null; - let otherDeviceKey: string | null = null; - let myMasterKey: string | null = 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. - */ - public get encodedSharedSecret(): string { - return this.sharedSecret; - } - - public getBuffer(): Buffer { - return this.buffer; - } - - private static generateSharedSecret(): string { - const secretBytes = new Uint8Array(11); - global.crypto.getRandomValues(secretBytes); - return encodeUnpaddedBase64(secretBytes); - } - - private static async getOtherDeviceKey(request: VerificationRequest, client: MatrixClient): Promise<string> { - 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(); - } - - private static determineMode(request: VerificationRequest, client: MatrixClient): Mode { - 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; - } - - private static generateQrData( - request: VerificationRequest, - client: MatrixClient, - mode: Mode, - encodedSharedSecret: string, - otherUserMasterKey?: string, - otherDeviceKey?: string, - myMasterKey?: string, - ): IQrData { - const myUserId = client.getUserId()!; - const transactionId = request.channel.transactionId; - const qrData: IQrData = { - 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; - } - - private static generateBuffer(qrData: IQrData): Buffer { - let buf = Buffer.alloc(0); // we'll concat our way through life - - const appendByte = (b: number): void => { - const tmpBuf = Buffer.from([b]); - buf = Buffer.concat([buf, tmpBuf]); - }; - const appendInt = (i: number): void => { - const tmpBuf = Buffer.alloc(2); - tmpBuf.writeInt16BE(i, 0); - buf = Buffer.concat([buf, tmpBuf]); - }; - const appendStr = (s: string, enc: BufferEncoding, withLengthPrefix = true): void => { - const tmpBuf = Buffer.from(s, enc); - if (withLengthPrefix) appendInt(tmpBuf.byteLength); - buf = Buffer.concat([buf, tmpBuf]); - }; - const appendEncBase64 = (b64: string): void => { - const b = decodeBase64(b64); - const tmpBuf = Buffer.from(b); - buf = Buffer.concat([buf, tmpBuf]); - }; - - // Actually build the buffer for the QR code - appendStr(qrData.prefix, "ascii", false); - appendByte(qrData.version); - appendByte(qrData.mode); - appendStr(qrData.transactionId!, "utf-8"); - appendEncBase64(qrData.firstKeyB64); - appendEncBase64(qrData.secondKeyB64); - appendEncBase64(qrData.secretB64); - - return buf; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SAS.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SAS.ts deleted file mode 100644 index a8d237d..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SAS.ts +++ /dev/null @@ -1,492 +0,0 @@ -/* -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. - */ - -import anotherjson from "another-json"; -import { Utility, SAS as OlmSAS } from "@matrix-org/olm"; - -import { VerificationBase as Base, SwitchStartEventError, VerificationEventHandlerMap } from "./Base"; -import { - errorFactory, - newInvalidMessageError, - newKeyMismatchError, - newUnknownMethodError, - newUserCancelledError, -} from "./Error"; -import { logger } from "../../logger"; -import { IContent, MatrixEvent } from "../../models/event"; -import { generateDecimalSas } from "./SASDecimal"; -import { EventType } from "../../@types/event"; - -const START_TYPE = EventType.KeyVerificationStart; - -const EVENTS = [EventType.KeyVerificationAccept, EventType.KeyVerificationKey, EventType.KeyVerificationMac]; - -let olmutil: Utility; - -const newMismatchedSASError = errorFactory("m.mismatched_sas", "Mismatched short authentication string"); - -const newMismatchedCommitmentError = errorFactory("m.mismatched_commitment", "Mismatched commitment"); - -type EmojiMapping = [emoji: string, name: string]; - -const emojiMapping: 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: number[]): EmojiMapping[] { - const emojis = [ - // just like base64 encoding - sasBytes[0] >> 2, - ((sasBytes[0] & 0x3) << 4) | (sasBytes[1] >> 4), - ((sasBytes[1] & 0xf) << 2) | (sasBytes[2] >> 6), - sasBytes[2] & 0x3f, - sasBytes[3] >> 2, - ((sasBytes[3] & 0x3) << 4) | (sasBytes[4] >> 4), - ((sasBytes[4] & 0xf) << 2) | (sasBytes[5] >> 6), - ]; - - return emojis.map((num) => emojiMapping[num]); -} - -const sasGenerators = { - decimal: generateDecimalSas, - emoji: generateEmojiSas, -} as const; - -export interface IGeneratedSas { - decimal?: [number, number, number]; - emoji?: EmojiMapping[]; -} - -export interface ISasEvent { - sas: IGeneratedSas; - confirm(): Promise<void>; - cancel(): void; - mismatch(): void; -} - -function generateSas(sasBytes: Uint8Array, methods: string[]): IGeneratedSas { - const sas: IGeneratedSas = {}; - 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", -} as const; - -type MacMethod = keyof typeof macMethods; - -function calculateMAC(olmSAS: OlmSAS, method: MacMethod) { - return function (input: string, info: string): string { - const mac = olmSAS[macMethods[method]](input, info); - 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: SAS, olmSAS: OlmSAS, bytes: number): Uint8Array { - 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: SAS, olmSAS: OlmSAS, bytes: number): Uint8Array { - 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); - }, -} as const; - -type KeyAgreement = keyof typeof calculateKeyAgreement; - -/* 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: KeyAgreement[] = ["curve25519-hkdf-sha256", "curve25519"]; -const HASHES_LIST = ["sha256"]; -const MAC_LIST: MacMethod[] = [ - "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<T>(anArray: T[], aSet: Set<T>): T[] { - return Array.isArray(anArray) ? anArray.filter((x) => aSet.has(x)) : []; -} - -export enum SasEvent { - ShowSas = "show_sas", -} - -type EventHandlerMap = { - [SasEvent.ShowSas]: (sas: ISasEvent) => void; -} & VerificationEventHandlerMap; - -export class SAS extends Base<SasEvent, EventHandlerMap> { - private waitingForAccept?: boolean; - public ourSASPubKey?: string; - public theirSASPubKey?: string; - public sasEvent?: ISasEvent; - - // eslint-disable-next-line @typescript-eslint/naming-convention - public static get NAME(): string { - return "m.sas.v1"; - } - - public get events(): string[] { - return EVENTS; - } - - protected doVerification = async (): Promise<void> => { - await global.Olm.init(); - olmutil = olmutil || new global.Olm.Utility(); - - // make sure user's keys are downloaded - await this.baseApis.downloadKeys([this.userId]); - - let retry = false; - do { - try { - if (this.initiatedByMe) { - return await this.doSendVerification(); - } else { - return await this.doRespondVerification(); - } - } catch (err) { - if (err instanceof SwitchStartEventError) { - // this changes what initiatedByMe returns - this.startEvent = err.startEvent; - retry = true; - } else { - throw err; - } - } - } while (retry); - }; - - public canSwitchStartEvent(event: MatrixEvent): boolean { - if (event.getType() !== START_TYPE) { - return false; - } - const content = event.getContent(); - return content?.method === SAS.NAME && !!this.waitingForAccept; - } - - private async sendStart(): Promise<Record<string, any>> { - 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; - } - - private async verifyAndCheckMAC( - keyAgreement: KeyAgreement, - sasMethods: string[], - olmSAS: OlmSAS, - macMethod: MacMethod, - ): Promise<void> { - const sasBytes = calculateKeyAgreement[keyAgreement](this, olmSAS, 6); - const verifySAS = new Promise<void>((resolve, reject) => { - this.sasEvent = { - sas: generateSas(sasBytes, sasMethods), - confirm: async (): Promise<void> => { - try { - await this.sendMAC(olmSAS, macMethod); - resolve(); - } catch (err) { - reject(err); - } - }, - cancel: () => reject(newUserCancelledError()), - mismatch: () => reject(newMismatchedSASError()), - }; - this.emit(SasEvent.ShowSas, this.sasEvent); - }); - - const [e] = await Promise.all([ - this.waitForEvent(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 = EventType.KeyVerificationDone; - return e; - }), - verifySAS, - ]); - const content = e.getContent(); - await this.checkMAC(olmSAS, content, macMethod); - } - - private async doSendVerification(): Promise<void> { - this.waitingForAccept = true; - let startContent; - if (this.startEvent) { - startContent = this.channel.completedContentFromEvent(this.startEvent); - } else { - startContent = await this.sendStart(); - } - - // we might have switched to a different start event, - // but was we didn't call _waitForEvent there was no - // call that could throw yet. So check manually that - // we're still on the initiator side - if (!this.initiatedByMe) { - throw new SwitchStartEventError(this.startEvent); - } - - let e: MatrixEvent; - try { - e = await this.waitForEvent(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 newUnknownMethodError(); - } - if (typeof content.commitment !== "string") { - throw newInvalidMessageError(); - } - const keyAgreement = content.key_agreement_protocol; - const macMethod = content.message_authentication_code; - const hashCommitment = content.commitment; - const olmSAS = new global.Olm.SAS(); - try { - this.ourSASPubKey = olmSAS.get_pubkey(); - await this.send(EventType.KeyVerificationKey, { - key: this.ourSASPubKey, - }); - - e = await this.waitForEvent(EventType.KeyVerificationKey); - // FIXME: make sure event is properly formed - content = e.getContent(); - const commitmentStr = content.key + anotherjson.stringify(startContent); - // TODO: use selected hash function (when we support multiple) - if (olmutil.sha256(commitmentStr) !== hashCommitment) { - throw newMismatchedCommitmentError(); - } - this.theirSASPubKey = content.key; - olmSAS.set_their_key(content.key); - - await this.verifyAndCheckMAC(keyAgreement, sasMethods, olmSAS, macMethod); - } finally { - olmSAS.free(); - } - } - - private async doRespondVerification(): Promise<void> { - // as m.related_to is not included in the encrypted content in e2e rooms, - // we need to make sure it is added - let content = this.channel.completedContentFromEvent(this.startEvent!); - - // Note: we intersect using our pre-made lists, rather than the sets, - // so that the result will be in our order of preference. Then - // fetching the first element from the array will give our preferred - // method out of the ones offered by the other party. - const keyAgreement = intersection(KEY_AGREEMENT_LIST, new Set(content.key_agreement_protocols))[0]; - const hashMethod = intersection(HASHES_LIST, new Set(content.hashes))[0]; - const macMethod = intersection(MAC_LIST, new Set(content.message_authentication_codes))[0]; - // FIXME: allow app to specify what SAS methods can be used - const sasMethods = intersection(content.short_authentication_string, SAS_SET); - if (!(keyAgreement !== undefined && hashMethod !== undefined && macMethod !== undefined && sasMethods.length)) { - throw newUnknownMethodError(); - } - - const olmSAS = new global.Olm.SAS(); - try { - const commitmentStr = olmSAS.get_pubkey() + anotherjson.stringify(content); - await this.send(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(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(EventType.KeyVerificationKey, { - key: this.ourSASPubKey, - }); - - await this.verifyAndCheckMAC(keyAgreement, sasMethods, olmSAS, macMethod); - } finally { - olmSAS.free(); - } - } - - private sendMAC(olmSAS: OlmSAS, method: MacMethod): Promise<void> { - const mac: Record<string, string> = {}; - const keyList: string[] = []; - 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(EventType.KeyVerificationMac, { mac, keys }); - } - - private async checkMAC(olmSAS: OlmSAS, content: IContent, method: MacMethod): Promise<void> { - const baseInfo = - "MATRIX_KEY_VERIFICATION_MAC" + - this.userId + - this.deviceId + - this.baseApis.getUserId() + - this.baseApis.deviceId + - this.channel.transactionId; - - if ( - content.keys !== - calculateMAC(olmSAS, method)(Object.keys(content.mac).sort().join(","), baseInfo + "KEY_IDS") - ) { - throw newKeyMismatchError(); - } - - await this.verifyKeys(this.userId, content.mac, (keyId, device, keyInfo) => { - if (keyInfo !== calculateMAC(olmSAS, method)(device.keys[keyId], baseInfo + keyId)) { - throw newKeyMismatchError(); - } - }); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SASDecimal.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SASDecimal.ts deleted file mode 100644 index 0cb4630..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SASDecimal.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* -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 - */ -export function generateDecimalSas(sasBytes: number[]): [number, number, number] { - /* - * +--------+--------+--------+--------+--------+ - * | 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, - ]; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/Channel.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/Channel.ts deleted file mode 100644 index 48415f9..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/Channel.ts +++ /dev/null @@ -1,34 +0,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. -*/ - -import { MatrixEvent } from "../../../models/event"; -import { VerificationRequest } from "./VerificationRequest"; - -export interface IVerificationChannel { - request?: VerificationRequest; - readonly userId?: string; - readonly roomId?: string; - readonly deviceId?: string; - readonly transactionId?: string; - readonly receiveStartFromOtherDevices?: boolean; - getTimestamp(event: MatrixEvent): number; - send(type: string, uncompletedContent: Record<string, any>): Promise<void>; - completeContent(type: string, content: Record<string, any>): Record<string, any>; - sendCompleted(type: string, content: Record<string, any>): Promise<void>; - completedContentFromEvent(event: MatrixEvent): Record<string, any>; - canCreateRequest(type: string): boolean; - handleEvent(event: MatrixEvent, request: VerificationRequest, isLiveEvent: boolean): Promise<void>; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/InRoomChannel.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/InRoomChannel.ts deleted file mode 100644 index ff11bf1..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/InRoomChannel.ts +++ /dev/null @@ -1,356 +0,0 @@ -/* -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { VerificationRequest, REQUEST_TYPE, READY_TYPE, START_TYPE } from "./VerificationRequest"; -import { logger } from "../../../logger"; -import { IVerificationChannel } from "./Channel"; -import { EventType } from "../../../@types/event"; -import { MatrixClient } from "../../../client"; -import { MatrixEvent } from "../../../models/event"; -import { IRequestsMap } from "../.."; - -const MESSAGE_TYPE = 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. - */ -export class InRoomChannel implements IVerificationChannel { - private requestEventId?: string; - - /** - * @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. - */ - public constructor(private readonly client: MatrixClient, public readonly roomId: string, public userId?: string) {} - - public get receiveStartFromOtherDevices(): boolean { - return true; - } - - /** The transaction id generated/used by this verification channel */ - public get transactionId(): string | undefined { - return this.requestEventId; - } - - public static getOtherPartyUserId(event: MatrixEvent, client: MatrixClient): string | undefined { - const type = InRoomChannel.getEventType(event); - if (type !== REQUEST_TYPE) { - return; - } - const ownUserId = client.getUserId(); - const sender = event.getSender(); - const content = event.getContent(); - const receiver = content.to; - - if (sender === ownUserId) { - return receiver; - } else if (receiver === ownUserId) { - return sender; - } - } - - /** - * @param event - the event to get the timestamp of - * @returns the timestamp when the event was sent - */ - public getTimestamp(event: MatrixEvent): number { - 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 - */ - public static canCreateRequest(type: string): boolean { - return type === REQUEST_TYPE; - } - - public canCreateRequest(type: string): boolean { - 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 - */ - public static getTransactionId(event: MatrixEvent): string | undefined { - if (InRoomChannel.getEventType(event) === 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 - */ - public static validateEvent(event: MatrixEvent, client: MatrixClient): boolean { - const txnId = InRoomChannel.getTransactionId(event); - if (typeof txnId !== "string" || txnId.length === 0) { - return false; - } - const type = InRoomChannel.getEventType(event); - const content = event.getContent(); - - // from here on we're fairly sure that this is supposed to be - // part of a verification request, so be noisy when rejecting something - if (type === REQUEST_TYPE) { - if (!content || typeof content.to !== "string" || !content.to.length) { - logger.log("InRoomChannel: validateEvent: " + "no valid to " + (content && content.to)); - return false; - } - - // ignore requests that are not direct to or sent by the syncing user - if (!InRoomChannel.getOtherPartyUserId(event, client)) { - logger.log( - "InRoomChannel: validateEvent: " + - `not directed to or sent by me: ${event.getSender()}` + - `, ${content && content.to}`, - ); - return false; - } - } - - return VerificationRequest.validateEvent(type, event, client); - } - - /** - * As m.key.verification.request events are as m.room.message events with the InRoomChannel - * to have a fallback message in non-supporting clients, we map the real event type - * to the symbolic one to keep things in unison with ToDeviceChannel - * @param event - the event to get the type of - * @returns the "symbolic" event type - */ - public static getEventType(event: MatrixEvent): string { - const type = event.getType(); - if (type === MESSAGE_TYPE) { - const content = event.getContent(); - if (content) { - const { msgtype } = content; - if (msgtype === REQUEST_TYPE) { - return REQUEST_TYPE; - } - } - } - if (type && type !== REQUEST_TYPE) { - return type; - } else { - return ""; - } - } - - /** - * Changes the state of the channel, request, and verifier in response to a key verification event. - * @param 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. - */ - public async handleEvent(event: MatrixEvent, request: VerificationRequest, isLiveEvent = false): Promise<void> { - // 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.log(`InRoomChannel: ignoring verification event from non-participating sender ${sender}`); - return; - } - } - if (!this.requestEventId) { - this.requestEventId = InRoomChannel.getTransactionId(event); - } - - const isRemoteEcho = !!event.getUnsigned().transaction_id; - const isSentByUs = event.getSender() === this.client.getUserId(); - - return request.handleEvent(type, event, isLiveEvent, isRemoteEcho, isSentByUs); - } - - /** - * Adds the transaction id (relation) back to a received event - * so it has the same format as returned by `completeContent` before sending. - * The relation can not appear on the event content because of encryption, - * relations are excluded from encryption. - * @param event - the received event - * @returns the content object with the relation added again - */ - public completedContentFromEvent(event: MatrixEvent): Record<string, any> { - // 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. - */ - public completeContent(type: string, content: Record<string, any>): Record<string, any> { - content = Object.assign({}, content); - if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) { - content.from_device = this.client.getDeviceId(); - } - if (type === REQUEST_TYPE) { - // type is mapped to m.room.message in the send method - content = { - body: - this.client.getUserId() + - " is requesting to verify " + - "your key, but your client does not support in-chat key " + - "verification. You will need to use legacy key " + - "verification to verify keys.", - msgtype: REQUEST_TYPE, - to: this.userId, - from_device: content.from_device, - methods: content.methods, - }; - } else { - content[M_RELATES_TO] = { - rel_type: M_REFERENCE, - event_id: this.transactionId, - }; - } - return content; - } - - /** - * Send an event over the channel with the content not having gone through `completeContent`. - * @param type - the event type - * @param uncompletedContent - the (incomplete) content - * @returns the promise of the request - */ - public send(type: string, uncompletedContent: Record<string, any>): Promise<void> { - 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 - */ - public async sendCompleted(type: string, content: Record<string, any>): Promise<void> { - let sendType = type; - if (type === REQUEST_TYPE) { - sendType = MESSAGE_TYPE; - } - const response = await this.client.sendEvent(this.roomId, sendType, content); - if (type === REQUEST_TYPE) { - this.requestEventId = response.event_id; - } - } -} - -export class InRoomRequests implements IRequestsMap { - private requestsByRoomId = new Map<string, Map<string, VerificationRequest>>(); - - public getRequest(event: MatrixEvent): VerificationRequest | undefined { - const roomId = event.getRoomId()!; - const txnId = InRoomChannel.getTransactionId(event)!; - return this.getRequestByTxnId(roomId, txnId); - } - - public getRequestByChannel(channel: InRoomChannel): VerificationRequest | undefined { - return this.getRequestByTxnId(channel.roomId, channel.transactionId!); - } - - private getRequestByTxnId(roomId: string, txnId: string): VerificationRequest | undefined { - const requestsByTxnId = this.requestsByRoomId.get(roomId); - if (requestsByTxnId) { - return requestsByTxnId.get(txnId); - } - } - - public setRequest(event: MatrixEvent, request: VerificationRequest): void { - this.doSetRequest(event.getRoomId()!, InRoomChannel.getTransactionId(event)!, request); - } - - public setRequestByChannel(channel: IVerificationChannel, request: VerificationRequest): void { - this.doSetRequest(channel.roomId!, channel.transactionId!, request); - } - - private doSetRequest(roomId: string, txnId: string, request: VerificationRequest): void { - let requestsByTxnId = this.requestsByRoomId.get(roomId); - if (!requestsByTxnId) { - requestsByTxnId = new Map(); - this.requestsByRoomId.set(roomId, requestsByTxnId); - } - requestsByTxnId.set(txnId, request); - } - - public removeRequest(event: MatrixEvent): void { - 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); - } - } - } - - public findRequestInProgress(roomId: string): VerificationRequest | undefined { - const requestsByTxnId = this.requestsByRoomId.get(roomId); - if (requestsByTxnId) { - for (const request of requestsByTxnId.values()) { - if (request.pending) { - return request; - } - } - } - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/ToDeviceChannel.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/ToDeviceChannel.ts deleted file mode 100644 index d51b85a..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/ToDeviceChannel.ts +++ /dev/null @@ -1,354 +0,0 @@ -/* -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { randomString } from "../../../randomstring"; -import { logger } from "../../../logger"; -import { - CANCEL_TYPE, - PHASE_STARTED, - PHASE_READY, - REQUEST_TYPE, - READY_TYPE, - START_TYPE, - VerificationRequest, -} from "./VerificationRequest"; -import { errorFromEvent, newUnexpectedMessageError } from "../Error"; -import { MatrixEvent } from "../../../models/event"; -import { IVerificationChannel } from "./Channel"; -import { MatrixClient } from "../../../client"; -import { IRequestsMap } from "../.."; - -export type Request = VerificationRequest<ToDeviceChannel>; - -/** - * A key verification channel that sends verification events over to_device messages. - * Generates its own transaction ids. - */ -export class ToDeviceChannel implements IVerificationChannel { - public request?: VerificationRequest; - - // userId and devices of user we're about to verify - public constructor( - private readonly client: MatrixClient, - public readonly userId: string, - private readonly devices: string[], - public transactionId?: string, - public deviceId?: string, - ) {} - - public isToDevices(devices: string[]): boolean { - if (devices.length === this.devices.length) { - for (const device of devices) { - if (!this.devices.includes(device)) { - return false; - } - } - return true; - } else { - return false; - } - } - - public static getEventType(event: MatrixEvent): string { - return event.getType(); - } - - /** - * Extract the transaction id used by a given key verification event, if any - * @param event - the event - * @returns the transaction id - */ - public static getTransactionId(event: MatrixEvent): string { - 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 - */ - public static canCreateRequest(type: string): boolean { - return type === REQUEST_TYPE || type === START_TYPE; - } - - public canCreateRequest(type: string): boolean { - 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 - */ - public static validateEvent(event: MatrixEvent, client: MatrixClient): boolean { - if (event.isCancelled()) { - logger.warn("Ignoring flagged verification request from " + event.getSender()); - return false; - } - const content = event.getContent(); - if (!content) { - logger.warn("ToDeviceChannel.validateEvent: invalid: no content"); - return false; - } - - if (!content.transaction_id) { - logger.warn("ToDeviceChannel.validateEvent: invalid: no transaction_id"); - return false; - } - - const type = event.getType(); - - if (type === REQUEST_TYPE) { - if (!Number.isFinite(content.timestamp)) { - logger.warn("ToDeviceChannel.validateEvent: invalid: no timestamp"); - return false; - } - if (event.getSender() === client.getUserId() && content.from_device == client.getDeviceId()) { - // ignore requests from ourselves, because it doesn't make sense for a - // device to verify itself - logger.warn("ToDeviceChannel.validateEvent: invalid: from own device"); - return false; - } - } - - return VerificationRequest.validateEvent(type, event, client); - } - - /** - * @param event - the event to get the timestamp of - * @returns the timestamp when the event was sent - */ - public getTimestamp(event: MatrixEvent): number { - 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. - */ - public async handleEvent(event: MatrixEvent, request: Request, isLiveEvent = false): Promise<void> { - const type = event.getType(); - const content = event.getContent(); - if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) { - if (!this.transactionId) { - this.transactionId = content.transaction_id; - } - const deviceId = content.from_device; - // adopt deviceId if not set before and valid - if (!this.deviceId && this.devices.includes(deviceId)) { - this.deviceId = deviceId; - } - // if no device id or different from 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(CANCEL_TYPE, errorFromEvent(newUnexpectedMessageError())); - return this.sendToDevices(CANCEL_TYPE, cancelContent, [deviceId]); - } - } - const wasStarted = request.phase === PHASE_STARTED || request.phase === PHASE_READY; - - await request.handleEvent(event.getType(), event, isLiveEvent, false, false); - - const isStarted = request.phase === PHASE_STARTED || request.phase === PHASE_READY; - - const isAcceptingEvent = type === START_TYPE || type === READY_TYPE; - // the request has picked a ready or start event, tell the other devices about it - if (isAcceptingEvent && !wasStarted && isStarted && this.deviceId) { - const nonChosenDevices = this.devices.filter((d) => d !== this.deviceId && d !== this.client.getDeviceId()); - if (nonChosenDevices.length) { - const message = this.completeContent(CANCEL_TYPE, { - code: "m.accepted", - reason: "Verification request accepted by another device", - }); - await this.sendToDevices(CANCEL_TYPE, message, nonChosenDevices); - } - } - } - - /** - * See {@link InRoomChannel#completedContentFromEvent} for why this is needed. - * @param event - the received event - * @returns the content object - */ - public completedContentFromEvent(event: MatrixEvent): Record<string, any> { - 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. - */ - public completeContent(type: string, content: Record<string, any>): Record<string, any> { - // make a copy - content = Object.assign({}, content); - if (this.transactionId) { - content.transaction_id = this.transactionId; - } - if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) { - content.from_device = this.client.getDeviceId(); - } - if (type === REQUEST_TYPE) { - content.timestamp = Date.now(); - } - return content; - } - - /** - * Send an event over the channel with the content not having gone through `completeContent`. - * @param type - the event type - * @param uncompletedContent - the (incomplete) content - * @returns the promise of the request - */ - public send(type: string, uncompletedContent: Record<string, any> = {}): Promise<void> { - // create transaction id when sending request - if ((type === REQUEST_TYPE || type === START_TYPE) && !this.transactionId) { - this.transactionId = ToDeviceChannel.makeTransactionId(); - } - const content = this.completeContent(type, uncompletedContent); - return this.sendCompleted(type, content); - } - - /** - * Send an event over the channel with the content having gone through `completeContent` already. - * @param type - the event type - * @returns the promise of the request - */ - public async sendCompleted(type: string, content: Record<string, any>): Promise<void> { - let result; - if (type === REQUEST_TYPE || (type === 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 MatrixEvent({ - sender: this.client.getUserId()!, - content, - type, - }); - await this.request!.handleEvent( - type, - remoteEchoEvent, - /*isLiveEvent=*/ true, - /*isRemoteEcho=*/ true, - /*isSentByUs=*/ true, - ); - return result; - } - - private async sendToDevices(type: string, content: Record<string, any>, devices: string[]): Promise<void> { - if (devices.length) { - const deviceMessages: Map<string, Record<string, any>> = 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 - */ - public static makeTransactionId(): string { - return randomString(32); - } -} - -export class ToDeviceRequests implements IRequestsMap { - private requestsByUserId = new Map<string, Map<string, Request>>(); - - public getRequest(event: MatrixEvent): Request | undefined { - return this.getRequestBySenderAndTxnId(event.getSender()!, ToDeviceChannel.getTransactionId(event)); - } - - public getRequestByChannel(channel: ToDeviceChannel): Request | undefined { - return this.getRequestBySenderAndTxnId(channel.userId, channel.transactionId!); - } - - public getRequestBySenderAndTxnId(sender: string, txnId: string): Request | undefined { - const requestsByTxnId = this.requestsByUserId.get(sender); - if (requestsByTxnId) { - return requestsByTxnId.get(txnId); - } - } - - public setRequest(event: MatrixEvent, request: Request): void { - this.setRequestBySenderAndTxnId(event.getSender()!, ToDeviceChannel.getTransactionId(event), request); - } - - public setRequestByChannel(channel: ToDeviceChannel, request: Request): void { - this.setRequestBySenderAndTxnId(channel.userId, channel.transactionId!, request); - } - - public setRequestBySenderAndTxnId(sender: string, txnId: string, request: Request): void { - let requestsByTxnId = this.requestsByUserId.get(sender); - if (!requestsByTxnId) { - requestsByTxnId = new Map(); - this.requestsByUserId.set(sender, requestsByTxnId); - } - requestsByTxnId.set(txnId, request); - } - - public removeRequest(event: MatrixEvent): void { - 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); - } - } - } - - public findRequestInProgress(userId: string, devices: string[]): Request | undefined { - const requestsByTxnId = this.requestsByUserId.get(userId); - if (requestsByTxnId) { - for (const request of requestsByTxnId.values()) { - if (request.pending && request.channel.isToDevices(devices)) { - return request; - } - } - } - } - - public getRequestsInProgress(userId: string): Request[] { - const requestsByTxnId = this.requestsByUserId.get(userId); - if (requestsByTxnId) { - return Array.from(requestsByTxnId.values()).filter((r) => r.pending); - } - return []; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/VerificationRequest.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/VerificationRequest.ts deleted file mode 100644 index 617432e..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/VerificationRequest.ts +++ /dev/null @@ -1,926 +0,0 @@ -/* -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. -*/ - -import { logger } from "../../../logger"; -import { errorFactory, errorFromEvent, newUnexpectedMessageError, newUnknownMethodError } from "../Error"; -import { QRCodeData, SCAN_QR_CODE_METHOD } from "../QRCode"; -import { IVerificationChannel } from "./Channel"; -import { MatrixClient } from "../../../client"; -import { MatrixEvent } from "../../../models/event"; -import { EventType } from "../../../@types/event"; -import { VerificationBase } from "../Base"; -import { VerificationMethod } from "../../index"; -import { TypedEventEmitter } from "../../../models/typed-event-emitter"; - -// How long after the event's timestamp that the request times out -const TIMEOUT_FROM_EVENT_TS = 10 * 60 * 1000; // 10 minutes - -// How long after we receive the event that the request times out -const TIMEOUT_FROM_EVENT_RECEIPT = 2 * 60 * 1000; // 2 minutes - -// to avoid almost expired verification notifications -// from showing a notification and almost immediately -// disappearing, also ignore verification requests that -// are this amount of time away from expiring. -const VERIFICATION_REQUEST_MARGIN = 3 * 1000; // 3 seconds - -export const EVENT_PREFIX = "m.key.verification."; -export const REQUEST_TYPE = EVENT_PREFIX + "request"; -export const START_TYPE = EVENT_PREFIX + "start"; -export const CANCEL_TYPE = EVENT_PREFIX + "cancel"; -export const DONE_TYPE = EVENT_PREFIX + "done"; -export const READY_TYPE = EVENT_PREFIX + "ready"; - -export enum Phase { - Unsent = 1, - Requested, - Ready, - Started, - Cancelled, - Done, -} - -// Legacy export fields -export const PHASE_UNSENT = Phase.Unsent; -export const PHASE_REQUESTED = Phase.Requested; -export const PHASE_READY = Phase.Ready; -export const PHASE_STARTED = Phase.Started; -export const PHASE_CANCELLED = Phase.Cancelled; -export const PHASE_DONE = Phase.Done; - -interface ITargetDevice { - userId?: string; - deviceId?: string; -} - -interface ITransition { - phase: Phase; - event?: MatrixEvent; -} - -export enum VerificationRequestEvent { - Change = "change", -} - -type EventHandlerMap = { - /** - * Fires whenever the state of the request object has changed. - */ - [VerificationRequestEvent.Change]: () => void; -}; - -/** - * 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`. - */ -export class VerificationRequest<C extends IVerificationChannel = IVerificationChannel> extends TypedEventEmitter< - VerificationRequestEvent, - EventHandlerMap -> { - private eventsByUs = new Map<string, MatrixEvent>(); - private eventsByThem = new Map<string, MatrixEvent>(); - private _observeOnly = false; - private timeoutTimer: ReturnType<typeof setTimeout> | null = null; - private _accepting = false; - private _declining = false; - private verifierHasFinished = false; - private _cancelled = false; - private _chosenMethod: VerificationMethod | null = 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 - private _qrCodeData: QRCodeData | null = null; - - // The timestamp when we received the request event from the other side - private requestReceivedAt: number | null = null; - - private commonMethods: VerificationMethod[] = []; - private _phase!: Phase; - public _cancellingUserId?: string; // Used in tests only - private _verifier?: VerificationBase<any, any>; - - public constructor( - public readonly channel: C, - private readonly verificationMethods: Map<VerificationMethod, typeof VerificationBase>, - private readonly client: MatrixClient, - ) { - super(); - 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 - */ - public static validateEvent(type: string, event: MatrixEvent, client: MatrixClient): boolean { - const content = event.getContent(); - - if (!type || !type.startsWith(EVENT_PREFIX)) { - return false; - } - - // from here on we're fairly sure that this is supposed to be - // part of a verification request, so be noisy when rejecting something - if (!content) { - logger.log("VerificationRequest: validateEvent: no content"); - return false; - } - - if (type === REQUEST_TYPE || type === READY_TYPE) { - if (!Array.isArray(content.methods)) { - logger.log("VerificationRequest: validateEvent: " + "fail because methods"); - return false; - } - } - - if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) { - if (typeof content.from_device !== "string" || content.from_device.length === 0) { - logger.log("VerificationRequest: validateEvent: " + "fail because from_device"); - return false; - } - } - - return true; - } - - public get invalid(): boolean { - return this.phase === PHASE_UNSENT; - } - - /** returns whether the phase is PHASE_REQUESTED */ - public get requested(): boolean { - return this.phase === PHASE_REQUESTED; - } - - /** returns whether the phase is PHASE_CANCELLED */ - public get cancelled(): boolean { - return this.phase === PHASE_CANCELLED; - } - - /** returns whether the phase is PHASE_READY */ - public get ready(): boolean { - return this.phase === PHASE_READY; - } - - /** returns whether the phase is PHASE_STARTED */ - public get started(): boolean { - return this.phase === PHASE_STARTED; - } - - /** returns whether the phase is PHASE_DONE */ - public get done(): boolean { - return this.phase === PHASE_DONE; - } - - /** once the phase is PHASE_STARTED (and !initiatedByMe) or PHASE_READY: common methods supported by both sides */ - public get methods(): VerificationMethod[] { - return this.commonMethods; - } - - /** the method picked in the .start event */ - public get chosenMethod(): VerificationMethod | null { - return this._chosenMethod; - } - - public calculateEventTimeout(event: MatrixEvent): number { - 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 */ - public get timeout(): number { - 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. - */ - public get requestEvent(): MatrixEvent | undefined { - return this.getEventByEither(REQUEST_TYPE); - } - - /** current phase of the request. Some properties might only be defined in a current phase. */ - public get phase(): 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. */ - public get verifier(): VerificationBase<any, any> | undefined { - return this._verifier; - } - - public get canAccept(): boolean { - return this.phase < PHASE_READY && !this._accepting && !this._declining; - } - - public get accepting(): boolean { - return this._accepting; - } - - public get declining(): boolean { - return this._declining; - } - - /** whether this request has sent it's initial event and needs more events to complete */ - public get pending(): boolean { - 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 */ - public get qrCodeData(): QRCodeData | null { - 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 */ - public otherPartySupportsMethod(method: string, force = false): boolean { - 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 - */ - public get initiatedByMe(): boolean { - // 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 */ - public get requestingUserId(): string { - if (this.initiatedByMe) { - return this.client.getUserId()!; - } else { - return this.otherUserId; - } - } - - /** The id of the user that (will) receive(d) the request */ - public get receivingUserId(): string { - if (this.initiatedByMe) { - return this.otherUserId; - } else { - return this.client.getUserId()!; - } - } - - /** The user id of the other party in this request */ - public get otherUserId(): string { - return this.channel.userId!; - } - - public get isSelfVerification(): boolean { - return this.client.getUserId() === this.otherUserId; - } - - /** - * The id of the user that cancelled the request, - * only defined when phase is PHASE_CANCELLED - */ - public get cancellingUserId(): string | undefined { - 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 - */ - public get cancellationCode(): string { - const ev = this.getEventByEither(CANCEL_TYPE); - return ev ? ev.getContent().code : null; - } - - public get observeOnly(): boolean { - 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 - */ - public get targetDevice(): ITargetDevice { - 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 - */ - public beginKeyVerification( - method: VerificationMethod, - targetDevice: ITargetDevice | null = null, - ): VerificationBase<any, any> { - // 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 newUnknownMethodError(); - } - this._verifier = this.createVerifier(method, null, targetDevice); - if (!this._verifier) { - throw newUnknownMethodError(); - } - this._chosenMethod = method; - } - } - return this._verifier!; - } - - /** - * sends the initial .request event. - * @returns resolves when the event has been sent. - */ - public async sendRequest(): Promise<void> { - 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. - */ - public async cancel({ reason = "User declined", code = "m.user" } = {}): Promise<void> { - if (!this.observeOnly && this._phase !== PHASE_CANCELLED) { - this._declining = true; - this.emit(VerificationRequestEvent.Change); - if (this._verifier) { - return this._verifier.cancel(errorFactory(code, reason)()); - } else { - this._cancellingUserId = this.client.getUserId()!; - await this.channel.send(CANCEL_TYPE, { code, reason }); - } - } - } - - /** - * Accepts the request, sending a .ready event to the other party - * @returns resolves when the event has been sent. - */ - public async accept(): Promise<void> { - 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 - */ - public waitFor(fn: (request: VerificationRequest) => boolean): Promise<VerificationRequest> { - return new Promise((resolve, reject) => { - const check = (): boolean => { - 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); - } - }); - } - - private setPhase(phase: Phase, notify = true): void { - this._phase = phase; - if (notify) { - this.emit(VerificationRequestEvent.Change); - } - } - - private getEventByEither(type: string): MatrixEvent | undefined { - return this.eventsByThem.get(type) || this.eventsByUs.get(type); - } - - private getEventBy(type: string, byThem = false): MatrixEvent | undefined { - if (byThem) { - return this.eventsByThem.get(type); - } else { - return this.eventsByUs.get(type); - } - } - - private calculatePhaseTransitions(): ITransition[] { - const transitions: ITransition[] = [{ phase: PHASE_UNSENT }]; - const phase = (): 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: MatrixEvent | undefined; - 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; - } - - private transitionToPhase(transition: ITransition): void { - const { phase, event } = transition; - // get common methods - if (phase === PHASE_REQUESTED || phase === PHASE_READY) { - if (!this.wasSentByOwnDevice(event)) { - const content = event!.getContent<{ - methods: string[]; - }>(); - 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; - } - } - } - } - - private applyPhaseTransitions(): ITransition[] { - 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; - } - - private isWinningStartRace(newEvent: MatrixEvent): boolean { - 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; - } - - public hasEventId(eventId: string): boolean { - 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. - */ - public async handleEvent( - type: string, - event: MatrixEvent, - isLiveEvent: boolean, - isRemoteEcho: boolean, - isSentByUs: boolean, - ): Promise<void> { - // 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(SCAN_QR_CODE_METHOD, true); - if (shouldGenerateQrCode) { - this._qrCodeData = await QRCodeData.create(this, this.client); - } - } - - const lastTransition = newTransitions[newTransitions.length - 1]; - const { phase } = lastTransition; - - this.setupTimeout(phase); - // set phase as last thing as this emits the "change" event - this.setPhase(phase); - } else if (this._observeOnly !== wasObserveOnly) { - this.emit(VerificationRequestEvent.Change); - } - } finally { - // log events we processed so we can see from rageshakes what events were added to a request - logger.log( - `Verification request ${this.channel.transactionId}: ` + - `${type} event with id:${event.getId()}, ` + - `content:${JSON.stringify(event.getContent())} ` + - `deviceId:${this.channel.deviceId}, ` + - `sender:${event.getSender()}, isSentByUs:${isSentByUs}, ` + - `isLiveEvent:${isLiveEvent}, isRemoteEcho:${isRemoteEcho}, ` + - `phase:${oldPhase}=>${this.phase}, ` + - `observeOnly:${wasObserveOnly}=>${this._observeOnly}`, - ); - } - } - - private setupTimeout(phase: Phase): void { - 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; - } - } - } - - private cancelOnTimeout = async (): Promise<void> => { - 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.error("Error while cancelling verification request", err); - } - }; - - private async cancelOnError(type: string, event: MatrixEvent): Promise<boolean> { - if (type === START_TYPE) { - const method = event.getContent().method; - if (!this.verificationMethods.has(method)) { - await this.cancel(errorFromEvent(newUnknownMethodError())); - return true; - } - } - - const isUnexpectedRequest = type === REQUEST_TYPE && this.phase !== PHASE_UNSENT; - const isUnexpectedReady = type === READY_TYPE && this.phase !== PHASE_REQUESTED && 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.warn(`Cancelling, unexpected ${type} verification ` + `event from ${event.getSender()}`); - const reason = `Unexpected ${type} event in phase ${this.phase}`; - await this.cancel(errorFromEvent(newUnexpectedMessageError({ reason }))); - return true; - } - return false; - } - - private adjustObserveOnly(event: MatrixEvent, isLiveEvent = false): void { - // don't send out events for historical requests - if (!isLiveEvent) { - this._observeOnly = true; - } - if (this.calculateEventTimeout(event) < VERIFICATION_REQUEST_MARGIN) { - this._observeOnly = true; - } - } - - private addEvent(type: string, event: MatrixEvent, isSentByUs = false): void { - 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(); - } - } - - private createVerifier( - method: VerificationMethod, - startEvent: MatrixEvent | null = null, - targetDevice: ITargetDevice | null = null, - ): VerificationBase<any, any> | undefined { - if (!targetDevice) { - targetDevice = this.targetDevice; - } - const { userId, deviceId } = targetDevice; - - const VerifierCtor = this.verificationMethods.get(method); - if (!VerifierCtor) { - logger.warn("could not find verifier constructor for method", method); - return; - } - return new VerifierCtor(this.channel, this.client, userId!, deviceId!, startEvent, this); - } - - private wasSentByOwnUser(event?: MatrixEvent): boolean { - return event?.getSender() === this.client.getUserId(); - } - - // only for .request, .ready or .start - private wasSentByOwnDevice(event?: MatrixEvent): boolean { - if (!this.wasSentByOwnUser(event)) { - return false; - } - const content = event!.getContent(); - if (!content || content.from_device !== this.client.getDeviceId()) { - return false; - } - return true; - } - - public onVerifierCancelled(): void { - this._cancelled = true; - // move to cancelled phase - const newTransitions = this.applyPhaseTransitions(); - if (newTransitions.length) { - this.setPhase(newTransitions[newTransitions.length - 1].phase); - } - } - - public onVerifierFinished(): void { - this.channel.send(EventType.KeyVerificationDone, {}); - this.verifierHasFinished = true; - // move to .done phase - const newTransitions = this.applyPhaseTransitions(); - if (newTransitions.length) { - this.setPhase(newTransitions[newTransitions.length - 1].phase); - } - } - - public getEventFromOtherParty(type: string): MatrixEvent | undefined { - return this.eventsByThem.get(type); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/embedded.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/embedded.ts deleted file mode 100644 index a08b79a..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/embedded.ts +++ /dev/null @@ -1,347 +0,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. -*/ - -import { - WidgetApi, - WidgetApiToWidgetAction, - MatrixCapabilities, - IWidgetApiRequest, - IWidgetApiAcknowledgeResponseData, - ISendEventToWidgetActionRequest, - ISendToDeviceToWidgetActionRequest, - ISendEventFromWidgetResponseData, -} from "matrix-widget-api"; - -import { IEvent, IContent, EventStatus } from "./models/event"; -import { ISendEventResponse } from "./@types/requests"; -import { EventType } from "./@types/event"; -import { logger } from "./logger"; -import { MatrixClient, ClientEvent, IMatrixClientCreateOpts, IStartClientOpts, SendToDeviceContentMap } from "./client"; -import { SyncApi, SyncState } from "./sync"; -import { SlidingSyncSdk } from "./sliding-sync-sdk"; -import { MatrixEvent } from "./models/event"; -import { User } from "./models/user"; -import { Room } from "./models/room"; -import { ToDeviceBatch, ToDevicePayload } from "./models/ToDeviceMessage"; -import { DeviceInfo } from "./crypto/deviceinfo"; -import { IOlmDevice } from "./crypto/algorithms/megolm"; -import { MapWithDefault, recursiveMapToObject } from "./utils"; - -interface IStateEventRequest { - eventType: string; - stateKey?: string; -} - -export interface ICapabilities { - /** - * Event types that this client expects to send. - */ - sendEvent?: string[]; - /** - * Event types that this client expects to receive. - */ - receiveEvent?: string[]; - - /** - * Message types that this client expects to send, or true for all message - * types. - */ - sendMessage?: string[] | true; - /** - * Message types that this client expects to receive, or true for all - * message types. - */ - receiveMessage?: string[] | true; - - /** - * Types of state events that this client expects to send. - */ - sendState?: IStateEventRequest[]; - /** - * Types of state events that this client expects to receive. - */ - receiveState?: IStateEventRequest[]; - - /** - * To-device event types that this client expects to send. - */ - sendToDevice?: string[]; - /** - * To-device event types that this client expects to receive. - */ - receiveToDevice?: string[]; - - /** - * Whether this client needs access to TURN servers. - * @defaultValue false - */ - turnServers?: boolean; -} - -/** - * A MatrixClient that routes its requests through the widget API instead of the - * real CS API. - * @experimental This class is considered unstable! - */ -export class RoomWidgetClient extends MatrixClient { - private room?: Room; - private widgetApiReady = new Promise<void>((resolve) => this.widgetApi.once("ready", resolve)); - private lifecycle?: AbortController; - private syncState: SyncState | null = null; - - public constructor( - private readonly widgetApi: WidgetApi, - private readonly capabilities: ICapabilities, - private readonly roomId: string, - opts: IMatrixClientCreateOpts, - ) { - super(opts); - - // Request capabilities for the functionality this client needs to support - 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(MatrixCapabilities.MSC3846TurnServers); - } - - widgetApi.on(`action:${WidgetApiToWidgetAction.SendEvent}`, this.onEvent); - widgetApi.on(`action:${WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice); - - // Open communication with the host - widgetApi.start(); - } - - public async startClient(opts: IStartClientOpts = {}): Promise<void> { - 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(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(opts.slidingSync, this, opts, this.buildSyncApiOptions()); - } else { - this.syncApi = new 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 MatrixEvent(rawEvent as Partial<IEvent>)); - - await this.syncApi!.injectRoomEvents(this.room!, [], events); - events.forEach((event) => { - this.emit(ClientEvent.Event, event); - logger.info(`Backfilled event ${event.getId()} ${event.getType()} ${event.getStateKey()}`); - }); - }) ?? [], - ); - this.setSyncState(SyncState.Syncing); - logger.info("Finished backfilling events"); - - // Watch for TURN servers, if requested - if (this.capabilities.turnServers) this.watchTurnServers(); - } - - public stopClient(): void { - this.widgetApi.off(`action:${WidgetApiToWidgetAction.SendEvent}`, this.onEvent); - this.widgetApi.off(`action:${WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice); - - super.stopClient(); - this.lifecycle!.abort(); // Signal to other async tasks that the client has stopped - } - - public async joinRoom(roomIdOrAlias: string): Promise<Room> { - if (roomIdOrAlias === this.roomId) return this.room!; - throw new Error(`Unknown room: ${roomIdOrAlias}`); - } - - protected async encryptAndSendEvent(room: Room, event: MatrixEvent): Promise<ISendEventResponse> { - let response: ISendEventFromWidgetResponseData; - try { - response = await this.widgetApi.sendRoomEvent(event.getType(), event.getContent(), room.roomId); - } catch (e) { - this.updatePendingEventStatus(room, event, EventStatus.NOT_SENT); - throw e; - } - - room.updatePendingEvent(event, EventStatus.SENT, response.event_id); - return { event_id: response.event_id }; - } - - public async sendStateEvent( - roomId: string, - eventType: string, - content: any, - stateKey = "", - ): Promise<ISendEventResponse> { - return await this.widgetApi.sendStateEvent(eventType, stateKey, content, roomId); - } - - public async sendToDevice(eventType: string, contentMap: SendToDeviceContentMap): Promise<{}> { - await this.widgetApi.sendToDevice(eventType, false, recursiveMapToObject(contentMap)); - return {}; - } - - public async queueToDevice({ eventType, batch }: ToDeviceBatch): Promise<void> { - // map: user Id → device Id → payload - const contentMap: MapWithDefault<string, Map<string, ToDevicePayload>> = new MapWithDefault(() => new Map()); - for (const { userId, deviceId, payload } of batch) { - contentMap.getOrCreate(userId).set(deviceId, payload); - } - - await this.widgetApi.sendToDevice(eventType, false, recursiveMapToObject(contentMap)); - } - - public async encryptAndSendToDevices(userDeviceInfoArr: IOlmDevice<DeviceInfo>[], payload: object): Promise<void> { - // map: user Id → device Id → payload - const contentMap: MapWithDefault<string, Map<string, object>> = new MapWithDefault(() => new Map()); - for (const { - userId, - deviceInfo: { deviceId }, - } of userDeviceInfoArr) { - contentMap.getOrCreate(userId).set(deviceId, payload); - } - - await this.widgetApi.sendToDevice((payload as { type: string }).type, true, recursiveMapToObject(contentMap)); - } - - // Overridden since we get TURN servers automatically over the widget API, - // and this method would otherwise complain about missing an access token - public async checkTurnServers(): Promise<boolean> { - return this.turnServers.length > 0; - } - - // Overridden since we 'sync' manually without the sync API - public getSyncState(): SyncState | null { - return this.syncState; - } - - private setSyncState(state: SyncState): void { - const oldState = this.syncState; - this.syncState = state; - this.emit(ClientEvent.Sync, state, oldState); - } - - private async ack(ev: CustomEvent<IWidgetApiRequest>): Promise<void> { - await this.widgetApi.transport.reply<IWidgetApiAcknowledgeResponseData>(ev.detail, {}); - } - - private onEvent = async (ev: CustomEvent<ISendEventToWidgetActionRequest>): Promise<void> => { - 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 MatrixEvent(ev.detail.data as Partial<IEvent>); - await this.syncApi!.injectRoomEvents(this.room!, [], [event]); - this.emit(ClientEvent.Event, event); - this.setSyncState(SyncState.Syncing); - logger.info(`Received event ${event.getId()} ${event.getType()} ${event.getStateKey()}`); - } else { - const { event_id: eventId, room_id: roomId } = ev.detail.data; - logger.info(`Received event ${eventId} for a different room ${roomId}; discarding`); - } - - await this.ack(ev); - }; - - private onToDevice = async (ev: CustomEvent<ISendToDeviceToWidgetActionRequest>): Promise<void> => { - ev.preventDefault(); - - const event = new MatrixEvent({ - type: ev.detail.data.type, - sender: ev.detail.data.sender, - content: ev.detail.data.content as IContent, - }); - // 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(EventType.RoomMessageEncrypted, {}, "", ""); - - this.emit(ClientEvent.ToDeviceEvent, event); - this.setSyncState(SyncState.Syncing); - await this.ack(ev); - }; - - private async watchTurnServers(): Promise<void> { - const servers = this.widgetApi.getTurnServers(); - const onClientStopped = (): void => { - 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(ClientEvent.TurnServers, this.turnServers); - logger.log(`Received TURN server: ${server.uris}`); - } - } catch (e) { - logger.warn("Error watching TURN servers", e); - } finally { - this.lifecycle!.signal.removeEventListener("abort", onClientStopped); - } - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/errors.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/errors.ts deleted file mode 100644 index 9d24651..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/errors.ts +++ /dev/null @@ -1,53 +0,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. -*/ - -export enum InvalidStoreState { - ToggledLazyLoading, -} - -export class InvalidStoreError extends Error { - public static TOGGLED_LAZY_LOADING = InvalidStoreState.ToggledLazyLoading; - - public constructor(public readonly reason: InvalidStoreState, public readonly value: any) { - const message = - `Store is invalid because ${reason}, ` + - `please stop the client, delete all data and start the client again`; - super(message); - this.name = "InvalidStoreError"; - } -} - -export enum InvalidCryptoStoreState { - TooNew = "TOO_NEW", -} - -export class InvalidCryptoStoreError extends Error { - public static TOO_NEW = InvalidCryptoStoreState.TooNew; - - public constructor(public readonly reason: InvalidCryptoStoreState) { - const message = - `Crypto store is invalid because ${reason}, ` + - `please stop the client, delete all data and start the client again`; - super(message); - this.name = "InvalidCryptoStoreError"; - } -} - -export class KeySignatureUploadError extends Error { - public constructor(message: string, public readonly value: any) { - super(message); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/event-mapper.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/event-mapper.ts deleted file mode 100644 index 828d87e..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/event-mapper.ts +++ /dev/null @@ -1,97 +0,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. -*/ - -import { MatrixClient } from "./client"; -import { IEvent, MatrixEvent, MatrixEventEvent } from "./models/event"; -import { RelationType } from "./@types/event"; - -export type EventMapper = (obj: Partial<IEvent>) => MatrixEvent; - -export interface MapperOpts { - // don't re-emit events emitted on an event mapped by this mapper on the client - preventReEmit?: boolean; - // decrypt event proactively - decrypt?: boolean; - // the event is a to_device event - toDevice?: boolean; -} - -export function eventMapperFor(client: MatrixClient, options: MapperOpts): EventMapper { - let preventReEmit = Boolean(options.preventReEmit); - const decrypt = options.decrypt !== false; - - function mapper(plainOldJsObject: Partial<IEvent>): MatrixEvent { - if (options.toDevice) { - delete plainOldJsObject.room_id; - } - - const room = client.getRoom(plainOldJsObject.room_id); - - let event: MatrixEvent | undefined; - // 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 MatrixEvent(plainOldJsObject); - } else { - // merge the latest unsigned data from the server - event.setUnsigned({ ...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<Partial<IEvent>>(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, [MatrixEventEvent.Decrypted]); - } - if (decrypt) { - client.decryptEventIfNeeded(event); - } - } - - if (!preventReEmit) { - client.reEmitter.reEmit(event, [MatrixEventEvent.Replaced, MatrixEventEvent.VisibilityChange]); - room?.reEmitter.reEmit(event, [MatrixEventEvent.BeforeRedaction]); - } - return event; - } - - return mapper; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/ExtensibleEvent.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/ExtensibleEvent.ts deleted file mode 100644 index 0496592..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/ExtensibleEvent.ts +++ /dev/null @@ -1,58 +0,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. -*/ - -import { ExtensibleEventType, IPartialEvent } from "../@types/extensible_events"; - -/** - * Represents an Extensible Event in Matrix. - */ -export abstract class ExtensibleEvent<TContent extends object = object> { - protected constructor(public readonly wireFormat: IPartialEvent<TContent>) {} - - /** - * Shortcut to wireFormat.content - */ - public get wireContent(): TContent { - 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. - */ - public abstract serialize(): IPartialEvent<object>; - - /** - * 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. - */ - public abstract isEquivalentTo(primaryEventType: ExtensibleEventType): boolean; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/InvalidEventError.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/InvalidEventError.ts deleted file mode 100644 index 12e59ad..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/InvalidEventError.ts +++ /dev/null @@ -1,24 +0,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. - */ -export class InvalidEventError extends Error { - public constructor(message: string) { - super(message); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/MessageEvent.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/MessageEvent.ts deleted file mode 100644 index 3d049f4..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/MessageEvent.ts +++ /dev/null @@ -1,145 +0,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. -*/ - -import { Optional } from "matrix-events-sdk"; - -import { ExtensibleEvent } from "./ExtensibleEvent"; -import { - ExtensibleEventType, - IMessageRendering, - IPartialEvent, - isEventTypeSame, - M_HTML, - M_MESSAGE, - ExtensibleAnyMessageEventContent, - M_TEXT, -} from "../@types/extensible_events"; -import { isOptionalAString, isProvided } from "./utilities"; -import { InvalidEventError } from "./InvalidEventError"; - -/** - * 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. - */ -export class MessageEvent extends ExtensibleEvent<ExtensibleAnyMessageEventContent> { - /** - * The default text for the event. - */ - public readonly text: string; - - /** - * The default HTML for the event, if provided. - */ - public readonly html: Optional<string>; - - /** - * 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. - */ - public readonly renderings: IMessageRendering[]; - - /** - * 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. - */ - public constructor(wireFormat: IPartialEvent<ExtensibleAnyMessageEventContent>) { - super(wireFormat); - - const mmessage = M_MESSAGE.findIn(this.wireContent); - const mtext = M_TEXT.findIn<string>(this.wireContent); - const mhtml = M_HTML.findIn<string>(this.wireContent); - if (isProvided(mmessage)) { - if (!Array.isArray(mmessage)) { - throw new InvalidEventError("m.message contents must be an array"); - } - const text = mmessage.find((r) => !isProvided(r.mimetype) || r.mimetype === "text/plain"); - const html = mmessage.find((r) => r.mimetype === "text/html"); - - if (!text) throw new InvalidEventError("m.message is missing a plain text representation"); - - this.text = text.body; - this.html = html?.body; - this.renderings = mmessage; - } else if (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("Missing textual representation for event"); - } - } - - public isEquivalentTo(primaryEventType: ExtensibleEventType): boolean { - return isEventTypeSame(primaryEventType, M_MESSAGE); - } - - protected serializeMMessageOnly(): ExtensibleAnyMessageEventContent { - let messageRendering: ExtensibleAnyMessageEventContent = { - [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 = { - [M_TEXT.name]: this.renderings[0].body, - }; - } - } - - return messageRendering; - } - - public serialize(): IPartialEvent<object> { - return { - type: "m.room.message", - content: { - ...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. - */ - public static from(text: string, html?: string): MessageEvent { - return new MessageEvent({ - type: M_MESSAGE.name, - content: { - [M_TEXT.name]: text, - [M_HTML.name]: html, - }, - }); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollEndEvent.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollEndEvent.ts deleted file mode 100644 index 243f190..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollEndEvent.ts +++ /dev/null @@ -1,97 +0,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. -*/ - -import { - ExtensibleEventType, - IPartialEvent, - isEventTypeSame, - M_TEXT, - REFERENCE_RELATION, -} from "../@types/extensible_events"; -import { M_POLL_END, PollEndEventContent } from "../@types/polls"; -import { ExtensibleEvent } from "./ExtensibleEvent"; -import { InvalidEventError } from "./InvalidEventError"; -import { MessageEvent } from "./MessageEvent"; - -/** - * Represents a poll end/closure event. - */ -export class PollEndEvent extends ExtensibleEvent<PollEndEventContent> { - /** - * The poll start event ID referenced by the response. - */ - public readonly pollEventId: string; - - /** - * The closing message for the event. - */ - public readonly closingMessage: MessageEvent; - - /** - * 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. - */ - public constructor(wireFormat: IPartialEvent<PollEndEventContent>) { - super(wireFormat); - - const rel = this.wireContent["m.relates_to"]; - if (!REFERENCE_RELATION.matches(rel?.rel_type) || typeof rel?.event_id !== "string") { - throw new InvalidEventError("Relationship must be a reference to an event"); - } - - this.pollEventId = rel.event_id; - this.closingMessage = new MessageEvent(this.wireFormat); - } - - public isEquivalentTo(primaryEventType: ExtensibleEventType): boolean { - return isEventTypeSame(primaryEventType, M_POLL_END); - } - - public serialize(): IPartialEvent<object> { - return { - type: M_POLL_END.name, - content: { - "m.relates_to": { - rel_type: REFERENCE_RELATION.name, - event_id: this.pollEventId, - }, - [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. - */ - public static from(pollEventId: string, message: string): PollEndEvent { - return new PollEndEvent({ - type: M_POLL_END.name, - content: { - "m.relates_to": { - rel_type: REFERENCE_RELATION.name, - event_id: pollEventId, - }, - [M_POLL_END.name]: {}, - [M_TEXT.name]: message, - }, - }); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollResponseEvent.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollResponseEvent.ts deleted file mode 100644 index a61fc2e..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollResponseEvent.ts +++ /dev/null @@ -1,143 +0,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. -*/ - -import { ExtensibleEvent } from "./ExtensibleEvent"; -import { M_POLL_RESPONSE, PollResponseEventContent, PollResponseSubtype } from "../@types/polls"; -import { ExtensibleEventType, IPartialEvent, isEventTypeSame, REFERENCE_RELATION } from "../@types/extensible_events"; -import { InvalidEventError } from "./InvalidEventError"; -import { PollStartEvent } from "./PollStartEvent"; - -/** - * Represents a poll response event. - */ -export class PollResponseEvent extends ExtensibleEvent<PollResponseEventContent> { - private internalAnswerIds: string[] = []; - private internalSpoiled = false; - - /** - * The provided answers for the poll. Note that this may be falsy/unpredictable if - * the `spoiled` property is true. - */ - public get answerIds(): string[] { - return this.internalAnswerIds; - } - - /** - * The poll start event ID referenced by the response. - */ - public readonly pollEventId: string; - - /** - * Whether the vote is spoiled. - */ - public get spoiled(): boolean { - 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. - */ - public constructor(wireFormat: IPartialEvent<PollResponseEventContent>) { - super(wireFormat); - - const rel = this.wireContent["m.relates_to"]; - if (!REFERENCE_RELATION.matches(rel?.rel_type) || typeof rel?.event_id !== "string") { - throw new 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. - */ - public validateAgainst(poll: PollStartEvent | null): void { - const response = M_POLL_RESPONSE.findIn<PollResponseSubtype>(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; - } - - public isEquivalentTo(primaryEventType: ExtensibleEventType): boolean { - return isEventTypeSame(primaryEventType, M_POLL_RESPONSE); - } - - public serialize(): IPartialEvent<object> { - return { - type: M_POLL_RESPONSE.name, - content: { - "m.relates_to": { - rel_type: REFERENCE_RELATION.name, - event_id: this.pollEventId, - }, - [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. - */ - public static from(answers: string[], pollEventId: string): PollResponseEvent { - return new PollResponseEvent({ - type: M_POLL_RESPONSE.name, - content: { - "m.relates_to": { - rel_type: REFERENCE_RELATION.name, - event_id: pollEventId, - }, - [M_POLL_RESPONSE.name]: { - answers: answers, - }, - }, - }); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollStartEvent.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollStartEvent.ts deleted file mode 100644 index 8584bf9..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollStartEvent.ts +++ /dev/null @@ -1,207 +0,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. -*/ - -import { NamespacedValue } from "matrix-events-sdk"; - -import { MessageEvent } from "./MessageEvent"; -import { ExtensibleEventType, IPartialEvent, isEventTypeSame, M_TEXT } from "../@types/extensible_events"; -import { - KnownPollKind, - M_POLL_KIND_DISCLOSED, - M_POLL_KIND_UNDISCLOSED, - M_POLL_START, - PollStartEventContent, - PollStartSubtype, - PollAnswer, -} from "../@types/polls"; -import { InvalidEventError } from "./InvalidEventError"; -import { ExtensibleEvent } from "./ExtensibleEvent"; - -/** - * 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. - */ -export class PollAnswerSubevent extends MessageEvent { - /** - * The answer ID. - */ - public readonly id: string; - - public constructor(wireFormat: IPartialEvent<PollAnswer>) { - super(wireFormat); - - const id = wireFormat.content.id; - if (!id || typeof id !== "string") { - throw new InvalidEventError("Answer ID must be a non-empty string"); - } - this.id = id; - } - - public serialize(): IPartialEvent<object> { - return { - type: "org.matrix.sdk.poll.answer", - content: { - 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. - */ - public static from(id: string, text: string): PollAnswerSubevent { - return new PollAnswerSubevent({ - type: "org.matrix.sdk.poll.answer", - content: { - id: id, - [M_TEXT.name]: text, - }, - }); - } -} - -/** - * Represents a poll start event. - */ -export class PollStartEvent extends ExtensibleEvent<PollStartEventContent> { - /** - * The question being asked, as a MessageEvent node. - */ - public readonly question: MessageEvent; - - /** - * 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. - */ - public readonly kind: KnownPollKind; - - /** - * The true kind as provided by the event sender. Might not be valid. - */ - public readonly rawKind: string; - - /** - * The maximum number of selections a user is allowed to make. - */ - public readonly maxSelections: number; - - /** - * The possible answers for the poll. - */ - public readonly answers: PollAnswerSubevent[]; - - /** - * 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. - */ - public constructor(wireFormat: IPartialEvent<PollStartEventContent>) { - super(wireFormat); - - const poll = M_POLL_START.findIn<PollStartSubtype>(this.wireContent); - - if (!poll?.question) { - throw new InvalidEventError("A question is required"); - } - - this.question = new MessageEvent({ type: "org.matrix.sdk.poll.question", content: poll.question }); - - this.rawKind = poll.kind; - if (M_POLL_KIND_DISCLOSED.matches(this.rawKind)) { - this.kind = M_POLL_KIND_DISCLOSED; - } else { - this.kind = 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("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("No answers available"); - } - this.answers = answers; - } - - public isEquivalentTo(primaryEventType: ExtensibleEventType): boolean { - return isEventTypeSame(primaryEventType, M_POLL_START); - } - - public serialize(): IPartialEvent<object> { - return { - type: M_POLL_START.name, - content: { - [M_POLL_START.name]: { - question: this.question.serialize().content, - kind: this.rawKind, - max_selections: this.maxSelections, - answers: this.answers.map((a) => a.serialize().content), - }, - [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. - */ - public static from( - question: string, - answers: string[], - kind: KnownPollKind | string, - maxSelections = 1, - ): PollStartEvent { - return new PollStartEvent({ - type: M_POLL_START.name, - content: { - [M_TEXT.name]: question, // unused by parsing - [M_POLL_START.name]: { - question: { [M_TEXT.name]: question }, - kind: kind instanceof NamespacedValue ? kind.name : kind, - max_selections: maxSelections, - answers: answers.map((a) => ({ id: makeId(), [M_TEXT.name]: a })), - }, - }, - }); - } -} - -const LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; -function makeId(): string { - return [...Array(16)].map(() => LETTERS.charAt(Math.floor(Math.random() * LETTERS.length))).join(""); -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/utilities.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/utilities.ts deleted file mode 100644 index 0660442..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/utilities.ts +++ /dev/null @@ -1,35 +0,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. -*/ - -import { Optional } from "matrix-events-sdk"; - -/** - * Determines if the given optional was provided a value. - * @param s - The optional to test. - * @returns True if the value is defined. - */ -export function isProvided<T>(s: Optional<T>): boolean { - 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. - */ -export function isOptionalAString(s: Optional<string>): s is string { - return isProvided(s) && typeof s === "string"; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/feature.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/feature.ts deleted file mode 100644 index 9141e81..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/feature.ts +++ /dev/null @@ -1,75 +0,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. -*/ - -import { IServerVersions } from "./client"; - -export enum ServerSupport { - Stable, - Unstable, - Unsupported, -} - -export enum Feature { - Thread = "Thread", - ThreadUnreadNotifications = "ThreadUnreadNotifications", - LoginTokenRequest = "LoginTokenRequest", - RelationBasedRedactions = "RelationBasedRedactions", - AccountDataDeletion = "AccountDataDeletion", -} - -type FeatureSupportCondition = { - unstablePrefixes?: string[]; - matrixVersion?: string; -}; - -const featureSupportResolver: Record<string, FeatureSupportCondition> = { - [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"], - }, -}; - -export async function buildFeatureSupportMap(versions: IServerVersions): Promise<Map<Feature, ServerSupport>> { - const supportMap = new Map<Feature, ServerSupport>(); - 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 as Feature, ServerSupport.Stable); - } else if (supportUnstablePrefixes) { - supportMap.set(feature as Feature, ServerSupport.Unstable); - } else { - supportMap.set(feature as Feature, ServerSupport.Unsupported); - } - } - return supportMap; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/filter-component.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/filter-component.ts deleted file mode 100644 index e28571d..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/filter-component.ts +++ /dev/null @@ -1,204 +0,0 @@ -/* -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. -*/ - -import { RelationType } from "./@types/event"; -import { MatrixEvent } from "./models/event"; -import { FILTER_RELATED_BY_REL_TYPES, FILTER_RELATED_BY_SENDERS, THREAD_RELATION_TYPE } from "./models/thread"; - -/** - * 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: string, filterValue: string): boolean { - if (filterValue.endsWith("*")) { - const typePrefix = filterValue.slice(0, -1); - return actualValue.slice(0, typePrefix.length) === typePrefix; - } else { - return actualValue === filterValue; - } -} - -/* eslint-disable camelcase */ -export interface IFilterComponent { - "types"?: string[]; - "not_types"?: string[]; - "rooms"?: string[]; - "not_rooms"?: string[]; - "senders"?: string[]; - "not_senders"?: string[]; - "contains_url"?: boolean; - "limit"?: number; - "related_by_senders"?: Array<RelationType | string>; - "related_by_rel_types"?: string[]; - - // Unstable values - "io.element.relation_senders"?: Array<RelationType | string>; - "io.element.relation_types"?: string[]; -} -/* 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'. - */ -export class FilterComponent { - public constructor(private filterJson: IFilterComponent, public readonly userId?: string | undefined | null) {} - - /** - * 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 - */ - public check(event: MatrixEvent): boolean { - const bundledRelationships = event.getUnsigned()?.["m.relations"] || {}; - const relations: Array<string | RelationType> = 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: string[] = []; - if (this.userId && bundledRelationships?.[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 - */ - public toJSON(): object { - 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, - [FILTER_RELATED_BY_SENDERS.name]: this.filterJson[FILTER_RELATED_BY_SENDERS.name] || [], - [FILTER_RELATED_BY_REL_TYPES.name]: this.filterJson[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 - */ - private checkFields( - roomId: string | undefined, - sender: string | undefined, - eventType: string, - containsUrl: boolean, - relationTypes: Array<RelationType | string>, - relationSenders: string[], - ): boolean { - const literalKeys = { - rooms: function (v: string): boolean { - return roomId === v; - }, - senders: function (v: string): boolean { - return sender === v; - }, - types: function (v: string): boolean { - return matchesWildcard(eventType, v); - }, - } as const; - - for (const name in literalKeys) { - const matchFunc = literalKeys[<keyof typeof literalKeys>name]; - const notName = "not_" + name; - const disallowedValues = this.filterJson[<`not_${keyof typeof literalKeys}`>notName]; - if (disallowedValues?.some(matchFunc)) { - return false; - } - - const allowedValues = this.filterJson[name as keyof typeof literalKeys]; - if (allowedValues && !allowedValues.some(matchFunc)) { - return false; - } - } - - const containsUrlFilter = this.filterJson.contains_url; - if (containsUrlFilter !== undefined && containsUrlFilter !== containsUrl) { - return false; - } - - const relationTypesFilter = this.filterJson[FILTER_RELATED_BY_REL_TYPES.name]; - if (relationTypesFilter !== undefined) { - if (!this.arrayMatchesFilter(relationTypesFilter, relationTypes)) { - return false; - } - } - - const relationSendersFilter = this.filterJson[FILTER_RELATED_BY_SENDERS.name]; - if (relationSendersFilter !== undefined) { - if (!this.arrayMatchesFilter(relationSendersFilter, relationSenders)) { - return false; - } - } - - return true; - } - - private arrayMatchesFilter(filter: any[], values: any[]): boolean { - 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 - */ - public filter(events: MatrixEvent[]): MatrixEvent[] { - 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. - */ - public limit(): number { - return this.filterJson.limit !== undefined ? this.filterJson.limit : 10; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/filter.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/filter.ts deleted file mode 100644 index 4d74c8c..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/filter.ts +++ /dev/null @@ -1,242 +0,0 @@ -/* -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. -*/ - -import { EventType, RelationType } from "./@types/event"; -import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync"; -import { FilterComponent, IFilterComponent } from "./filter-component"; -import { MatrixEvent } from "./models/event"; - -/** - */ -function setProp(obj: Record<string, any>, keyNesting: string, val: any): void { - const nestedKeys = keyNesting.split(".") as [keyof typeof obj]; - 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 */ -export interface IFilterDefinition { - event_fields?: string[]; - event_format?: "client" | "federation"; - presence?: IFilterComponent; - account_data?: IFilterComponent; - room?: IRoomFilter; -} - -export interface IRoomEventFilter extends IFilterComponent { - "lazy_load_members"?: boolean; - "include_redundant_members"?: boolean; - "types"?: Array<EventType | string>; - "related_by_senders"?: Array<RelationType | string>; - "related_by_rel_types"?: string[]; - "unread_thread_notifications"?: boolean; - "org.matrix.msc3773.unread_thread_notifications"?: boolean; - - // Unstable values - "io.element.relation_senders"?: Array<RelationType | string>; - "io.element.relation_types"?: string[]; -} - -interface IStateFilter extends IRoomEventFilter {} - -interface IRoomFilter { - not_rooms?: string[]; - rooms?: string[]; - ephemeral?: IRoomEventFilter; - include_leave?: boolean; - state?: IStateFilter; - timeline?: IRoomEventFilter; - account_data?: IRoomEventFilter; -} -/* eslint-enable camelcase */ - -export class Filter { - public static LAZY_LOADING_MESSAGES_FILTER = { - lazy_load_members: true, - }; - - /** - * Create a filter from existing data. - */ - public static fromJson(userId: string | undefined | null, filterId: string, jsonObj: IFilterDefinition): Filter { - const filter = new Filter(userId, filterId); - filter.setDefinition(jsonObj); - return filter; - } - - private definition: IFilterDefinition = {}; - private roomFilter?: FilterComponent; - private roomTimelineFilter?: FilterComponent; - - /** - * Construct a new Filter. - * @param userId - The user ID for this filter. - * @param filterId - The filter ID if known. - */ - public constructor(public readonly userId: string | undefined | null, public filterId?: string) {} - - /** - * Get the ID of this filter on your homeserver (if known) - * @returns The filter ID - */ - public getFilterId(): string | undefined { - return this.filterId; - } - - /** - * Get the JSON body of the filter. - * @returns The filter definition - */ - public getDefinition(): IFilterDefinition { - return this.definition; - } - - /** - * Set the JSON body of the filter - * @param definition - The filter definition - */ - public setDefinition(definition: IFilterDefinition): void { - 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: IRoomFilter = {}; - if (roomFilterJson) { - if (roomFilterJson.rooms) { - roomFilterFields.rooms = roomFilterJson.rooms; - } - if (roomFilterJson.rooms) { - roomFilterFields.not_rooms = roomFilterJson.not_rooms; - } - } - - this.roomFilter = new FilterComponent(roomFilterFields, this.userId); - this.roomTimelineFilter = new 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 - */ - public getRoomTimelineFilterComponent(): FilterComponent | undefined { - 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 - */ - public filterRoomTimeline(events: MatrixEvent[]): MatrixEvent[] { - 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. - */ - public setTimelineLimit(limit: number): void { - setProp(this.definition, "room.timeline.limit", limit); - } - - /** - * Enable threads unread notification - */ - public setUnreadThreadNotifications(enabled: boolean): void { - this.definition = { - ...this.definition, - room: { - ...this.definition?.room, - timeline: { - ...this.definition?.room?.timeline, - [UNREAD_THREAD_NOTIFICATIONS.name]: enabled, - }, - }, - }; - } - - public setLazyLoadMembers(enabled: boolean): void { - 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. - */ - public setIncludeLeaveRooms(includeLeave: boolean): void { - setProp(this.definition, "room.include_leave", includeLeave); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/errors.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/errors.ts deleted file mode 100644 index e48fc02..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/errors.ts +++ /dev/null @@ -1,84 +0,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. -*/ - -import { IUsageLimit } from "../@types/partials"; -import { MatrixEvent } from "../models/event"; - -interface IErrorJson extends Partial<IUsageLimit> { - [key: string]: any; // extensible - errcode?: string; - error?: string; -} - -/** - * 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. - */ -export class HTTPError extends Error { - public constructor(msg: string, public readonly httpStatus?: number) { - super(msg); - } -} - -export class MatrixError extends HTTPError { - // The Matrix 'errcode' value, e.g. "M_FORBIDDEN". - public readonly errcode?: string; - // The raw Matrix error JSON used to construct this object. - public data: IErrorJson; - - /** - * 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 - */ - public constructor( - errorJson: IErrorJson = {}, - public readonly httpStatus?: number, - public url?: string, - public event?: MatrixEvent, - ) { - let message = errorJson.error || "Unknown message"; - if (httpStatus) { - message = `[${httpStatus}] ${message}`; - } - if (url) { - message = `${message} (${url})`; - } - super(`MatrixError: ${message}`, httpStatus); - 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. - */ -export class ConnectionError extends Error { - public constructor(message: string, cause?: Error) { - super(message + (cause ? `: ${cause.message}` : "")); - } - - public get name(): string { - return "ConnectionError"; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/fetch.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/fetch.ts deleted file mode 100644 index ecb0908..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/fetch.ts +++ /dev/null @@ -1,311 +0,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. -*/ - -/** - * This is an internal module. See {@link MatrixHttpApi} for the public class. - */ - -import * as utils from "../utils"; -import { TypedEventEmitter } from "../models/typed-event-emitter"; -import { Method } from "./method"; -import { ConnectionError, MatrixError } from "./errors"; -import { HttpApiEvent, HttpApiEventHandlerMap, IHttpOpts, IRequestOpts } from "./interface"; -import { anySignal, parseErrorResponse, timeoutSignal } from "./utils"; -import { QueryDict } from "../utils"; - -type Body = Record<string, any> | BodyInit; - -interface TypedResponse<T> extends Response { - json(): Promise<T>; -} - -export type ResponseType<T, O extends IHttpOpts> = O extends undefined - ? T - : O extends { onlyData: true } - ? T - : TypedResponse<T>; - -export class FetchHttpApi<O extends IHttpOpts> { - private abortController = new AbortController(); - - public constructor( - private eventEmitter: TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>, - public readonly opts: O, - ) { - utils.checkObjectHasKeys(opts, ["baseUrl", "prefix"]); - opts.onlyData = !!opts.onlyData; - opts.useAuthorizationHeader = opts.useAuthorizationHeader ?? true; - } - - public abort(): void { - this.abortController.abort(); - this.abortController = new AbortController(); - } - - public fetch(resource: URL | string, options?: RequestInit): ReturnType<typeof global.fetch> { - 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 - */ - public setIdBaseUrl(url: string): void { - this.opts.idBaseUrl = url; - } - - public idServerRequest<T extends {} = Record<string, unknown>>( - method: Method, - path: string, - params: Record<string, string | string[]> | undefined, - prefix: string, - accessToken?: string, - ): Promise<ResponseType<T, O>> { - if (!this.opts.idBaseUrl) { - throw new Error("No identity server base URL set"); - } - - let queryParams: QueryDict | undefined = undefined; - let body: Record<string, string | string[]> | undefined = undefined; - if (method === Method.Get) { - queryParams = params; - } else { - body = params; - } - - const fullUri = this.getUrl(path, queryParams, prefix, this.opts.idBaseUrl); - - const opts: IRequestOpts = { - 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. - */ - public authedRequest<T>( - method: Method, - path: string, - queryParams?: QueryDict, - body?: Body, - opts: IRequestOpts = {}, - ): Promise<ResponseType<T, O>> { - 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<T>(method, path, queryParams, body, opts); - - requestPromise.catch((err: MatrixError) => { - if (err.errcode == "M_UNKNOWN_TOKEN" && !opts?.inhibitLogoutEmit) { - this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, err); - } else if (err.errcode == "M_CONSENT_NOT_GIVEN") { - this.eventEmitter.emit(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. - */ - public request<T>( - method: Method, - path: string, - queryParams?: QueryDict, - body?: Body, - opts?: IRequestOpts, - ): Promise<ResponseType<T, O>> { - const fullUri = this.getUrl(path, queryParams, opts?.prefix, opts?.baseUrl); - return this.requestOtherUrl<T>(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. - */ - public async requestOtherUrl<T>( - method: Method, - url: URL | string, - body?: Body, - opts: Pick<IRequestOpts, "headers" | "json" | "localTimeoutMs" | "keepAlive" | "abortSignal"> = {}, - ): Promise<ResponseType<T, O>> { - 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(timeoutSignal(timeout)); - } - if (opts.abortSignal) { - signals.push(opts.abortSignal); - } - - let data: BodyInit; - if (jsonBody) { - data = JSON.stringify(body); - } else { - data = body as BodyInit; - } - - const { signal, cleanup } = anySignal(signals); - - let res: Response; - 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 ((<Error>e).name === "AbortError") { - throw e; - } - throw new ConnectionError("fetch failed", <Error>e); - } finally { - cleanup(); - } - - if (!res.ok) { - throw parseErrorResponse(res, await res.text()); - } - - if (this.opts.onlyData) { - return json ? res.json() : res.text(); - } - return res as ResponseType<T, O>; - } - - /** - * 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 - */ - public getUrl(path: string, queryParams?: QueryDict, prefix?: string, baseUrl?: string): URL { - const url = new URL((baseUrl ?? this.opts.baseUrl) + (prefix ?? this.opts.prefix) + path); - if (queryParams) { - utils.encodeParams(queryParams, url.searchParams); - } - return url; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/index.ts deleted file mode 100644 index c5e8e2a..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/index.ts +++ /dev/null @@ -1,191 +0,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. -*/ - -import { FetchHttpApi } from "./fetch"; -import { FileType, IContentUri, IHttpOpts, Upload, UploadOpts, UploadResponse } from "./interface"; -import { MediaPrefix } from "./prefix"; -import * as utils from "../utils"; -import * as callbacks from "../realtime-callbacks"; -import { Method } from "./method"; -import { ConnectionError } from "./errors"; -import { parseErrorResponse } from "./utils"; - -export * from "./interface"; -export * from "./prefix"; -export * from "./errors"; -export * from "./method"; -export * from "./utils"; - -export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> { - private uploads: Upload[] = []; - - /** - * 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). - */ - public uploadContent(file: FileType, opts: UploadOpts = {}): Promise<UploadResponse> { - 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 as File).type ?? "application/octet-stream"; - const fileName = opts.name ?? (file as File).name; - - const upload = { - loaded: 0, - total: 0, - abortController, - } as Upload; - const defer = utils.defer<UploadResponse>(); - - if (global.XMLHttpRequest) { - const xhr = new global.XMLHttpRequest(); - - const timeoutFn = function (): void { - xhr.abort(); - defer.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 (): void { - 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) { - defer.reject(parseErrorResponse(xhr, xhr.responseText)); - } else { - defer.resolve(JSON.parse(xhr.responseText)); - } - } catch (err) { - if ((<Error>err).name === "AbortError") { - defer.reject(err); - return; - } - defer.reject(new ConnectionError("request failed", <Error>err)); - } - break; - } - }; - - xhr.upload.onprogress = (ev: ProgressEvent): void => { - 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, 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.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: utils.QueryDict = {}; - if (includeFilename && fileName) { - queryParams.filename = fileName; - } - - const headers: Record<string, string> = { "Content-Type": contentType }; - - this.authedRequest<UploadResponse>(Method.Post, "/upload", queryParams, file, { - prefix: MediaPrefix.R0, - headers, - abortSignal: abortController.signal, - }) - .then((response) => { - return this.opts.onlyData ? <UploadResponse>response : response.json(); - }) - .then(defer.resolve, defer.reject); - } - - // remove the upload from the list on completion - upload.promise = defer.promise.finally(() => { - utils.removeElement(this.uploads, (elem) => elem === upload); - }); - abortController.signal.addEventListener("abort", () => { - utils.removeElement(this.uploads, (elem) => elem === upload); - defer.reject(new DOMException("Aborted", "AbortError")); - }); - this.uploads.push(upload); - return upload.promise; - } - - public cancelUpload(promise: Promise<UploadResponse>): boolean { - const upload = this.uploads.find((u) => u.promise === promise); - if (upload) { - upload.abortController.abort(); - return true; - } - return false; - } - - public getCurrentUploads(): Upload[] { - 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. - */ - public getContentUri(): IContentUri { - return { - base: this.opts.baseUrl, - path: MediaPrefix.R0 + "/upload", - params: { - access_token: this.opts.accessToken!, - }, - }; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/interface.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/interface.ts deleted file mode 100644 index 9946aa3..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/interface.ts +++ /dev/null @@ -1,147 +0,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. -*/ - -import { MatrixError } from "./errors"; - -export interface IHttpOpts { - fetchFn?: typeof global.fetch; - - baseUrl: string; - idBaseUrl?: string; - prefix: string; - extraParams?: Record<string, string>; - - accessToken?: string; - useAuthorizationHeader?: boolean; // defaults to true - - onlyData?: boolean; - localTimeoutMs?: number; -} - -export interface IRequestOpts { - /** - * The alternative base url to use. - * If not specified, uses this.opts.baseUrl - */ - baseUrl?: string; - /** - * The full prefix to use e.g. - * "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix. - */ - prefix?: string; - /** - * map of additional request headers - */ - headers?: Record<string, string>; - abortSignal?: AbortSignal; - /** - * The maximum amount of time to wait before - * timing out the request. If not specified, there is no timeout. - */ - localTimeoutMs?: number; - keepAlive?: boolean; // defaults to false - json?: boolean; // defaults to true - - // Set to true to prevent the request function from emitting a Session.logged_out event. - // This is intended for use on endpoints where M_UNKNOWN_TOKEN is a valid/notable error response, - // such as with token refreshes. - inhibitLogoutEmit?: boolean; -} - -export interface IContentUri { - base: string; - path: string; - params: { - // eslint-disable-next-line camelcase - access_token: string; - }; -} - -export enum HttpApiEvent { - SessionLoggedOut = "Session.logged_out", - NoConsent = "no_consent", -} - -export type HttpApiEventHandlerMap = { - /** - * Fires whenever the login session the JS SDK is using is no - * longer valid and the user must log in again. - * NB. This only fires when action is required from the user, not - * when then login session can be renewed by using a refresh token. - * @example - * ``` - * matrixClient.on("Session.logged_out", function(errorObj){ - * // show the login screen - * }); - * ``` - */ - [HttpApiEvent.SessionLoggedOut]: (err: MatrixError) => void; - /** - * Fires when the JS SDK receives a M_CONSENT_NOT_GIVEN error in response - * to a HTTP request. - * @example - * ``` - * matrixClient.on("no_consent", function(message, contentUri) { - * console.info(message + ' Go to ' + contentUri); - * }); - * ``` - */ - [HttpApiEvent.NoConsent]: (message: string, consentUri: string) => void; -}; - -export interface UploadProgress { - loaded: number; - total: number; -} - -export interface UploadOpts { - /** - * Name to give the file on the server. Defaults to <tt>file.name</tt>. - */ - name?: string; - /** - * Content-type for the upload. Defaults to - * <tt>file.type</tt>, or <tt>applicaton/octet-stream</tt>. - */ - type?: string; - /** - * if false will not send the filename, - * e.g for encrypted file uploads where filename leaks are undesirable. - * Defaults to true. - */ - includeFilename?: boolean; - /** - * Optional. Called when a chunk of - * data has been uploaded, with an object containing the fields `loaded` - * (number of bytes transferred) and `total` (total size, if known). - */ - progressHandler?(progress: UploadProgress): void; - abortController?: AbortController; -} - -export interface Upload { - loaded: number; - total: number; - promise: Promise<UploadResponse>; - abortController: AbortController; -} - -export interface UploadResponse { - // eslint-disable-next-line camelcase - content_uri: string; -} - -export type FileType = XMLHttpRequestBodyInit; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/method.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/method.ts deleted file mode 100644 index 1914360..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/method.ts +++ /dev/null @@ -1,22 +0,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. -*/ - -export enum Method { - Get = "GET", - Put = "PUT", - Post = "POST", - Delete = "DELETE", -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/prefix.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/prefix.ts deleted file mode 100644 index f15b1ac..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/prefix.ts +++ /dev/null @@ -1,48 +0,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. -*/ - -export enum ClientPrefix { - /** - * A constant representing the URI path for release 0 of the Client-Server HTTP API. - */ - R0 = "/_matrix/client/r0", - /** - * A constant representing the URI path for the legacy release v1 of the Client-Server HTTP API. - */ - V1 = "/_matrix/client/v1", - /** - * A constant representing the URI path for Client-Server API endpoints versioned at v3. - */ - V3 = "/_matrix/client/v3", - /** - * A constant representing the URI path for as-yet unspecified Client-Server HTTP APIs. - */ - Unstable = "/_matrix/client/unstable", -} - -export enum IdentityPrefix { - /** - * URI path for the v2 identity API - */ - V2 = "/_matrix/identity/v2", -} - -export enum MediaPrefix { - /** - * URI path for the media repo API - */ - R0 = "/_matrix/media/r0", -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/utils.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/utils.ts deleted file mode 100644 index c49be74..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/utils.ts +++ /dev/null @@ -1,153 +0,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. -*/ - -import { parse as parseContentType, ParsedMediaType } from "content-type"; - -import { logger } from "../logger"; -import { sleep } from "../utils"; -import { ConnectionError, HTTPError, MatrixError } from "./errors"; - -// Ponyfill for https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout -export function timeoutSignal(ms: number): AbortSignal { - const controller = new AbortController(); - setTimeout(() => { - controller.abort(); - }, ms); - - return controller.signal; -} - -export function anySignal(signals: AbortSignal[]): { - signal: AbortSignal; - cleanup(): void; -} { - const controller = new AbortController(); - - function cleanup(): void { - for (const signal of signals) { - signal.removeEventListener("abort", onAbort); - } - } - - function onAbort(): void { - 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 - */ -export function parseErrorResponse(response: XMLHttpRequest | Response, body?: string): Error { - let contentType: ParsedMediaType | null; - try { - contentType = getResponseContentType(response); - } catch (e) { - return <Error>e; - } - - if (contentType?.type === "application/json" && body) { - return new MatrixError( - JSON.parse(body), - response.status, - isXhr(response) ? response.responseURL : response.url, - ); - } - if (contentType?.type === "text/plain") { - return new HTTPError(`Server returned ${response.status} error: ${body}`, response.status); - } - return new HTTPError(`Server returned ${response.status} error`, response.status); -} - -function isXhr(response: XMLHttpRequest | Response): response is XMLHttpRequest { - 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: XMLHttpRequest | Response): ParsedMediaType | null { - let contentType: string | null; - if (isXhr(response)) { - contentType = response.getResponseHeader("Content-Type"); - } else { - contentType = response.headers.get("Content-Type"); - } - - if (!contentType) return null; - - try { - return parseContentType(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 - */ -export async function retryNetworkOperation<T>(maxAttempts: number, callback: () => Promise<T>): Promise<T> { - let attempts = 0; - let lastConnectionError: ConnectionError | null = null; - while (attempts < maxAttempts) { - try { - if (attempts > 0) { - const timeout = 1000 * Math.pow(2, attempts); - logger.log(`network operation failed ${attempts} times, retrying in ${timeout}ms...`); - await sleep(timeout); - } - return await callback(); - } catch (err) { - if (err instanceof ConnectionError) { - attempts += 1; - lastConnectionError = err; - } else { - throw err; - } - } - } - throw lastConnectionError; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/index.ts deleted file mode 100644 index c9a5dcf..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import * as matrixcs from "./matrix"; - -if (global.__js_sdk_entrypoint) { - throw new Error("Multiple matrix-js-sdk entrypoints detected!"); -} -global.__js_sdk_entrypoint = true; - -export * from "./matrix"; -export default matrixcs; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/indexeddb-helpers.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/indexeddb-helpers.ts deleted file mode 100644 index 6f99ae5..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/indexeddb-helpers.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* -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 - */ -export function exists(indexedDB: IDBFactory, dbName: string): Promise<boolean> { - return new Promise<boolean>((resolve, reject) => { - let exists = true; - const req = indexedDB.open(dbName); - req.onupgradeneeded = (): void => { - // 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 = (): void => reject(req.error); - req.onsuccess = (): void => { - 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 = (): void => reject(req.error); - }); -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/indexeddb-worker.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/indexeddb-worker.ts deleted file mode 100644 index 68dcf0f..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/indexeddb-worker.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* -Copyright 2017 Vector Creations Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/** - * Separate exports file for the indexeddb web worker, which is designed - * to be used separately - */ - -/** The {@link IndexedDBStoreWorker} class. */ -export { IndexedDBStoreWorker } from "./store/indexeddb-store-worker"; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/interactive-auth.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/interactive-auth.ts deleted file mode 100644 index 7d9c183..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/interactive-auth.ts +++ /dev/null @@ -1,617 +0,0 @@ -/* -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. -*/ - -import { logger } from "./logger"; -import { MatrixClient } from "./client"; -import { defer, IDeferred } from "./utils"; -import { MatrixError } from "./http-api"; - -const EMAIL_STAGE_TYPE = "m.login.email.identity"; -const MSISDN_STAGE_TYPE = "m.login.msisdn"; - -export interface UIAFlow { - stages: AuthType[]; -} - -export interface IInputs { - // An email address. If supplied, a flow using email verification will be chosen. - emailAddress?: string; - // An ISO two letter country code. Gives the country that opts.phoneNumber should be resolved relative to. - phoneCountry?: string; - // A phone number. If supplied, a flow using phone number validation will be chosen. - phoneNumber?: string; - registrationToken?: string; -} - -export interface IStageStatus { - emailSid?: string; - errcode?: string; - error?: string; -} - -export interface IAuthData { - session?: string; - type?: string; - completed?: string[]; - flows?: UIAFlow[]; - available_flows?: UIAFlow[]; - stages?: string[]; - required_stages?: AuthType[]; - params?: Record<string, Record<string, any>>; - data?: Record<string, string>; - errcode?: string; - error?: string; - user_id?: string; - device_id?: string; - access_token?: string; -} - -export enum AuthType { - Password = "m.login.password", - Recaptcha = "m.login.recaptcha", - Terms = "m.login.terms", - Email = "m.login.email.identity", - Msisdn = "m.login.msisdn", - Sso = "m.login.sso", - SsoUnstable = "org.matrix.login.sso", - Dummy = "m.login.dummy", - RegistrationToken = "m.login.registration_token", - // For backwards compatability with servers that have not yet updated to - // use the stable "m.login.registration_token" type. - // The authentication flow is the same in both cases. - UnstableRegistrationToken = "org.matrix.msc3231.login.registration_token", -} - -export interface IAuthDict { - // [key: string]: any; - type?: string; - session?: string; - // TODO: Remove `user` once servers support proper UIA - // See https://github.com/vector-im/element-web/issues/10312 - user?: string; - identifier?: any; - password?: string; - response?: string; - // TODO: Remove `threepid_creds` once servers support proper UIA - // See https://github.com/vector-im/element-web/issues/10312 - // See https://github.com/matrix-org/matrix-doc/issues/2220 - // eslint-disable-next-line camelcase - threepid_creds?: any; - threepidCreds?: any; - // For m.login.registration_token type - token?: string; -} - -class NoAuthFlowFoundError extends Error { - public name = "NoAuthFlowFoundError"; - - // eslint-disable-next-line @typescript-eslint/naming-convention, camelcase - public constructor(m: string, public readonly required_stages: string[], public readonly flows: UIAFlow[]) { - super(m); - } -} - -interface IOpts { - /** - * A matrix client to use for the auth process - */ - matrixClient: MatrixClient; - /** - * Error response from the last request. If null, a request will be made with no auth before starting. - */ - authData?: IAuthData; - /** - * Inputs provided by the user and used by different stages of the auto process. - * The inputs provided will affect what flow is chosen. - */ - inputs?: IInputs; - /** - * If resuming an existing interactive auth session, the sessionId of that session. - */ - sessionId?: string; - /** - * If resuming an existing interactive auth session, the client secret for that session - */ - clientSecret?: string; - /** - * If returning from having completed m.login.email.identity auth, the sid for the email verification session. - */ - emailSid?: string; - - /** - * Called with the new auth dict to submit the request. - * Also passes a second deprecated arg which is a flag set to true if this request is a background request. - * The busyChanged callback should be used instead of the background flag. - * Should return a promise which resolves to the successful response or rejects with a MatrixError. - */ - doRequest(auth: IAuthData | null, background: boolean): Promise<IAuthData>; - /** - * Called when the status of the UI auth changes, - * ie. when the state of an auth stage changes of when the auth flow moves to a new stage. - * The arguments are: the login type (eg m.login.password); and an object which is either an error or an - * informational object specific to the login type. - * If the 'errcode' key is defined, the object is an error, and has keys: - * errcode: string, the textual error code, eg. M_UNKNOWN - * error: string, human readable string describing the error - * - * The login type specific objects are as follows: - * m.login.email.identity: - * * emailSid: string, the sid of the active email auth session - */ - stateUpdated(nextStage: AuthType, status: IStageStatus): void; - - /** - * A function that takes the email address (string), clientSecret (string), attempt number (int) and - * sessionId (string) and calls the relevant requestToken function and returns the promise returned by that - * function. - * If the resulting promise rejects, the rejection will propagate through to the attemptAuth promise. - */ - requestEmailToken(email: string, secret: string, attempt: number, session: string): Promise<{ sid: string }>; - /** - * Called whenever the interactive auth logic becomes busy submitting information provided by the user or finishes. - * After this has been called with true the UI should indicate that a request is in progress - * until it is called again with false. - */ - busyChanged?(busy: boolean): void; - startAuthStage?(nextStage: string): Promise<void>; // LEGACY -} - -/** - * 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 - */ -export class InteractiveAuth { - private readonly matrixClient: MatrixClient; - private readonly inputs: IInputs; - private readonly clientSecret: string; - private readonly requestCallback: IOpts["doRequest"]; - private readonly busyChangedCallback?: IOpts["busyChanged"]; - private readonly stateUpdatedCallback: IOpts["stateUpdated"]; - private readonly requestEmailTokenCallback: IOpts["requestEmailToken"]; - - private data: IAuthData; - private emailSid?: string; - private requestingEmailToken = false; - private attemptAuthDeferred: IDeferred<IAuthData> | null = null; - private chosenFlow: UIAFlow | null = null; - private currentStage: string | null = null; - - private emailAttempt = 1; - - // if we are currently trying to submit an auth dict (which includes polling) - // the promise the will resolve/reject when it completes - private submitPromise: Promise<void> | null = null; - - public constructor(opts: IOpts) { - 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; - } - - /** - * 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 - */ - public attemptAuth(): Promise<IAuthData> { - // This promise will be quite long-lived and will resolve when the - // request is authenticated and completes successfully. - this.attemptAuthDeferred = 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. - */ - public async poll(): Promise<void> { - 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: IAuthDict = {}; - 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: Record<string, string> = { - 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 - */ - public getSessionId(): string | undefined { - return this.data?.session; - } - - /** - * get the client secret used for validation sessions - * with the identity server. - * - * @returns client secret - */ - public getClientSecret(): string { - 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 - */ - public getStageParams(loginType: string): Record<string, any> | undefined { - return this.data.params?.[loginType]; - } - - public getChosenFlow(): UIAFlow | null { - 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. - */ - public async submitAuthDict(authData: IAuthDict, background = false): Promise<void> { - 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: IAuthDict; - 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 - */ - public getEmailSid(): string | undefined { - 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 - */ - public setEmailSid(sid: string): void { - this.emailSid = sid; - } - - /** - * Requests a new email token and sets the email sid for the validation session - */ - public requestEmailToken = async (): Promise<void> => { - if (!this.requestingEmailToken) { - 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.trace("Email token request succeeded"); - } finally { - this.requestingEmailToken = false; - } - } else { - logger.warn("Could not request email token: Already requesting"); - } - }; - - /** - * 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. - */ - private async doRequest(auth: IAuthData | null, background = false): Promise<void> { - 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 = (<MatrixError>error).data?.flows ?? null; - const haveFlows = this.data.flows || Boolean(errorFlows); - if ((<MatrixError>error).httpStatus !== 401 || !(<MatrixError>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.log("Background poll request failed doing UI auth: ignoring", error); - } - } - if (!(<MatrixError>error).data) { - (<MatrixError>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 ( - !(<MatrixError>error).data.flows && - !(<MatrixError>error).data.completed && - !(<MatrixError>error).data.session - ) { - (<MatrixError>error).data.flows = this.data.flows; - (<MatrixError>error).data.completed = this.data.completed; - (<MatrixError>error).data.session = this.data.session; - } - this.data = (<MatrixError>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 - */ - private startNextAuthStage(): void { - 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 - */ - private chooseStage(): AuthType | undefined { - if (this.chosenFlow === null) { - this.chosenFlow = this.chooseFlow(); - } - logger.log("Active flow => %s", JSON.stringify(this.chosenFlow)); - const nextStage = this.firstUncompletedStage(this.chosenFlow); - logger.log("Next stage: %s", nextStage); - return nextStage; - } - - /** - * Pick one of the flows from the returned list - * If a flow using all of the inputs is found, it will - * be returned, otherwise, null will be returned. - * - * Only flows using all given inputs are chosen because it - * is 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 - */ - private chooseFlow(): UIAFlow { - const flows = this.data.flows || []; - - // we've been given an email or we've already done an email part - const haveEmail = Boolean(this.inputs.emailAddress) || Boolean(this.emailSid); - const haveMsisdn = Boolean(this.inputs.phoneCountry) && Boolean(this.inputs.phoneNumber); - - for (const flow of flows) { - let flowHasEmail = false; - let flowHasMsisdn = false; - for (const stage of flow.stages) { - if (stage === EMAIL_STAGE_TYPE) { - flowHasEmail = true; - } else if (stage == MSISDN_STAGE_TYPE) { - flowHasMsisdn = true; - } - } - - if (flowHasEmail == haveEmail && flowHasMsisdn == haveMsisdn) { - return flow; - } - } - - const requiredStages: string[] = []; - 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 - */ - private firstUncompletedStage(flow: UIAFlow): AuthType | undefined { - const completed = this.data.completed || []; - return flow.stages.find((stageType) => !completed.includes(stageType)); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/logger.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/logger.ts deleted file mode 100644 index ba7f742..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/logger.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* -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. -*/ - -import log, { Logger } from "loglevel"; - -// This is to demonstrate, that you can use any namespace you want. -// Namespaces allow you to turn on/off the logging for specific parts of the -// application. -// An idea would be to control this via an environment variable (on Node.js). -// See https://www.npmjs.com/package/debug to see how this could be implemented -// Part of #332 is introducing a logging library in the first place. -const DEFAULT_NAMESPACE = "matrix"; - -// because rageshakes in react-sdk hijack the console log, also at module load time, -// initializing the logger here races with the initialization of rageshakes. -// to avoid the issue, we override the methodFactory of loglevel that binds to the -// console methods at initialization time by a factory that looks up the console methods -// when logging so we always get the current value of console methods. -log.methodFactory = function (methodName, logLevel, loggerName) { - return function (this: PrefixedLogger, ...args): void { - /* 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. - */ -export const logger = log.getLogger(DEFAULT_NAMESPACE) as PrefixedLogger; -logger.setLevel(log.levels.DEBUG, false); - -export interface PrefixedLogger extends Logger { - withPrefix: (prefix: string) => PrefixedLogger; - prefix: string; -} - -function extendLogger(logger: Logger): void { - (<PrefixedLogger>logger).withPrefix = function (prefix: string): PrefixedLogger { - const existingPrefix = this.prefix || ""; - return getPrefixedLogger(existingPrefix + prefix); - }; -} - -extendLogger(logger); - -function getPrefixedLogger(prefix: string): PrefixedLogger { - const prefixLogger = log.getLogger(`${DEFAULT_NAMESPACE}-${prefix}`) as PrefixedLogger; - if (prefixLogger.prefix !== prefix) { - // Only do this setup work the first time through, as loggers are saved by name. - extendLogger(prefixLogger); - prefixLogger.prefix = prefix; - prefixLogger.setLevel(log.levels.DEBUG, false); - } - return prefixLogger; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/matrix.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/matrix.ts deleted file mode 100644 index 591c5e3..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/matrix.ts +++ /dev/null @@ -1,110 +0,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. -*/ - -import { WidgetApi } from "matrix-widget-api"; - -import { MemoryCryptoStore } from "./crypto/store/memory-crypto-store"; -import { MemoryStore } from "./store/memory"; -import { MatrixScheduler } from "./scheduler"; -import { MatrixClient, ICreateClientOpts } from "./client"; -import { RoomWidgetClient, ICapabilities } from "./embedded"; -import { CryptoStore } from "./crypto/store/base"; - -export * from "./client"; -export * from "./embedded"; -export * from "./http-api"; -export * from "./autodiscovery"; -export * from "./sync-accumulator"; -export * from "./errors"; -export * from "./models/beacon"; -export * from "./models/event"; -export * from "./models/room"; -export * from "./models/event-timeline"; -export * from "./models/event-timeline-set"; -export * from "./models/poll"; -export * from "./models/room-member"; -export * from "./models/room-state"; -export * from "./models/user"; -export * from "./scheduler"; -export * from "./filter"; -export * from "./timeline-window"; -export * from "./interactive-auth"; -export * from "./service-types"; -export * from "./store/memory"; -export * from "./store/indexeddb"; -export * from "./crypto/store/memory-crypto-store"; -export * from "./crypto/store/indexeddb-crypto-store"; -export * from "./content-repo"; -export * from "./@types/event"; -export * from "./@types/PushRules"; -export * from "./@types/partials"; -export * from "./@types/requests"; -export * from "./@types/search"; -export * from "./models/room-summary"; -export * as ContentHelpers from "./content-helpers"; -export * as SecretStorage from "./secret-storage"; -export type { ICryptoCallbacks } from "./crypto"; // used to be located here -export { createNewMatrixCall } from "./webrtc/call"; -export type { MatrixCall } from "./webrtc/call"; -export { GroupCallEvent, GroupCallIntent, GroupCallState, GroupCallType } from "./webrtc/groupCall"; -export type { GroupCall } from "./webrtc/groupCall"; -export type { CryptoApi } from "./crypto-api"; - -let cryptoStoreFactory = (): CryptoStore => new MemoryCryptoStore(); - -/** - * Configure a different factory to be used for creating crypto stores - * - * @param fac - a function which will return a new {@link CryptoStore} - */ -export function setCryptoStoreFactory(fac: () => CryptoStore): void { - cryptoStoreFactory = fac; -} - -function amendClientOpts(opts: ICreateClientOpts): ICreateClientOpts { - opts.store = - opts.store ?? - new MemoryStore({ - localStorage: global.localStorage, - }); - opts.scheduler = opts.scheduler ?? new 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`. - */ -export function createClient(opts: ICreateClientOpts): MatrixClient { - return new MatrixClient(amendClientOpts(opts)); -} - -export function createRoomWidgetClient( - widgetApi: WidgetApi, - capabilities: ICapabilities, - roomId: string, - opts: ICreateClientOpts, -): MatrixClient { - return new RoomWidgetClient(widgetApi, capabilities, roomId, amendClientOpts(opts)); -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/MSC3089Branch.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/MSC3089Branch.ts deleted file mode 100644 index 27be4b8..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/MSC3089Branch.ts +++ /dev/null @@ -1,258 +0,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. -*/ - -import { MatrixClient } from "../client"; -import { IEncryptedFile, RelationType, UNSTABLE_MSC3089_BRANCH } from "../@types/event"; -import { IContent, MatrixEvent } from "./event"; -import { MSC3089TreeSpace } from "./MSC3089TreeSpace"; -import { EventTimeline } from "./event-timeline"; -import { FileType } from "../http-api"; -import type { ISendEventResponse } from "../@types/requests"; - -/** - * 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. - */ -export class MSC3089Branch { - public constructor( - private client: MatrixClient, - public readonly indexEvent: MatrixEvent, - public readonly directory: MSC3089TreeSpace, - ) { - // Nothing to do - } - - /** - * The file ID. - */ - public get id(): string { - const stateKey = this.indexEvent.getStateKey(); - if (!stateKey) { - throw new Error("State key not found for branch"); - } - return stateKey; - } - - /** - * Whether this branch is active/valid. - */ - public get isActive(): boolean { - return this.indexEvent.getContent()["active"] === true; - } - - /** - * Version for the file, one-indexed. - */ - public get version(): number { - return this.indexEvent.getContent()["version"] ?? 1; - } - - private get roomId(): string { - return this.indexEvent.getRoomId()!; - } - - /** - * Deletes the file from the tree, including all prior edits/versions. - * @returns Promise which resolves when complete. - */ - public async delete(): Promise<void> { - await this.client.sendStateEvent(this.roomId, 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. - */ - public getName(): string { - 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. - */ - public async setName(name: string): Promise<void> { - await this.client.sendStateEvent( - this.roomId, - UNSTABLE_MSC3089_BRANCH.name, - { - ...this.indexEvent.getContent(), - name: name, - }, - this.id, - ); - } - - /** - * Gets whether or not a file is locked. - * @returns True if locked, false otherwise. - */ - public isLocked(): boolean { - 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. - */ - public async setLocked(locked: boolean): Promise<void> { - await this.client.sendStateEvent( - this.roomId, - UNSTABLE_MSC3089_BRANCH.name, - { - ...this.indexEvent.getContent(), - locked: locked, - }, - this.id, - ); - } - - /** - * Gets information about the file needed to download it. - * @returns Information about the file. - */ - public async getFileInfo(): Promise<{ info: IEncryptedFile; httpUrl: string }> { - 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. - */ - public async getFileEvent(): Promise<MatrixEvent> { - const room = this.client.getRoom(this.roomId); - if (!room) throw new Error("Unknown room"); - - let event: MatrixEvent | undefined = 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.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. - */ - public async createNewVersion( - name: string, - encryptedContents: FileType, - info: Partial<IEncryptedFile>, - additionalContent?: IContent, - ): Promise<ISendEventResponse> { - const fileEventResponse = await this.directory.createFile(name, encryptedContents, info, { - ...(additionalContent ?? {}), - "m.new_content": true, - "m.relates_to": { - rel_type: RelationType.Replace, - event_id: this.id, - }, - }); - - // Update the version of the new event - await this.client.sendStateEvent( - this.roomId, - UNSTABLE_MSC3089_BRANCH.name, - { - active: true, - name: name, - version: this.version + 1, - }, - fileEventResponse["event_id"], - ); - - // Deprecate ourselves - await this.client.sendStateEvent( - this.roomId, - UNSTABLE_MSC3089_BRANCH.name, - { - ...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. - */ - public async getVersionHistory(): Promise<MSC3089Branch[]> { - const fileHistory: MSC3089Branch[] = []; - 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: MatrixEvent | undefined; - 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; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/MSC3089TreeSpace.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/MSC3089TreeSpace.ts deleted file mode 100644 index b0e71d9..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/MSC3089TreeSpace.ts +++ /dev/null @@ -1,566 +0,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. -*/ - -import promiseRetry from "p-retry"; - -import { MatrixClient } from "../client"; -import { EventType, IEncryptedFile, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF } from "../@types/event"; -import { Room } from "./room"; -import { logger } from "../logger"; -import { IContent, MatrixEvent } from "./event"; -import { - averageBetweenStrings, - DEFAULT_ALPHABET, - lexicographicCompare, - nextString, - prevString, - simpleRetryOperation, -} from "../utils"; -import { MSC3089Branch } from "./MSC3089Branch"; -import { isRoomSharedHistory } from "../crypto/algorithms/megolm"; -import { ISendEventResponse } from "../@types/requests"; -import { FileType } from "../http-api"; - -/** - * The recommended defaults for a tree space's power levels. Note that this - * is UNSTABLE and subject to breaking changes without notice. - */ -export 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: { - [EventType.RoomPowerLevels]: 100, - [EventType.RoomHistoryVisibility]: 100, - [EventType.RoomTombstone]: 100, - [EventType.RoomEncryption]: 100, - [EventType.RoomName]: 50, - [EventType.RoomMessage]: 50, - [EventType.RoomMessageEncrypted]: 50, - [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. - */ -export enum TreePermissions { - Viewer = "viewer", // Default - Editor = "editor", // "Moderator" or ~PL50 - Owner = "owner", // "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. - */ -export class MSC3089TreeSpace { - public readonly room: Room; - - public constructor(private client: MatrixClient, public readonly roomId: string) { - this.room = this.client.getRoom(this.roomId)!; - - if (!this.room) throw new Error("Unknown room"); - } - - /** - * Syntactic sugar for room ID of the Space. - */ - public get id(): string { - return this.roomId; - } - - /** - * Whether or not this is a top level space. - */ - public get isTopLevel(): boolean { - // 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(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. - */ - public async setName(name: string): Promise<void> { - await this.client.sendStateEvent(this.roomId, 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. - */ - public async invite(userId: string, andSubspaces = true, shareHistoryKeys = true): Promise<void> { - const promises: Promise<void>[] = [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 && isRoomSharedHistory(this.room)) { - // noinspection JSIgnoredPromiseFromCall - we aren't concerned as much if this fails. - this.client.sendSharedHistoryKeys(this.roomId, [userId]); - } - }); - } - - private retryInvite(userId: string): Promise<void> { - return 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 promiseRetry.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. - */ - public async setPermissions(userId: string, role: TreePermissions): Promise<void> { - const currentPls = this.room.currentState.getStateEvents(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"]?.[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, 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. - */ - public getPermissions(userId: string): TreePermissions { - const currentPls = this.room.currentState.getStateEvents(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"]?.[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. - */ - public async createDirectory(name: string): Promise<MSC3089TreeSpace> { - const directory = await this.client.unstableCreateFileTree(name); - - await this.client.sendStateEvent( - this.roomId, - EventType.SpaceChild, - { - via: [this.client.getDomain()], - }, - directory.roomId, - ); - - await this.client.sendStateEvent( - directory.roomId, - 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. - */ - public getDirectories(): MSC3089TreeSpace[] { - const trees: MSC3089TreeSpace[] = []; - const children = this.room.currentState.getStateEvents(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.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. - */ - public getDirectory(roomId: string): MSC3089TreeSpace | undefined { - return this.getDirectories().find((r) => r.roomId === roomId); - } - - /** - * Deletes the tree, kicking all members and deleting **all subdirectories**. - * @returns Promise which resolves when complete. - */ - public async delete(): Promise<void> { - const subdirectories = this.getDirectories(); - for (const dir of subdirectories) { - await dir.delete(); - } - - const kickMemberships = ["invite", "knock", "join"]; - const members = this.room.currentState.getStateEvents(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); - } - - private getOrderedChildren(children: MatrixEvent[]): { roomId: string; order: string }[] { - const ordered: { roomId: string; order: string }[] = children - .map((c) => ({ roomId: c.getStateKey(), order: c.getContent()["order"] })) - .filter((c) => c.roomId) as { roomId: string; order: string }[]; - 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 lexicographicCompare(a.roomId, b.roomId); - } - - const createTsA = roomA.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs() ?? 0; - const createTsB = roomB.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs() ?? 0; - if (createTsA === createTsB) { - return lexicographicCompare(a.roomId, b.roomId); - } - return createTsA - createTsB; - } else { - // both not-null orders - return lexicographicCompare(a.order, b.order); - } - }); - return ordered; - } - - private getParentRoom(): Room { - const parents = this.room.currentState.getStateEvents(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. - */ - public getOrder(): number { - if (this.isTopLevel) return -1; - - const parentRoom = this.getParentRoom(); - const children = parentRoom.currentState.getStateEvents(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. - */ - public async setOrder(index: number): Promise<void> { - if (this.isTopLevel) throw new Error("Cannot set order of top level spaces currently"); - - const parentRoom = this.getParentRoom(); - const children = parentRoom.currentState.getStateEvents(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 = DEFAULT_ALPHABET[0]; - let ensureBeforeIsSane = false; - if (!prev) { - // Move to front - if (next?.order) { - newOrder = prevString(next.order); - } - } else if (index === ordered.length - 1) { - // Move to back - if (next?.order) { - newOrder = 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 = nextString(startOrder); - } else { - newOrder = averageBetweenStrings(startOrder, endOrder); - } - } else { - if (startOrder) { - // We're at the end (endOrder is null, so no explicit order) - newOrder = nextString(startOrder); - } else if (endOrder) { - // We're at the start (startOrder is null, so nothing before us) - newOrder = 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: string | undefined; - 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 ? nextString(lastOrder) : DEFAULT_ALPHABET[0]; - const currentChild = parentRoom.currentState.getStateEvents(EventType.SpaceChild, target.roomId); - const content = currentChild?.getContent() ?? { via: [this.client.getDomain()] }; - await this.client.sendStateEvent( - parentRoom.roomId, - EventType.SpaceChild, - { - ...content, - order: lastOrder, - }, - target.roomId, - ); - } else { - lastOrder = target.order; - } - } - if (lastOrder) { - newOrder = nextString(lastOrder); - } - } - - // TODO: Deal with order conflicts by reordering - - // Now we can finally update our own order state - const currentChild = parentRoom.currentState.getStateEvents(EventType.SpaceChild, this.roomId); - const content = currentChild?.getContent() ?? { via: [this.client.getDomain()] }; - await this.client.sendStateEvent( - parentRoom.roomId, - EventType.SpaceChild, - { - ...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. - */ - public async createFile( - name: string, - encryptedContents: FileType, - info: Partial<IEncryptedFile>, - additionalContent?: IContent, - ): Promise<ISendEventResponse> { - const { content_uri: mxc } = await this.client.uploadContent(encryptedContents, { - includeFilename: false, - }); - info.url = mxc; - - const fileContent = { - msgtype: 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, { - ...additionalContent, - ...fileContent, - [UNSTABLE_MSC3089_LEAF.name]: {}, - }); - - await this.client.sendStateEvent( - this.roomId, - 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. - */ - public getFile(fileEventId: string): MSC3089Branch | null { - const branch = this.room.currentState.getStateEvents(UNSTABLE_MSC3089_BRANCH.name, fileEventId); - return branch ? new 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. - */ - public listFiles(): MSC3089Branch[] { - 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. - */ - public listAllFiles(): MSC3089Branch[] { - const branches = this.room.currentState.getStateEvents(UNSTABLE_MSC3089_BRANCH.name) ?? []; - return branches.map((e) => new MSC3089Branch(this.client, e, this)); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/ToDeviceMessage.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/ToDeviceMessage.ts deleted file mode 100644 index 8efc3ed..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/ToDeviceMessage.ts +++ /dev/null @@ -1,38 +0,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. -*/ - -export type ToDevicePayload = Record<string, any>; - -export interface ToDeviceMessage { - userId: string; - deviceId: string; - payload: ToDevicePayload; -} - -export interface ToDeviceBatch { - eventType: string; - batch: ToDeviceMessage[]; -} - -// Only used internally -export interface ToDeviceBatchWithTxnId extends ToDeviceBatch { - txnId: string; -} - -// Only used internally -export interface IndexedToDeviceBatch extends ToDeviceBatchWithTxnId { - id: number; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/beacon.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/beacon.ts deleted file mode 100644 index 3801831..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/beacon.ts +++ /dev/null @@ -1,209 +0,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. -*/ - -import { MBeaconEventContent } from "../@types/beacon"; -import { BeaconInfoState, BeaconLocationState, parseBeaconContent, parseBeaconInfoContent } from "../content-helpers"; -import { MatrixEvent } from "./event"; -import { sortEventsByLatestContentTimestamp } from "../utils"; -import { TypedEventEmitter } from "./typed-event-emitter"; - -export enum BeaconEvent { - New = "Beacon.new", - Update = "Beacon.update", - LivenessChange = "Beacon.LivenessChange", - Destroy = "Beacon.Destroy", - LocationUpdate = "Beacon.LocationUpdate", -} - -export type BeaconEventHandlerMap = { - [BeaconEvent.Update]: (event: MatrixEvent, beacon: Beacon) => void; - [BeaconEvent.LivenessChange]: (isLive: boolean, beacon: Beacon) => void; - [BeaconEvent.Destroy]: (beaconIdentifier: string) => void; - [BeaconEvent.LocationUpdate]: (locationState: BeaconLocationState) => void; - [BeaconEvent.Destroy]: (beaconIdentifier: string) => void; -}; - -export const isTimestampInDuration = (startTimestamp: number, durationMs: number, timestamp: number): boolean => - timestamp >= startTimestamp && startTimestamp + durationMs >= timestamp; - -// beacon info events are uniquely identified by -// `<roomId>_<state_key>` -export type BeaconIdentifier = string; -export const getBeaconInfoIdentifier = (event: MatrixEvent): BeaconIdentifier => - `${event.getRoomId()}_${event.getStateKey()}`; - -// https://github.com/matrix-org/matrix-spec-proposals/pull/3672 -export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.New>, BeaconEventHandlerMap> { - public readonly roomId: string; - // beaconInfo is assigned by setBeaconInfo in the constructor - // ! to make tsc believe it is definitely assigned - private _beaconInfo!: BeaconInfoState; - private _isLive?: boolean; - private livenessWatchTimeout?: ReturnType<typeof setTimeout>; - private _latestLocationEvent?: MatrixEvent; - - public constructor(private rootEvent: MatrixEvent) { - super(); - this.roomId = this.rootEvent.getRoomId()!; - this.setBeaconInfo(this.rootEvent); - } - - public get isLive(): boolean { - return !!this._isLive; - } - - public get identifier(): BeaconIdentifier { - return getBeaconInfoIdentifier(this.rootEvent); - } - - public get beaconInfoId(): string { - return this.rootEvent.getId()!; - } - - public get beaconInfoOwner(): string { - return this.rootEvent.getStateKey()!; - } - - public get beaconInfoEventType(): string { - return this.rootEvent.getType(); - } - - public get beaconInfo(): BeaconInfoState { - return this._beaconInfo; - } - - public get latestLocationState(): BeaconLocationState | undefined { - return this._latestLocationEvent && parseBeaconContent(this._latestLocationEvent.getContent()); - } - - public get latestLocationEvent(): MatrixEvent | undefined { - return this._latestLocationEvent; - } - - public update(beaconInfoEvent: MatrixEvent): void { - 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(); - } - - public destroy(): void { - 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 - */ - public monitorLiveness(): void { - 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 - */ - public addLocations(beaconLocationEvents: MatrixEvent[]): void { - // discard locations for beacons that are not live - if (!this.isLive) { - return; - } - - const validLocationEvents = beaconLocationEvents.filter((event) => { - const content = event.getContent<MBeaconEventContent>(); - const parsed = 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(sortEventsByLatestContentTimestamp)?.[0]; - - if (latestLocationEvent) { - this._latestLocationEvent = latestLocationEvent; - this.emit(BeaconEvent.LocationUpdate, this.latestLocationState!); - } - } - - private clearLatestLocation = (): void => { - this._latestLocationEvent = undefined; - this.emit(BeaconEvent.LocationUpdate, this.latestLocationState!); - }; - - private setBeaconInfo(event: MatrixEvent): void { - this._beaconInfo = parseBeaconInfoContent(event.getContent()); - this.checkLiveness(); - } - - private checkLiveness(): void { - 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); - } - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-context.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-context.ts deleted file mode 100644 index 0401cd5..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-context.ts +++ /dev/null @@ -1,110 +0,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. -*/ - -import { MatrixEvent } from "./event"; -import { Direction } from "./event-timeline"; - -export class EventContext { - private timeline: MatrixEvent[]; - private ourEventIndex = 0; - private paginateTokens: Record<Direction, string | null> = { - [Direction.Backward]: null, - [Direction.Forward]: null, - }; - - /** - * 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 - */ - public constructor(public readonly ourEvent: MatrixEvent) { - 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. - */ - public getEvent(): MatrixEvent { - return this.timeline[this.ourEventIndex]; - } - - /** - * Get the list of events in this context - * - * @returns An array of MatrixEvents - */ - public getTimeline(): MatrixEvent[] { - return this.timeline; - } - - /** - * Get the index in the timeline of our event - */ - public getOurEventIndex(): number { - return this.ourEventIndex; - } - - /** - * Get a pagination token. - * - * @param backwards - true to get the pagination token for going - */ - public getPaginateToken(backwards = false): string | null { - return this.paginateTokens[backwards ? Direction.Backward : 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 - */ - public setPaginateToken(token?: string, backwards = false): void { - this.paginateTokens[backwards ? Direction.Backward : 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 - */ - public addEvents(events: MatrixEvent[], atStart = false): void { - // 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); - } - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-status.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-status.ts deleted file mode 100644 index a5113e0..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-status.ts +++ /dev/null @@ -1,39 +0,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 - */ -export enum EventStatus { - /** The event was not sent and will no longer be retried. */ - NOT_SENT = "not_sent", - - /** The message is being encrypted */ - ENCRYPTING = "encrypting", - - /** The event is in the process of being sent. */ - SENDING = "sending", - - /** The event is in a queue waiting to be sent. */ - QUEUED = "queued", - - /** The event has been sent to the server, but we have not yet received the echo. */ - SENT = "sent", - - /** The event was cancelled before it was successfully sent. */ - CANCELLED = "cancelled", -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-timeline-set.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-timeline-set.ts deleted file mode 100644 index 5cb0499..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-timeline-set.ts +++ /dev/null @@ -1,906 +0,0 @@ -/* -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. -*/ - -import { EventTimeline, IAddEventOptions } from "./event-timeline"; -import { MatrixEvent } from "./event"; -import { logger } from "../logger"; -import { Room, RoomEvent } from "./room"; -import { Filter } from "../filter"; -import { RoomState } from "./room-state"; -import { TypedEventEmitter } from "./typed-event-emitter"; -import { RelationsContainer } from "./relations-container"; -import { MatrixClient } from "../client"; -import { Thread, ThreadFilterType } from "./thread"; - -const DEBUG = true; - -/* istanbul ignore next */ -let debuglog: (...args: any[]) => void; -if (DEBUG) { - // using bind means that we get to keep useful line numbers in the console - debuglog = logger.log.bind(logger); -} else { - /* istanbul ignore next */ - debuglog = function (): void {}; -} - -interface IOpts { - // Set to true to enable improved timeline support. - timelineSupport?: boolean; - // The filter object, if any, for this timelineSet. - filter?: Filter; - pendingEvents?: boolean; -} - -export enum DuplicateStrategy { - Ignore = "ignore", - Replace = "replace", -} - -export interface IRoomTimelineData { - // the timeline the event was added to/removed from - timeline: EventTimeline; - // true if the event was a real-time event added to the end of the live timeline - liveEvent?: boolean; -} - -export interface IAddEventToTimelineOptions - extends Pick<IAddEventOptions, "toStartOfTimeline" | "roomState" | "timelineWasEmpty"> { - /** Whether the sync response came from cache */ - fromCache?: boolean; -} - -export interface IAddLiveEventOptions - extends Pick<IAddEventToTimelineOptions, "fromCache" | "roomState" | "timelineWasEmpty"> { - /** Applies to events in the timeline only. If this is 'replace' then if a - * duplicate is encountered, the event passed to this function will replace - * the existing event in the timeline. If this is not specified, or is - * 'ignore', then the event passed to this function will be ignored - * entirely, preserving the existing event in the timeline. Events are - * identical based on their event ID <b>only</b>. */ - duplicateStrategy?: DuplicateStrategy; -} - -type EmittedEvents = RoomEvent.Timeline | RoomEvent.TimelineReset; - -export type EventTimelineSetHandlerMap = { - /** - * Fires whenever the timeline in a room is updated. - * @param event - The matrix event which caused this event to fire. - * @param room - The room, if any, whose timeline was updated. - * @param toStartOfTimeline - True if this event was added to the start - * @param removed - True if this event has just been removed from the timeline - * (beginning; oldest) of the timeline e.g. due to pagination. - * - * @param data - more data about the event - * - * @example - * ``` - * matrixClient.on("Room.timeline", - * function(event, room, toStartOfTimeline, removed, data) { - * if (!toStartOfTimeline && data.liveEvent) { - * var messageToAppend = room.timeline.[room.timeline.length - 1]; - * } - * }); - * ``` - */ - [RoomEvent.Timeline]: ( - event: MatrixEvent, - room: Room | undefined, - toStartOfTimeline: boolean | undefined, - removed: boolean, - data: IRoomTimelineData, - ) => void; - /** - * Fires whenever the live timeline in a room is reset. - * - * When we get a 'limited' sync (for example, after a network outage), we reset - * the live timeline to be empty before adding the recent events to the new - * timeline. This event is fired after the timeline is reset, and before the - * new events are added. - * - * @param room - The room whose live timeline was reset, if any - * @param timelineSet - timelineSet room whose live timeline was reset - * @param resetAllTimelines - True if all timelines were reset. - */ - [RoomEvent.TimelineReset]: ( - room: Room | undefined, - eventTimelineSet: EventTimelineSet, - resetAllTimelines: boolean, - ) => void; -}; - -export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTimelineSetHandlerMap> { - public readonly relations: RelationsContainer; - private readonly timelineSupport: boolean; - private readonly displayPendingEvents: boolean; - private liveTimeline: EventTimeline; - private timelines: EventTimeline[]; - private _eventIdToTimeline = new Map<string, EventTimeline>(); - private filter?: Filter; - - /** - * 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) - */ - public constructor( - public readonly room: Room | undefined, - opts: IOpts = {}, - client?: MatrixClient, - public readonly thread?: Thread, - public readonly threadListType: ThreadFilterType | null = null, - ) { - super(); - - this.timelineSupport = Boolean(opts.timelineSupport); - this.liveTimeline = new EventTimeline(this); - this.displayPendingEvents = opts.pendingEvents !== false; - - // just a list - *not* ordered. - this.timelines = [this.liveTimeline]; - this._eventIdToTimeline = new Map<string, EventTimeline>(); - - this.filter = opts.filter; - - this.relations = this.room?.relations ?? new RelationsContainer(room?.client ?? client!); - } - - /** - * Get all the timelines in this set - * @returns the timelines in this set - */ - public getTimelines(): EventTimeline[] { - return this.timelines; - } - - /** - * Get the filter object this timeline set is filtered on, if any - * @returns the optional filter for this timelineSet - */ - public getFilter(): Filter | undefined { - 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 - */ - public setFilter(filter?: Filter): void { - 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' - */ - public getPendingEvents(): MatrixEvent[] { - if (!this.room || !this.displayPendingEvents) { - return []; - } - - return this.room.getPendingEvents(); - } - /** - * Get the live timeline for this room. - * - * @returns live timeline - */ - public getLiveTimeline(): EventTimeline { - return this.liveTimeline; - } - - /** - * Set the live timeline for this room. - * - * @returns live timeline - */ - public setLiveTimeline(timeline: EventTimeline): void { - this.liveTimeline = timeline; - } - - /** - * Return the timeline (if any) this event is in. - * @param eventId - the eventId being sought - * @returns timeline - */ - public eventIdToTimeline(eventId: string): EventTimeline | undefined { - 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 - */ - public replaceEventId(oldEventId: string, newEventId: string): void { - 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} - */ - public resetLiveTimeline(backPaginationToken?: string, forwardPaginationToken?: string): void { - // Each EventTimeline has RoomState objects tracking the state at the start - // and end of that timeline. The copies at the end of the live timeline are - // special because they will have listeners attached to monitor changes to - // the current room state, so we move this RoomState from the end of the - // current live timeline to the end of the new one and, if necessary, - // replace it with a newly created one. We also make a copy for the start - // of the new timeline. - - // if timeline support is disabled, forget about the old timelines - const resetAllTimelines = !this.timelineSupport || !forwardPaginationToken; - - const oldTimeline = this.liveTimeline; - const newTimeline = resetAllTimelines - ? oldTimeline.forkLive(EventTimeline.FORWARDS) - : oldTimeline.fork(EventTimeline.FORWARDS); - - if (resetAllTimelines) { - this.timelines = [newTimeline]; - this._eventIdToTimeline = new Map<string, EventTimeline>(); - } else { - this.timelines.push(newTimeline); - } - - if (forwardPaginationToken) { - // Now set the forward pagination token on the old live timeline - // so it can be forward-paginated. - oldTimeline.setPaginationToken(forwardPaginationToken, EventTimeline.FORWARDS); - } - - // make sure we set the pagination token before firing timelineReset, - // otherwise clients which start back-paginating will fail, and then get - // stuck without realising that they *can* back-paginate. - newTimeline.setPaginationToken(backPaginationToken ?? null, EventTimeline.BACKWARDS); - - // Now we can swap the live timeline to the new one. - this.liveTimeline = newTimeline; - this.emit(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 - */ - public getTimelineForEvent(eventId?: string): EventTimeline | null { - 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 - */ - public findEventById(eventId: string): MatrixEvent | undefined { - 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 - */ - public addTimeline(): EventTimeline { - if (!this.timelineSupport) { - throw new Error( - "timeline support is disabled. Set the 'timelineSupport'" + - " parameter to true when creating MatrixClient to enable" + - " it.", - ); - } - - const timeline = new EventTimeline(this); - this.timelines.push(timeline); - return timeline; - } - - /** - * Add events to a timeline - * - * <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} - * - */ - public addEventsToTimeline( - events: MatrixEvent[], - toStartOfTimeline: boolean, - timeline: EventTimeline, - paginationToken?: string | null, - ): void { - if (!timeline) { - throw new Error("'timeline' not specified for EventTimelineSet.addEventsToTimeline"); - } - - if (!toStartOfTimeline && timeline == this.liveTimeline) { - throw new Error( - "EventTimelineSet.addEventsToTimeline cannot be used for adding events to " + - "the live timeline - use Room.addLiveEvents instead", - ); - } - - if (this.filter) { - events = this.filter.filterRoomTimeline(events); - if (!events.length) { - return; - } - } - - const direction = toStartOfTimeline ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; - const inverseDirection = toStartOfTimeline ? EventTimeline.FORWARDS : EventTimeline.BACKWARDS; - - // Adding events to timelines can be quite complicated. The following - // illustrates some of the corner-cases. - // - // Let's say we start by knowing about four timelines. timeline3 and - // timeline4 are neighbours: - // - // timeline1 timeline2 timeline3 timeline4 - // [M] [P] [S] <------> [T] - // - // Now we paginate timeline1, and get the following events from the server: - // [M, N, P, R, S, T, U]. - // - // 1. First, we ignore event M, since we already know about it. - // - // 2. Next, we append N to timeline 1. - // - // 3. Next, we don't add event P, since we already know about it, - // but we do link together the timelines. We now have: - // - // timeline1 timeline2 timeline3 timeline4 - // [M, N] <---> [P] [S] <------> [T] - // - // 4. Now we add event R to timeline2: - // - // timeline1 timeline2 timeline3 timeline4 - // [M, N] <---> [P, R] [S] <------> [T] - // - // Note that we have switched the timeline we are working on from - // timeline1 to timeline2. - // - // 5. We ignore event S, but again join the timelines: - // - // timeline1 timeline2 timeline3 timeline4 - // [M, N] <---> [P, R] <---> [S] <------> [T] - // - // 6. We ignore event T, and the timelines are already joined, so there - // is nothing to do. - // - // 7. Finally, we add event U to timeline4: - // - // timeline1 timeline2 timeline3 timeline4 - // [M, N] <---> [P, R] <---> [S] <------> [T, U] - // - // The important thing to note in the above is what happened when we - // already knew about a given event: - // - // - if it was appropriate, we joined up the timelines (steps 3, 5). - // - in any case, we started adding further events to the timeline which - // contained the event we knew about (steps 3, 5, 6). - // - // - // So much for adding events to the timeline. But what do we want to do - // with the pagination token? - // - // In the case above, we will be given a pagination token which tells us how to - // get events beyond 'U' - in this case, it makes sense to store this - // against timeline4. But what if timeline4 already had 'U' and beyond? in - // that case, our best bet is to throw away the pagination token we were - // given and stick with whatever token timeline4 had previously. In short, - // we want to only store the pagination token if the last event we receive - // is one we didn't previously know about. - // - // We make an exception for this if it turns out that we already knew about - // *all* of the events, and we weren't able to join up any timelines. When - // that happens, it means our existing pagination token is faulty, since it - // is only telling us what we already know. Rather than repeatedly - // paginating with the same token, we might as well use the new pagination - // token in the hope that we eventually work our way out of the mess. - - let didUpdate = false; - let lastEventWasNew = false; - for (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.info( - "Already have timeline for " + eventId + " - joining timeline " + timeline + " to " + existingTimeline, - ); - - // Variables to keep the line length limited below. - const existingIsLive = existingTimeline === this.liveTimeline; - const timelineIsLive = timeline === this.liveTimeline; - - const backwardsIsLive = direction === EventTimeline.BACKWARDS && existingIsLive; - const forwardsIsLive = direction === EventTimeline.FORWARDS && timelineIsLive; - - if (backwardsIsLive || forwardsIsLive) { - // The live timeline should never be spliced into a non-live position. - // We use independent logging to better discover the problem at a glance. - if (backwardsIsLive) { - logger.warn( - "Refusing to set a preceding existingTimeLine on our " + - "timeline as the existingTimeLine is live (" + - existingTimeline + - ")", - ); - } - if (forwardsIsLive) { - logger.warn( - "Refusing to set our preceding timeline on a existingTimeLine " + - "as our timeline is live (" + - timeline + - ")", - ); - } - continue; // abort splicing - try next event - } - - timeline.setNeighbouringTimeline(existingTimeline, direction); - existingTimeline.setNeighbouringTimeline(timeline, inverseDirection); - - timeline = existingTimeline; - didUpdate = true; - } - - // see above - if the last event was new to us, or if we didn't find any - // new information, we update the pagination token for whatever - // timeline we ended up on. - if (lastEventWasNew || !didUpdate) { - if (direction === EventTimeline.FORWARDS && timeline === this.liveTimeline) { - logger.warn({ lastEventWasNew, didUpdate }); // for debugging - logger.warn( - `Refusing to set forwards pagination token of live timeline ` + `${timeline} to ${paginationToken}`, - ); - return; - } - timeline.setPaginationToken(paginationToken ?? null, direction); - } - } - - /** - * Add an event to the end of this live timeline. - * - * @param event - Event to be added - * @param options - addLiveEvent options - */ - public addLiveEvent( - event: MatrixEvent, - { duplicateStrategy, fromCache, roomState, timelineWasEmpty }: IAddLiveEventOptions, - ): void; - /** - * @deprecated In favor of the overload with `IAddLiveEventOptions` - */ - public addLiveEvent( - event: MatrixEvent, - duplicateStrategy?: DuplicateStrategy, - fromCache?: boolean, - roomState?: RoomState, - ): void; - public addLiveEvent( - event: MatrixEvent, - duplicateStrategyOrOpts?: DuplicateStrategy | IAddLiveEventOptions, - fromCache = false, - roomState?: RoomState, - ): void { - let duplicateStrategy = (duplicateStrategyOrOpts as DuplicateStrategy) || DuplicateStrategy.Ignore; - let timelineWasEmpty: boolean | undefined; - 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.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.FORWARDS); - } - 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} - */ - public addEventToTimeline( - event: MatrixEvent, - timeline: EventTimeline, - { toStartOfTimeline, fromCache, roomState, timelineWasEmpty }: IAddEventToTimelineOptions, - ): void; - /** - * @deprecated In favor of the overload with `IAddEventToTimelineOptions` - */ - public addEventToTimeline( - event: MatrixEvent, - timeline: EventTimeline, - toStartOfTimeline: boolean, - fromCache?: boolean, - roomState?: RoomState, - ): void; - public addEventToTimeline( - event: MatrixEvent, - timeline: EventTimeline, - toStartOfTimelineOrOpts: boolean | IAddEventToTimelineOptions, - fromCache = false, - roomState?: RoomState, - ): void { - let toStartOfTimeline = !!toStartOfTimelineOrOpts; - let timelineWasEmpty: boolean | undefined; - 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.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.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: IRoomTimelineData = { - timeline: timeline, - liveEvent: !toStartOfTimeline && timeline == this.liveTimeline && !fromCache, - }; - this.emit(RoomEvent.Timeline, event, this.room, Boolean(toStartOfTimeline), false, data); - } - - /** - * Replaces event with ID oldEventId with one with newEventId, if oldEventId is - * recognised. Otherwise, add to the live timeline. Used to handle remote echos. - * - * @param 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} - */ - public handleRemoteEcho(localEvent: MatrixEvent, oldEventId: string, newEventId: string): void { - // 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. - */ - public removeEvent(eventId: string): MatrixEvent | null { - 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(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). - */ - public compareEventOrdering(eventId1: string, eventId2: string): number | null { - 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: number | undefined = undefined; - let idx2: number | undefined = 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: EventTimeline | null = timeline1; - while (tl) { - if (tl === timeline2) { - // timeline1 is before timeline2 - return -1; - } - tl = tl.getNeighbouringTimeline(EventTimeline.FORWARDS); - } - - // now try backwards from timeline1 - tl = timeline1; - while (tl) { - if (tl === timeline2) { - // timeline2 is before timeline1 - return 1; - } - tl = tl.getNeighbouringTimeline(EventTimeline.BACKWARDS); - } - - // the timelines are not contiguous. - return null; - } - - /** - * 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. - */ - public canContain(event: MatrixEvent): boolean { - 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; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-timeline.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-timeline.ts deleted file mode 100644 index d1ba321..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-timeline.ts +++ /dev/null @@ -1,458 +0,0 @@ -/* -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. -*/ - -import { logger } from "../logger"; -import { IMarkerFoundOptions, RoomState } from "./room-state"; -import { EventTimelineSet } from "./event-timeline-set"; -import { MatrixEvent } from "./event"; -import { Filter } from "../filter"; -import { EventType } from "../@types/event"; - -export interface IInitialiseStateOptions extends Pick<IMarkerFoundOptions, "timelineWasEmpty"> { - // This is a separate interface without any extra stuff currently added on - // top of `IMarkerFoundOptions` just because it feels like they have - // different concerns. One shouldn't necessarily look to add to - // `IMarkerFoundOptions` just because they want to add an extra option to - // `initialiseState`. -} - -export interface IAddEventOptions extends Pick<IMarkerFoundOptions, "timelineWasEmpty"> { - /** Whether to insert the new event at the start of the timeline where the - * oldest events are (timeline is in chronological order, oldest to most - * recent) */ - toStartOfTimeline: boolean; - /** The state events to reconcile metadata from */ - roomState?: RoomState; -} - -export enum Direction { - Backward = "b", - Forward = "f", -} - -export class EventTimeline { - /** - * Symbolic constant for methods which take a 'direction' argument: - * refers to the start of the timeline, or backwards in time. - */ - public static readonly BACKWARDS = Direction.Backward; - - /** - * Symbolic constant for methods which take a 'direction' argument: - * refers to the end of the timeline, or forwards in time. - */ - public static readonly FORWARDS = Direction.Forward; - - /** - * 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 - */ - public static setEventMetadata(event: MatrixEvent, stateContext: RoomState, toStartOfTimeline: boolean): void { - // 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() === 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; - } - } - } - - private readonly roomId: string | null; - private readonly name: string; - private events: MatrixEvent[] = []; - private baseIndex = 0; - - private startState?: RoomState; - private endState?: RoomState; - // 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. - private startToken: string | null = null; - private endToken: string | null = null; - - private prevTimeline: EventTimeline | null = null; - private nextTimeline: EventTimeline | null = null; - public paginationRequests: Record<Direction, Promise<boolean> | null> = { - [Direction.Backward]: null, - [Direction.Forward]: null, - }; - - /** - * 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 - */ - public constructor(private readonly eventTimelineSet: EventTimelineSet) { - this.roomId = eventTimelineSet.room?.roomId ?? null; - if (this.roomId) { - this.startState = new RoomState(this.roomId); - this.endState = new 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. - */ - public initialiseState(stateEvents: MatrixEvent[], { timelineWasEmpty }: IInitialiseStateOptions = {}): void { - 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 - */ - public forkLive(direction: Direction): EventTimeline { - 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 - */ - public fork(direction: Direction): EventTimeline { - 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 - */ - public getRoomId(): string | null { - return this.roomId; - } - - /** - * Get the filter for this timeline's timelineSet (if any) - * @returns filter - */ - public getFilter(): Filter | undefined { - return this.eventTimelineSet.getFilter(); - } - - /** - * Get the timelineSet for this timeline - * @returns timelineSet - */ - public getTimelineSet(): EventTimelineSet { - 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). - */ - public getBaseIndex(): number { - return this.baseIndex; - } - - /** - * Get the list of events in this context - * - * @returns An array of MatrixEvents - */ - public getEvents(): MatrixEvent[] { - 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 - */ - public getState(direction: Direction): RoomState | undefined { - 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 - */ - public getPaginationToken(direction: Direction): string | null { - 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. - */ - public setPaginationToken(token: string | null, direction: Direction): void { - 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. - */ - public getNeighbouringTimeline(direction: Direction): EventTimeline | null { - 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. - */ - public setNeighbouringTimeline(neighbour: EventTimeline, direction: Direction): void { - 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 - */ - public addEvent(event: MatrixEvent, { toStartOfTimeline, roomState, timelineWasEmpty }: IAddEventOptions): void; - /** - * @deprecated In favor of the overload with `IAddEventOptions` - */ - public addEvent(event: MatrixEvent, toStartOfTimeline: boolean, roomState?: RoomState): void; - public addEvent( - event: MatrixEvent, - toStartOfTimelineOrOpts: boolean | IAddEventOptions, - roomState?: RoomState, - ): void { - let toStartOfTimeline = !!toStartOfTimelineOrOpts; - let timelineWasEmpty: boolean | undefined; - if (typeof toStartOfTimelineOrOpts === "object") { - ({ toStartOfTimeline, roomState, timelineWasEmpty } = toStartOfTimelineOrOpts); - } else if (toStartOfTimelineOrOpts !== undefined) { - // Deprecation warning - // FIXME: Remove after 2023-06-01 (technical debt) - 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() === EventType.RoomMember && !toStartOfTimeline)) { - EventTimeline.setEventMetadata(event, roomState!, toStartOfTimeline); - } - } - } - - let insertIndex: number; - - if (toStartOfTimeline) { - insertIndex = 0; - } else { - insertIndex = this.events.length; - } - - this.events.splice(insertIndex, 0, event); // insert element - if (toStartOfTimeline) { - this.baseIndex++; - } - } - - /** - * Remove an event from the timeline - * - * @param eventId - ID of event to be removed - * @returns removed event, or null if not found - */ - public removeEvent(eventId: string): MatrixEvent | null { - 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 - */ - public toString(): string { - return this.name; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event.ts deleted file mode 100644 index 2db3479..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event.ts +++ /dev/null @@ -1,1631 +0,0 @@ -/* -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. - */ - -import { ExtensibleEvent, ExtensibleEvents, Optional } from "matrix-events-sdk"; - -import type { IEventDecryptionResult } from "../@types/crypto"; -import { logger } from "../logger"; -import { VerificationRequest } from "../crypto/verification/request/VerificationRequest"; -import { EVENT_VISIBILITY_CHANGE_TYPE, EventType, MsgType, RelationType } from "../@types/event"; -import { Crypto } from "../crypto"; -import { deepSortedObjectEntries, internaliseString } from "../utils"; -import { RoomMember } from "./room-member"; -import { Thread, ThreadEvent, EventHandlerMap as ThreadEventHandlerMap, THREAD_RELATION_TYPE } from "./thread"; -import { IActionsObject } from "../pushprocessor"; -import { TypedReEmitter } from "../ReEmitter"; -import { MatrixError } from "../http-api"; -import { TypedEventEmitter } from "./typed-event-emitter"; -import { EventStatus } from "./event-status"; -import { DecryptionError } from "../crypto/algorithms"; -import { CryptoBackend } from "../common-crypto/CryptoBackend"; -import { WITHHELD_MESSAGES } from "../crypto/OlmDevice"; - -export { EventStatus } from "./event-status"; - -/* eslint-disable camelcase */ -export interface IContent { - [key: string]: any; - "msgtype"?: MsgType | string; - "membership"?: string; - "avatar_url"?: string; - "displayname"?: string; - "m.relates_to"?: IEventRelation; - - "org.matrix.msc3952.mentions"?: IMentions; -} - -type StrippedState = Required<Pick<IEvent, "content" | "state_key" | "type" | "sender">>; - -export interface IUnsigned { - "age"?: number; - "prev_sender"?: string; - "prev_content"?: IContent; - "redacted_because"?: IEvent; - "transaction_id"?: string; - "invite_room_state"?: StrippedState[]; - "m.relations"?: Record<RelationType | string, any>; // No common pattern for aggregated relations -} - -export interface IThreadBundledRelationship { - latest_event: IEvent; - count: number; - current_user_participated?: boolean; -} - -export interface IEvent { - event_id: string; - type: string; - content: IContent; - sender: string; - room_id?: string; - origin_server_ts: number; - txn_id?: string; - state_key?: string; - membership?: string; - unsigned: IUnsigned; - redacts?: string; - - /** - * @deprecated in favour of `sender` - */ - user_id?: string; - /** - * @deprecated in favour of `unsigned.prev_content` - */ - prev_content?: IContent; - /** - * @deprecated in favour of `origin_server_ts` - */ - age?: number; -} - -export interface IAggregatedRelation { - origin_server_ts: number; - event_id?: string; - sender?: string; - type?: string; - count?: number; - key?: string; -} - -export interface IEventRelation { - "rel_type"?: RelationType | string; - "event_id"?: string; - "is_falling_back"?: boolean; - "m.in_reply_to"?: { - event_id?: string; - }; - "key"?: string; -} - -export interface IMentions { - user_ids?: string[]; - room?: boolean; -} - -/** - * When an event is a visibility change event, as per MSC3531, - * the visibility change implied by the event. - */ -export interface IVisibilityChange { - /** - * If `true`, the target event should be made visible. - * Otherwise, it should be hidden. - */ - visible: boolean; - - /** - * The event id affected. - */ - eventId: string; - - /** - * Optionally, a human-readable reason explaining why - * the event was hidden. Ignored if the event was made - * visible. - */ - reason: string | null; -} - -export interface IClearEvent { - room_id?: string; - type: string; - content: Omit<IContent, "membership" | "avatar_url" | "displayname" | "m.relates_to">; - unsigned?: IUnsigned; -} -/* eslint-enable camelcase */ - -interface IKeyRequestRecipient { - userId: string; - deviceId: "*" | string; -} - -export interface IDecryptOptions { - // Emits "event.decrypted" if set to true - emit?: boolean; - // True if this is a retry (enables more logging) - isRetry?: boolean; - // whether the message should be re-decrypted if it was previously successfully decrypted with an untrusted key - forceRedecryptIfUntrusted?: boolean; -} - -/** - * Message hiding, as specified by https://github.com/matrix-org/matrix-doc/pull/3531. - */ -export type MessageVisibility = IMessageVisibilityHidden | IMessageVisibilityVisible; -/** - * Variant of `MessageVisibility` for the case in which the message should be displayed. - */ -export interface IMessageVisibilityVisible { - readonly visible: true; -} -/** - * Variant of `MessageVisibility` for the case in which the message should be hidden. - */ -export interface IMessageVisibilityHidden { - readonly visible: false; - /** - * Optionally, a human-readable reason to show to the user indicating why the - * message has been hidden (e.g. "Message Pending Moderation"). - */ - readonly reason: string | null; -} -// A singleton implementing `IMessageVisibilityVisible`. -const MESSAGE_VISIBLE: IMessageVisibilityVisible = Object.freeze({ visible: true }); - -export enum MatrixEventEvent { - Decrypted = "Event.decrypted", - BeforeRedaction = "Event.beforeRedaction", - VisibilityChange = "Event.visibilityChange", - LocalEventIdReplaced = "Event.localEventIdReplaced", - Status = "Event.status", - Replaced = "Event.replaced", - RelationsCreated = "Event.relationsCreated", -} - -export type MatrixEventEmittedEvents = MatrixEventEvent | ThreadEvent.Update; - -export type MatrixEventHandlerMap = { - /** - * Fires when an event is decrypted - * - * @param event - The matrix event which has been decrypted - * @param err - The error that occurred during decryption, or `undefined` if no error occurred. - */ - [MatrixEventEvent.Decrypted]: (event: MatrixEvent, err?: Error) => void; - [MatrixEventEvent.BeforeRedaction]: (event: MatrixEvent, redactionEvent: MatrixEvent) => void; - [MatrixEventEvent.VisibilityChange]: (event: MatrixEvent, visible: boolean) => void; - [MatrixEventEvent.LocalEventIdReplaced]: (event: MatrixEvent) => void; - [MatrixEventEvent.Status]: (event: MatrixEvent, status: EventStatus | null) => void; - [MatrixEventEvent.Replaced]: (event: MatrixEvent) => void; - [MatrixEventEvent.RelationsCreated]: (relationType: string, eventType: string) => void; -} & Pick<ThreadEventHandlerMap, ThreadEvent.Update>; - -export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, MatrixEventHandlerMap> { - private pushActions: IActionsObject | null = null; - private _replacingEvent: MatrixEvent | null = null; - private _localRedactionEvent: MatrixEvent | null = null; - private _isCancelled = false; - private clearEvent?: IClearEvent; - - /* 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. - */ - private visibility: MessageVisibility = 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. - private _hasCachedExtEv = false; - private _cachedExtEv: Optional<ExtensibleEvent> = undefined; - - /* curve25519 key which we believe belongs to the sender of the event. See - * getSenderKey() - */ - private senderCurve25519Key: string | null = 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() - */ - private claimedEd25519Key: string | null = null; - - /* curve25519 keys of devices involved in telling us about the - * senderCurve25519Key and claimedEd25519Key. - * See getForwardingCurve25519KeyChain(). - */ - private forwardingCurve25519KeyChain: string[] = []; - - /* where the decryption key is untrusted - */ - private untrusted: boolean | null = null; - - /* if we have a process decrypting this event, a Promise which resolves - * when it is finished. Normally null. - */ - private decryptionPromise: Promise<void> | null = 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) - */ - private 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. - */ - private txnId?: string; - - /** - * A reference to the thread this event belongs to - */ - private thread?: Thread; - private threadId?: string; - - /* - * 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. - */ - private 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. - */ - public localTimestamp: number; - - /** - * 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 - */ - public sender: RoomMember | null = 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 - */ - public target: RoomMember | null = null; - /** - * The sending status of the event. - * @privateRemarks - * Should be read-only - */ - public status: EventStatus | null = null; - /** - * most recent error associated with sending the event, if any - * @privateRemarks - * Should be read-only - */ - public error: MatrixError | null = 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 - */ - public 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. - */ - public verificationRequest?: VerificationRequest; - - private readonly reEmitter: TypedReEmitter<MatrixEventEmittedEvents, MatrixEventHandlerMap>; - - /** - * 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. - */ - public constructor(public event: Partial<IEvent> = {}) { - 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. - (["state_key", "type", "sender", "room_id", "membership"] as const).forEach((prop) => { - if (typeof event[prop] !== "string") return; - event[prop] = internaliseString(event[prop]!); - }); - - (["membership", "avatar_url", "displayname"] as const).forEach((prop) => { - if (typeof event.content?.[prop] !== "string") return; - event.content[prop] = internaliseString(event.content[prop]!); - }); - - (["rel_type"] as const).forEach((prop) => { - if (typeof event.content?.["m.relates_to"]?.[prop] !== "string") return; - event.content["m.relates_to"][prop] = internaliseString(event.content["m.relates_to"][prop]!); - }); - - this.txnId = event.txn_id; - this.localTimestamp = Date.now() - (this.getAge() ?? 0); - this.reEmitter = new 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. - */ - public get unstableExtensibleEvent(): Optional<ExtensibleEvent> { - if (!this._hasCachedExtEv) { - this._cachedExtEv = ExtensibleEvents.parse(this.getEffectiveEvent()); - } - return this._cachedExtEv; - } - - private invalidateExtensibleEvent(): void { - // 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. - */ - public getEffectiveEvent(): IEvent { - const content = Object.assign({}, this.getContent()); // clone for mutation - - if (this.getWireType() === 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 }) as IEvent; - } - - /** - * Get the event_id for this event. - * @returns The event ID, e.g. <code>$143350589368169JsLZx:localhost - * </code> - */ - public getId(): string | undefined { - return this.event.event_id; - } - - /** - * Get the user_id for this event. - * @returns The user ID, e.g. `@alice:matrix.org` - */ - public getSender(): string | undefined { - 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` - */ - public getType(): EventType | string { - 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. - */ - public getWireType(): EventType | string { - 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> - */ - public getRoomId(): string | undefined { - return this.event.room_id; - } - - /** - * Get the timestamp of this event. - * @returns The event timestamp, e.g. `1433502692297` - */ - public getTs(): number { - 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)` - */ - public getDate(): Date | null { - 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 - * ``` - */ - public getDetails(): string { - 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. - */ - public getOriginalContent<T = IContent>(): T { - if (this._localRedactionEvent) { - return {} as T; - } - if (this.clearEvent) { - return (this.clearEvent.content || {}) as T; - } - return (this.event.content || {}) as T; - } - - /** - * 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. - */ - public getContent<T extends IContent = IContent>(): T { - if (this._localRedactionEvent) { - return {} as T; - } 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. - */ - public getWireContent(): IContent { - return this.event.content || {}; - } - - /** - * Get the event ID of the thread head - */ - public get threadRootId(): string | undefined { - const relatesTo = this.getWireContent()?.["m.relates_to"]; - if (relatesTo?.rel_type === 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 - */ - public get isThreadRoot(): boolean { - const threadDetails = this.getServerAggregatedRelation<IThreadBundledRelationship>(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(); - } - - public get replyEventId(): string | undefined { - return this.getWireContent()["m.relates_to"]?.["m.in_reply_to"]?.event_id; - } - - public get relationEventId(): string | undefined { - 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. - */ - public getPrevContent(): IContent { - // 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. - */ - public getDirectionalContent(): IContent { - 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. - */ - public getAge(): number | undefined { - 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. - */ - public getLocalAge(): number { - 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`. - */ - public getStateKey(): string | undefined { - return this.event.state_key; - } - - /** - * Check if this event is a state event. - * @returns True if this is a state event. - */ - public isState(): boolean { - 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} - */ - public makeEncrypted( - cryptoType: string, - cryptoContent: object, - senderCurve25519Key: string, - claimedEd25519Key: string, - ): void { - // 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. - */ - public isBeingDecrypted(): boolean { - return this.decryptionPromise != null; - } - - public getDecryptionPromise(): Promise<void> | null { - 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. - */ - public isDecryptionFailure(): boolean { - 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. - */ - public get isEncryptedDisabledForUnverifiedDevices(): boolean { - return this.isDecryptionFailure() && this.encryptedDisabledForUnverifiedDevices; - } - - public shouldAttemptDecryption(): boolean { - 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. - */ - public async attemptDecryption(crypto: CryptoBackend, options: IDecryptOptions = {}): Promise<void> { - // 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.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 - */ - public cancelAndResendKeyRequest(crypto: Crypto, userId: string): Promise<void> { - 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 - */ - public getKeyRequestRecipients(userId: string): IKeyRequestRecipient[] { - // send the request to all of our own devices - const recipients = [ - { - userId, - deviceId: "*", - }, - ]; - - return recipients; - } - - private async decryptionLoop(crypto: CryptoBackend, options: IDecryptOptions = {}): Promise<void> { - // 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: IEventDecryptionResult; - let err: Error | undefined = undefined; - try { - if (!crypto) { - res = this.badEncryptedMessage("Encryption not enabled"); - } else { - res = await crypto.decryptEvent(this); - if (options.isRetry === true) { - logger.info(`Decrypted event on retry (${this.getDetails()})`); - } - } - } catch (e) { - const detailedError = e instanceof DecryptionError ? (<DecryptionError>e).detailedString : String(e); - - err = e as Error; - - // see if we have a retry queued. - // - // NB: make sure to keep this check in the same tick of the - // event loop as `decryptionPromise = null` below - otherwise we - // risk a race: - // - // * A: we check retryDecryption here and see that it is - // false - // * B: we get a second call to attemptDecryption, which sees - // that decryptionPromise is set so sets - // retryDecryption - // * A: we continue below, clear decryptionPromise, and - // never do the retry. - // - if (this.retryDecryption) { - // decryption error, but we have a retry queued. - logger.log(`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.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.setPushActions(null); - - if (options.emit !== false) { - this.emit(MatrixEventEvent.Decrypted, this, err); - } - - return; - } - } - - private badEncryptedMessage(reason: string): IEventDecryptionResult { - return { - clearEvent: { - type: EventType.RoomMessage, - content: { - msgtype: "m.bad.encrypted", - body: "** Unable to decrypt: " + reason + " **", - }, - }, - encryptedDisabledForUnverifiedDevices: reason === `DecryptionError: ${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} - */ - private setClearData(decryptionResult: IEventDecryptionResult): void { - 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 - */ - public getClearContent(): IContent | null { - return this.clearEvent ? this.clearEvent.content : null; - } - - /** - * Check if the event is encrypted. - * @returns True if this event is encrypted. - */ - public isEncrypted(): boolean { - return !this.isState() && this.event.type === 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 - */ - public getSenderKey(): string | null { - return this.senderCurve25519Key; - } - - /** - * The additional keys the sender of this encrypted event claims to possess. - * - * Just a wrapper for #getClaimedEd25519Key (q.v.) - */ - public getKeysClaimed(): Partial<Record<"ed25519", string>> { - 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. - */ - public getClaimedEd25519Key(): string | null { - 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. - */ - public getForwardingCurve25519KeyChain(): string[] { - return this.forwardingCurve25519KeyChain; - } - - /** - * Whether the decryption key was obtained from an untrusted source. If so, - * we cannot verify the authenticity of the message. - */ - public isKeySourceUntrusted(): boolean | undefined { - return !!this.untrusted; - } - - public getUnsigned(): IUnsigned { - return this.event.unsigned || {}; - } - - public setUnsigned(unsigned: IUnsigned): void { - this.event.unsigned = unsigned; - } - - public unmarkLocallyRedacted(): boolean { - const value = this._localRedactionEvent; - this._localRedactionEvent = null; - if (this.event.unsigned) { - this.event.unsigned.redacted_because = undefined; - } - return !!value; - } - - public markLocallyRedacted(redactionEvent: MatrixEvent): void { - 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 as IEvent; - } - - /** - * 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). - */ - public applyVisibilityEvent(visibilityChange?: IVisibilityChange): void { - 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. - */ - public messageVisibility(): 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 - */ - public makeRedacted(redactionEvent: MatrixEvent): void { - // 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 as IEvent; - - for (const key in this.event) { - if (this.event.hasOwnProperty(key) && !REDACT_KEEP_KEYS.has(key)) { - delete this.event[key as keyof IEvent]; - } - } - - // 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() as keyof typeof REDACT_KEEP_CONTENT_MAP] - : {}; - 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 - */ - public isRedacted(): boolean { - return Boolean(this.getUnsigned().redacted_because); - } - - /** - * Check if this event is a redaction of another event - * - * @returns True if this event is a redaction - */ - public isRedaction(): boolean { - return this.getType() === 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`. - */ - public asVisibilityChange(): IVisibilityChange | null { - if (!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. - */ - public isVisibilityEvent(): boolean { - return 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 - */ - public getRedactionEvent(): IEvent | {} | null { - 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 - */ - public getPushActions(): IActionsObject | null { - return this.pushActions; - } - - /** - * Set the push actions for this event. - * - * @param pushActions - push actions - */ - public setPushActions(pushActions: IActionsObject | null): void { - this.pushActions = pushActions; - } - - /** - * Replace the `event` property and recalculate any properties based on it. - * @param event - the object to assign to the `event` property - */ - public handleRemoteEcho(event: object): void { - 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. - */ - public isSending(): boolean { - return !!this.status; - } - - /** - * Update the event's sending status and emit an event as well. - * - * @param status - The new status - */ - public setStatus(status: EventStatus | null): void { - this.status = status; - this.emit(MatrixEventEvent.Status, this, status); - } - - public replaceLocalEventId(eventId: string): void { - 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 - */ - public isRelation(relType?: string): boolean { - // 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 === 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. - */ - public getRelation(): IEventRelation | null { - 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} - */ - public makeReplaced(newEvent?: MatrixEvent): void { - // 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. - */ - public getAssociatedStatus(): EventStatus | null { - if (this._replacingEvent) { - return this._replacingEvent.status; - } else if (this._localRedactionEvent) { - return this._localRedactionEvent.status; - } - return this.status; - } - - public getServerAggregatedRelation<T>(relType: RelationType | string): T | undefined { - return this.getUnsigned()["m.relations"]?.[relType]; - } - - /** - * Returns the event ID of the event replacing the content of this event, if any. - */ - public replacingEventId(): string | undefined { - const replaceRelation = this.getServerAggregatedRelation<IAggregatedRelation>(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. - */ - public replacingEvent(): MatrixEvent | null { - return this._replacingEvent; - } - - /** - * Returns the origin_server_ts of the event replacing the content of this event, if any. - */ - public replacingEventDate(): Date | undefined { - const replaceRelation = this.getServerAggregatedRelation<IAggregatedRelation>(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 - */ - public localRedactionEvent(): MatrixEvent | null { - return this._localRedactionEvent; - } - - /** - * For relations and redactions, returns the event_id this event is referring to. - */ - public getAssociatedId(): string | undefined { - 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. - */ - public hasAssocation(): boolean { - return !!this.getAssociatedId(); - } - - /** - * Checks if this event is associated with another event. See `getAssociatedId`. - */ - public hasAssociation(): boolean { - 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 - */ - public updateAssociatedId(eventId: string): void { - 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. - */ - public flagCancelled(cancelled = true): void { - 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. - */ - public isCancelled(): boolean { - 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. - */ - public toSnapshot(): MatrixEvent { - 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 as keyof MatrixEvent] = 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. - */ - public isEquivalentTo(otherEvent: MatrixEvent): boolean { - if (!otherEvent) return false; - if (otherEvent === this) return true; - const myProps = deepSortedObjectEntries(this.event); - const theirProps = 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. - */ - public toJSON(): object { - const event = this.getEffectiveEvent(); - - if (!this.isEncrypted()) { - return event; - } - - return { - decrypted: event, - encrypted: this.event, - }; - } - - public setVerificationRequest(request: VerificationRequest): void { - this.verificationRequest = request; - } - - public setTxnId(txnId: string): void { - this.txnId = txnId; - } - - public getTxnId(): string | undefined { - return this.txnId; - } - - /** - * Set the instance of a thread associated with the current event - * @param thread - the thread - */ - public setThread(thread?: Thread): void { - if (this.thread) { - this.reEmitter.stopReEmitting(this.thread, [ThreadEvent.Update]); - } - this.thread = thread; - this.setThreadId(thread?.id); - if (thread) { - this.reEmitter.reEmit(thread, [ThreadEvent.Update]); - } - } - - /** - * Get the instance of the thread associated with the current event - */ - public getThread(): Thread | undefined { - return this.thread; - } - - public setThreadId(threadId?: string): void { - 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 - */ -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: Record<string, Record<string, 1>> = { - [EventType.RoomMember]: { membership: 1 }, - [EventType.RoomCreate]: { creator: 1 }, - [EventType.RoomJoinRules]: { join_rule: 1 }, - [EventType.RoomPowerLevels]: { - ban: 1, - events: 1, - events_default: 1, - kick: 1, - redact: 1, - state_default: 1, - users: 1, - users_default: 1, - }, -} as const; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/invites-ignorer.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/invites-ignorer.ts deleted file mode 100644 index 173ba62..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/invites-ignorer.ts +++ /dev/null @@ -1,368 +0,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. -*/ - -import { UnstableValue } from "matrix-events-sdk"; - -import { MatrixClient } from "../client"; -import { IContent, MatrixEvent } from "./event"; -import { EventTimeline } from "./event-timeline"; -import { Preset } from "../@types/partials"; -import { globToRegexp } from "../utils"; -import { Room } from "./room"; - -/// The event type storing the user's individual policies. -/// -/// Exported for testing purposes. -export const POLICIES_ACCOUNT_EVENT_TYPE = new 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. -export const IGNORE_INVITES_ACCOUNT_EVENT_KEY = new UnstableValue( - "m.ignore.invites", - "org.matrix.msc3847.ignore.invites", -); - -/// The types of recommendations understood. -enum PolicyRecommendation { - Ban = "m.ban", -} - -/** - * The various scopes for policies. - */ -export enum PolicyScope { - /** - * The policy deals with an individual user, e.g. reject invites - * from this user. - */ - User = "m.policy.user", - - /** - * The policy deals with a room, e.g. reject invites towards - * a specific room. - */ - Room = "m.policy.room", - - /** - * The policy deals with a server, e.g. reject invites from - * this server. - */ - Server = "m.policy.server", -} - -/** - * 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. - */ -export class IgnoredInvites { - public constructor(private readonly client: MatrixClient) {} - - /** - * 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. - */ - public async addRule(scope: PolicyScope, entity: string, reason: string): Promise<string> { - 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. - */ - public async removeRule(event: MatrixEvent): Promise<void> { - 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. - */ - public async addSource(roomId: string): Promise<boolean> { - // 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. - */ - public async getRuleForInvite({ - sender, - roomId, - }: { - sender: string; - roomId: string; - }): Promise<Readonly<MatrixEvent | null>> { - // 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.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: RegExp; - try { - regexp = new RegExp(globToRegexp(glob, false)); - } 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. - */ - public async getOrCreateTargetRoom(): Promise<Room> { - 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: 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. - */ - public async getOrCreateSourceRooms(): Promise<Room[]> { - const ignoreInvitesPolicies = this.getIgnoreInvitesPolicies(); - let sources: string[] = 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) as 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. - */ - private getIgnoreInvitesPolicies(): { [key: string]: any } { - return this.getPoliciesAndIgnoreInvitesPolicies().ignoreInvitesPolicies; - } - - /** - * Modify in place the `IGNORE_INVITES_POLICIES` object from account data. - */ - private async withIgnoreInvitesPolicies( - cb: (ignoreInvitesPolicies: { [key: string]: any }) => void, - ): Promise<void> { - 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. - */ - private getPoliciesAndIgnoreInvitesPolicies(): { - policies: { [key: string]: any }; - ignoreInvitesPolicies: { [key: string]: any }; - } { - let policies: IContent = {}; - 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 }; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/poll.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/poll.ts deleted file mode 100644 index 1d4344a..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/poll.ts +++ /dev/null @@ -1,268 +0,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. -*/ - -import { M_POLL_END, M_POLL_RESPONSE } from "../@types/polls"; -import { MatrixClient } from "../client"; -import { PollStartEvent } from "../extensible_events_v1/PollStartEvent"; -import { MatrixEvent } from "./event"; -import { Relations } from "./relations"; -import { Room } from "./room"; -import { TypedEventEmitter } from "./typed-event-emitter"; - -export enum PollEvent { - New = "Poll.new", - End = "Poll.end", - Update = "Poll.update", - Responses = "Poll.Responses", - Destroy = "Poll.Destroy", - UndecryptableRelations = "Poll.UndecryptableRelations", -} - -export type PollEventHandlerMap = { - [PollEvent.Update]: (event: MatrixEvent, poll: Poll) => void; - [PollEvent.Destroy]: (pollIdentifier: string) => void; - [PollEvent.End]: () => void; - [PollEvent.Responses]: (responses: Relations) => void; - [PollEvent.UndecryptableRelations]: (count: number) => void; -}; - -const filterResponseRelations = ( - relationEvents: MatrixEvent[], - pollEndTimestamp: number, -): { - responseEvents: MatrixEvent[]; -} => { - const responseEvents = relationEvents.filter((event) => { - if (event.isDecryptionFailure()) { - return; - } - return ( - 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 }; -}; - -export class Poll extends TypedEventEmitter<Exclude<PollEvent, PollEvent.New>, PollEventHandlerMap> { - public readonly roomId: string; - public readonly pollEvent: PollStartEvent; - private _isFetchingResponses = false; - private relationsNextBatch: string | undefined; - private responses: null | Relations = null; - private endEvent: MatrixEvent | undefined; - /** - * Keep track of undecryptable relations - * As incomplete result sets affect poll results - */ - private undecryptableRelationEventIds = new Set<string>(); - - public constructor(public readonly rootEvent: MatrixEvent, private matrixClient: MatrixClient, private room: Room) { - super(); - if (!this.rootEvent.getRoomId() || !this.rootEvent.getId()) { - throw new Error("Invalid poll start event."); - } - this.roomId = this.rootEvent.getRoomId()!; - this.pollEvent = this.rootEvent.unstableExtensibleEvent as unknown as PollStartEvent; - } - - public get pollId(): string { - return this.rootEvent.getId()!; - } - - public get endEventId(): string | undefined { - return this.endEvent?.getId(); - } - - public get isEnded(): boolean { - return !!this.endEvent; - } - - public get isFetchingResponses(): boolean { - return this._isFetchingResponses; - } - - public get undecryptableRelationsCount(): number { - return this.undecryptableRelationEventIds.size; - } - - public async getResponses(): Promise<Relations> { - // 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 - */ - public onNewRelation(event: MatrixEvent): void { - if (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); - } - } - - private async fetchResponses(): Promise<void> { - 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("m.reference", M_POLL_RESPONSE.name, this.matrixClient, [M_POLL_RESPONSE.altName!]); - - const pollEndEvent = allRelations.events.find((event) => 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 - */ - private refilterResponsesOnEnd(): void { - 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); - } - - private countUndecryptableEvents = (events: MatrixEvent[]): void => { - 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); - } - }; - - private validateEndEvent(endEvent?: MatrixEvent): boolean { - 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)) - ); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/read-receipt.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/read-receipt.ts deleted file mode 100644 index 5858fe5..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/read-receipt.ts +++ /dev/null @@ -1,312 +0,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. -*/ - -import { - CachedReceipt, - MAIN_ROOM_TIMELINE, - Receipt, - ReceiptCache, - ReceiptType, - WrappedReceipt, -} from "../@types/read_receipts"; -import { ListenerMap, TypedEventEmitter } from "./typed-event-emitter"; -import * as utils from "../utils"; -import { MatrixEvent } from "./event"; -import { EventType } from "../@types/event"; -import { EventTimelineSet } from "./event-timeline-set"; -import { MapWithDefault } from "../utils"; -import { NotificationCountType } from "./room"; - -export function synthesizeReceipt(userId: string, event: MatrixEvent, receiptType: ReceiptType): MatrixEvent { - return new MatrixEvent({ - content: { - [event.getId()!]: { - [receiptType]: { - [userId]: { - ts: event.getTs(), - thread_id: event.threadRootId ?? MAIN_ROOM_TIMELINE, - }, - }, - }, - }, - type: EventType.Receipt, - room_id: event.getRoomId(), - }); -} - -const ReceiptPairRealIndex = 0; -const ReceiptPairSyntheticIndex = 1; - -export abstract class ReadReceipt< - Events extends string, - Arguments extends ListenerMap<Events>, - SuperclassArguments extends ListenerMap<any> = Arguments, -> extends TypedEventEmitter<Events, Arguments, SuperclassArguments> { - // 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 - private receipts = new MapWithDefault<string, Map<string, [WrappedReceipt | null, WrappedReceipt | null]>>( - () => new Map(), - ); - private receiptCacheByEventId: ReceiptCache = new Map(); - - public abstract getUnfilteredTimelineSet(): EventTimelineSet; - public abstract timeline: MatrixEvent[]; - - /** - * 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 - */ - public getReadReceiptForUserId( - userId: string, - ignoreSynthesized = false, - receiptType = ReceiptType.Read, - ): WrappedReceipt | null { - 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. - */ - public getEventReadUpTo(userId: string, ignoreSynthesized = false): string | null { - // 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, ReceiptType.Read); - const privateReadReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized, ReceiptType.ReadPrivate); - - // If we have both, compare them - let comparison: number | null | undefined; - 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; - } - - public addReceiptToStructure( - eventId: string, - receiptType: ReceiptType, - userId: string, - receipt: Receipt, - synthetic: boolean, - ): void { - 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: WrappedReceipt = { - eventId, - data: receipt, - }; - - const realReceipt = synthetic ? pair[ReceiptPairRealIndex] : wrappedReceipt; - const syntheticReceipt = synthetic ? wrappedReceipt : pair[ReceiptPairSyntheticIndex]; - - let ordering: number | null = 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 as 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. - */ - public getReceiptsForEvent(event: MatrixEvent): CachedReceipt[] { - return this.receiptCacheByEventId.get(event.getId()!) || []; - } - - public abstract addReceipt(event: MatrixEvent, synthetic: boolean): void; - - public abstract setUnread(type: NotificationCountType, count: number): void; - - /** - * 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 - */ - public fixupNotifications(userId: string): void { - 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(NotificationCountType.Total, 0); - this.setUnread(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 - */ - public addLocalEchoReceipt(userId: string, e: MatrixEvent, receiptType: ReceiptType): void { - 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. - */ - public getUsersReadUpTo(event: MatrixEvent): string[] { - return this.getReceiptsForEvent(event) - .filter(function (receipt) { - return 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. - */ - public hasUserReadEvent(userId: string, eventId: string): boolean { - 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; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/related-relations.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/related-relations.ts deleted file mode 100644 index a005169..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/related-relations.ts +++ /dev/null @@ -1,39 +0,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. -*/ - -import { Relations, RelationsEvent, EventHandlerMap } from "./relations"; -import { MatrixEvent } from "./event"; -import { Listener } from "./typed-event-emitter"; - -export class RelatedRelations { - private relations: Relations[]; - - public constructor(relations: Relations[]) { - this.relations = relations.filter((r) => !!r); - } - - public getRelations(): MatrixEvent[] { - return this.relations.reduce<MatrixEvent[]>((c, p) => [...c, ...p.getRelations()], []); - } - - public on<T extends RelationsEvent>(ev: T, fn: Listener<RelationsEvent, EventHandlerMap, T>): void { - this.relations.forEach((r) => r.on(ev, fn)); - } - - public off<T extends RelationsEvent>(ev: T, fn: Listener<RelationsEvent, EventHandlerMap, T>): void { - this.relations.forEach((r) => r.off(ev, fn)); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/relations-container.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/relations-container.ts deleted file mode 100644 index d328b1c..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/relations-container.ts +++ /dev/null @@ -1,146 +0,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. -*/ - -import { Relations } from "./relations"; -import { EventType, RelationType } from "../@types/event"; -import { EventStatus, MatrixEvent, MatrixEventEvent } from "./event"; -import { EventTimelineSet } from "./event-timeline-set"; -import { MatrixClient } from "../client"; -import { Room } from "./room"; - -export class RelationsContainer { - // A tree of objects to access a set of related children for an event, as in: - // this.relations.get(parentEventId).get(relationType).get(relationEventType) - private relations = new Map<string, Map<RelationType | string, Map<EventType | string, Relations>>>(); - - public constructor(private readonly client: MatrixClient, private readonly room?: Room) {} - - /** - * 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. - */ - public getChildEventsForEvent( - eventId: string, - relationType: RelationType | string, - eventType: EventType | string, - ): Relations | undefined { - return this.relations.get(eventId)?.get(relationType)?.get(eventType); - } - - public getAllChildEventsForEvent(parentEventId: string): MatrixEvent[] { - const relationsForEvent = - this.relations.get(parentEventId) ?? new Map<RelationType | string, Map<EventType | string, Relations>>(); - const events: MatrixEvent[] = []; - 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. - */ - public aggregateParentEvent(event: MatrixEvent): void { - 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. - */ - public aggregateChildEvent(event: MatrixEvent, timelineSet?: EventTimelineSet): void { - if (event.isRedacted() || event.status === EventStatus.CANCELLED) { - return; - } - - const relation = event.getRelation(); - if (!relation) return; - - const onEventDecrypted = (): void => { - 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(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(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<RelationType | string, Map<EventType | string, Relations>>(); - this.relations.set(relatesToEventId!, relationsForEvent); - } - - let relationsWithRelType = relationsForEvent.get(relationType!); - if (!relationsWithRelType) { - relationsWithRelType = new Map<EventType | string, Relations>(); - relationsForEvent.set(relationType!, relationsWithRelType); - } - - let relationsWithEventType = relationsWithRelType.get(eventType); - if (!relationsWithEventType) { - relationsWithEventType = new 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); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/relations.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/relations.ts deleted file mode 100644 index d2b637c..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/relations.ts +++ /dev/null @@ -1,368 +0,0 @@ -/* -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. -*/ - -import { EventStatus, IAggregatedRelation, MatrixEvent, MatrixEventEvent } from "./event"; -import { logger } from "../logger"; -import { RelationType } from "../@types/event"; -import { TypedEventEmitter } from "./typed-event-emitter"; -import { MatrixClient } from "../client"; -import { Room } from "./room"; - -export enum RelationsEvent { - Add = "Relations.add", - Remove = "Relations.remove", - Redaction = "Relations.redaction", -} - -export type EventHandlerMap = { - [RelationsEvent.Add]: (event: MatrixEvent) => void; - [RelationsEvent.Remove]: (event: MatrixEvent) => void; - [RelationsEvent.Redaction]: (event: MatrixEvent) => void; -}; - -const matchesEventType = (eventType: string, targetEventType: string, altTargetEventTypes: string[] = []): boolean => - [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. - */ -export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap> { - private relationEventIds = new Set<string>(); - private relations = new Set<MatrixEvent>(); - private annotationsByKey: Record<string, Set<MatrixEvent>> = {}; - private annotationsBySender: Record<string, Set<MatrixEvent>> = {}; - private sortedAnnotationsByKey: [string, Set<MatrixEvent>][] = []; - private targetEvent: MatrixEvent | null = null; - private creationEmitted = false; - private readonly client: MatrixClient; - - /** - * @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 - */ - public constructor( - public readonly relationType: RelationType | string, - public readonly eventType: string, - client: MatrixClient | Room, - public readonly altEventTypes?: string[], - ) { - super(); - this.client = client instanceof Room ? client.client : client; - } - - /** - * Add relation events to this collection. - * - * @param event - The new relation event to be added. - */ - public async addEvent(event: MatrixEvent): Promise<void> { - if (this.relationEventIds.has(event.getId()!)) { - return; - } - - const relation = event.getRelation(); - if (!relation) { - logger.error("Event must have relation info"); - return; - } - - const relationType = relation.rel_type; - const eventType = event.getType(); - - if (this.relationType !== relationType || !matchesEventType(eventType, this.eventType, this.altEventTypes)) { - 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(MatrixEventEvent.Status, this.onEventStatus); - } - - this.relations.add(event); - this.relationEventIds.add(event.getId()!); - - if (this.relationType === RelationType.Annotation) { - this.addAnnotationToAggregation(event); - } else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) { - const lastReplacement = await this.getLastReplacement(); - this.targetEvent.makeReplaced(lastReplacement!); - } - - event.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); - - this.emit(RelationsEvent.Add, event); - - this.maybeEmitCreated(); - } - - /** - * Remove relation event from this collection. - * - * @param event - The relation event to remove. - */ - public async removeEvent(event: MatrixEvent): Promise<void> { - if (!this.relations.has(event)) { - return; - } - - this.relations.delete(event); - - if (this.relationType === RelationType.Annotation) { - this.removeAnnotationFromAggregation(event); - } else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) { - const lastReplacement = await this.getLastReplacement(); - this.targetEvent.makeReplaced(lastReplacement!); - } - - this.emit(RelationsEvent.Remove, event); - } - - /** - * Listens for event status changes to remove cancelled events. - * - * @param event - The event whose status has changed - * @param status - The new status - */ - private onEventStatus = (event: MatrixEvent, status: EventStatus | null): void => { - if (!event.isSending()) { - // Sending is done, so we don't need to listen anymore - event.removeListener(MatrixEventEvent.Status, this.onEventStatus); - return; - } - if (status !== EventStatus.CANCELLED) { - return; - } - // Event was cancelled, remove from the collection - event.removeListener(MatrixEventEvent.Status, this.onEventStatus); - this.removeEvent(event); - }; - - /** - * Get all relation events in this collection. - * - * These are currently in the order of insertion to this collection, which - * won't match timeline order in the case of scrollback. - * TODO: Tweak `addEvent` to insert correctly for scrollback. - * - * Relation events in insertion order. - */ - public getRelations(): MatrixEvent[] { - return [...this.relations]; - } - - private addAnnotationToAggregation(event: MatrixEvent): void { - 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); - } - - private removeAnnotationFromAggregation(event: MatrixEvent): void { - const { key } = event.getRelation() ?? {}; - if (!key) return; - - const eventsForKey = this.annotationsByKey[key]; - if (eventsForKey) { - eventsForKey.delete(event); - - // Re-sort the [key, events] pairs in descending order of event count - this.sortedAnnotationsByKey.sort((a, b) => { - const aEvents = a[1]; - const bEvents = b[1]; - return bEvents.size - aEvents.size; - }); - } - - const sender = event.getSender()!; - const eventsFromSender = this.annotationsBySender[sender]; - if (eventsFromSender) { - eventsFromSender.delete(event); - } - } - - /** - * For relations that have been redacted, we want to remove them from - * aggregation data sets and emit an update event. - * - * To do so, we listen for `Event.beforeRedaction`, which happens: - * - after the server accepted the redaction and remote echoed back to us - * - before the original event has been marked redacted in the client - * - * @param redactedEvent - The original relation event that is about to be redacted. - */ - private onBeforeRedaction = async (redactedEvent: MatrixEvent): Promise<void> => { - if (!this.relations.has(redactedEvent)) { - return; - } - - this.relations.delete(redactedEvent); - - if (this.relationType === RelationType.Annotation) { - // Remove the redacted annotation from aggregation by key - this.removeAnnotationFromAggregation(redactedEvent); - } else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) { - const lastReplacement = await this.getLastReplacement(); - this.targetEvent.makeReplaced(lastReplacement!); - } - - redactedEvent.removeListener(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); - - this.emit(RelationsEvent.Redaction, redactedEvent); - }; - - /** - * Get all events in this collection grouped by key and sorted by descending - * event count in each group. - * - * This is currently only supported for the annotation relation type. - * - * An array of [key, events] pairs sorted by descending event count. - * The events are stored in a Set (which preserves insertion order). - */ - public getSortedAnnotationsByKey(): [string, Set<MatrixEvent>][] | null { - if (this.relationType !== 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. - */ - public getAnnotationsBySender(): Record<string, Set<MatrixEvent>> | null { - if (this.relationType !== 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`. - */ - public async getLastReplacement(): Promise<MatrixEvent | null> { - if (this.relationType !== 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<IAggregatedRelation>(RelationType.Replace); - const minTs = replaceRelation?.origin_server_ts; - - const lastReplacement = this.getRelations().reduce<MatrixEvent | null>((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. - */ - public async setTargetEvent(event: MatrixEvent): Promise<void> { - if (this.targetEvent) { - return; - } - this.targetEvent = event; - - if (this.relationType === 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(); - } - - private maybeEmitCreated(): void { - 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(MatrixEventEvent.RelationsCreated, this.relationType, this.eventType); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-member.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-member.ts deleted file mode 100644 index 116a93b..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-member.ts +++ /dev/null @@ -1,453 +0,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. -*/ - -import { getHttpUriForMxc } from "../content-repo"; -import * as utils from "../utils"; -import { User } from "./user"; -import { MatrixEvent } from "./event"; -import { RoomState } from "./room-state"; -import { logger } from "../logger"; -import { TypedEventEmitter } from "./typed-event-emitter"; -import { EventType } from "../@types/event"; - -export enum RoomMemberEvent { - Membership = "RoomMember.membership", - Name = "RoomMember.name", - PowerLevel = "RoomMember.powerLevel", - Typing = "RoomMember.typing", -} - -export type RoomMemberEventHandlerMap = { - /** - * Fires whenever any room member's membership state changes. - * @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. - * @example - * ``` - * matrixClient.on("RoomMember.membership", function(event, member, oldMembership){ - * var newState = member.membership; - * }); - * ``` - */ - [RoomMemberEvent.Membership]: (event: MatrixEvent, member: RoomMember, oldMembership?: string) => void; - /** - * Fires whenever any room member's name changes. - * @param event - The matrix event which caused this event to fire. - * @param member - The member whose RoomMember.name changed. - * @param oldName - The previous name. Null if the member didn't have a name previously. - * @example - * ``` - * matrixClient.on("RoomMember.name", function(event, member){ - * var newName = member.name; - * }); - * ``` - */ - [RoomMemberEvent.Name]: (event: MatrixEvent, member: RoomMember, oldName: string | null) => void; - /** - * Fires whenever any room member's power level changes. - * @param event - The matrix event which caused this event to fire. - * @param member - The member whose RoomMember.powerLevel changed. - * @example - * ``` - * matrixClient.on("RoomMember.powerLevel", function(event, member){ - * var newPowerLevel = member.powerLevel; - * var newNormPowerLevel = member.powerLevelNorm; - * }); - * ``` - */ - [RoomMemberEvent.PowerLevel]: (event: MatrixEvent, member: RoomMember) => void; - /** - * Fires whenever any room member's typing state changes. - * @param event - The matrix event which caused this event to fire. - * @param member - The member whose RoomMember.typing changed. - * @example - * ``` - * matrixClient.on("RoomMember.typing", function(event, member){ - * var isTyping = member.typing; - * }); - * ``` - */ - [RoomMemberEvent.Typing]: (event: MatrixEvent, member: RoomMember) => void; -}; - -export class RoomMember extends TypedEventEmitter<RoomMemberEvent, RoomMemberEventHandlerMap> { - private _isOutOfBand = false; - private modified = -1; - public requestedProfileInfo = false; // used by sync.ts - - // XXX these should be read-only - /** - * True if the room member is currently typing. - */ - public 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. - */ - public name: string; - /** - * The ambiguous displayname of this room member. - */ - public rawDisplayName: string; - /** - * The power level for this room member. - */ - public powerLevel = 0; - /** - * The normalised power level (0-100) for this room member. - */ - public powerLevelNorm = 0; - /** - * The User object for this room member, if one exists. - */ - public user?: User; - /** - * The membership state for this room member e.g. 'join'. - */ - public membership?: string; - /** - * True if the member's name is disambiguated. - */ - public disambiguate = false; - /** - * The events describing this RoomMember. - */ - public events: { - /** - * The m.room.member event for this RoomMember. - */ - member?: MatrixEvent; - } = {}; - - /** - * Construct a new room member. - * - * @param roomId - The room ID of the member. - * @param userId - The user ID of the member. - */ - public constructor(public readonly roomId: string, public readonly userId: string) { - super(); - - this.name = userId; - this.rawDisplayName = userId; - this.updateModifiedTime(); - } - - /** - * Mark the member as coming from a channel that is not sync - */ - public markOutOfBand(): void { - 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. - */ - public isOutOfBand(): boolean { - 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} - */ - public setMembershipEvent(event: MatrixEvent, roomState?: RoomState): void { - const displayName = event.getDirectionalContent().displayname ?? ""; - - if (event.getType() !== 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.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 = utils.removeDirectionOverrideChars(event.getDirectionalContent().displayname ?? ""); - if (!this.rawDisplayName || !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} - */ - public setPowerLevelEvent(powerLevelEvent: MatrixEvent): void { - if (powerLevelEvent.getType() !== EventType.RoomPowerLevels || powerLevelEvent.getStateKey() !== "") { - return; - } - - const evContent = powerLevelEvent.getDirectionalContent(); - - let maxLevel = evContent.users_default || 0; - const users: { [userId: string]: number } = evContent.users || {}; - Object.values(users).forEach((lvl: number) => { - 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} - */ - public setTypingEvent(event: MatrixEvent): void { - 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. - */ - private updateModifiedTime(): void { - 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 - */ - public getLastModifiedTime(): number { - return this.modified; - } - - public isKicked(): boolean { - 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 - */ - public getDMInviter(): string | undefined { - // 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: string | undefined = 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. - */ - public getAvatarUrl( - baseUrl: string, - width: number, - height: number, - resizeMethod: string, - allowDefault = true, - allowDirectLinks: boolean, - ): string | null { - const rawUrl = this.getMxcAvatarUrl(); - - if (!rawUrl && !allowDefault) { - return null; - } - const httpUrl = getHttpUriForMxc(baseUrl, rawUrl, width, height, resizeMethod, allowDirectLinks); - if (httpUrl) { - return httpUrl; - } - return null; - } - - /** - * get the mxc avatar url, either from a state event, or from a lazily loaded member - * @returns the mxc avatar url - */ - public getMxcAvatarUrl(): string | undefined { - if (this.events.member) { - return this.events.member.getDirectionalContent().avatar_url; - } else if (this.user) { - return this.user.avatarUrl; - } - } -} - -const MXID_PATTERN = /@.+:.+/; -const LTR_RTL_PATTERN = /[\u200E\u200F\u202A-\u202F]/; - -function shouldDisambiguate(selfUserId: string, displayName?: string, roomState?: RoomState): boolean { - 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 (!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: string, displayName: string | undefined, disambiguate: boolean): string { - if (!displayName || displayName === selfUserId) return selfUserId; - - if (disambiguate) return 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 (!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 utils.removeDirectionOverrideChars(displayName); -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-state.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-state.ts deleted file mode 100644 index f975b9c..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-state.ts +++ /dev/null @@ -1,1081 +0,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. -*/ - -import { RoomMember } from "./room-member"; -import { logger } from "../logger"; -import * as utils from "../utils"; -import { EventType, UNSTABLE_MSC2716_MARKER } from "../@types/event"; -import { IEvent, MatrixEvent, MatrixEventEvent } from "./event"; -import { MatrixClient } from "../client"; -import { GuestAccess, HistoryVisibility, IJoinRuleEventContent, JoinRule } from "../@types/partials"; -import { TypedEventEmitter } from "./typed-event-emitter"; -import { Beacon, BeaconEvent, BeaconEventHandlerMap, getBeaconInfoIdentifier, BeaconIdentifier } from "./beacon"; -import { TypedReEmitter } from "../ReEmitter"; -import { M_BEACON, M_BEACON_INFO } from "../@types/beacon"; - -export interface IMarkerFoundOptions { - /** Whether the timeline was empty before the marker event arrived in the - * room. This could be happen in a variety of cases: - * 1. From the initial sync - * 2. It's the first state we're seeing after joining the room - * 3. Or whether it's coming from `syncFromCache` - * - * A marker event refers to `UNSTABLE_MSC2716_MARKER` and indicates that - * history was imported somewhere back in time. It specifically points to an - * MSC2716 insertion event where the history was imported at. Marker events - * are sent as state events so they are easily discoverable by clients and - * homeservers and don't get lost in timeline gaps. - */ - timelineWasEmpty?: boolean; -} - -// possible statuses for out-of-band member loading -enum OobStatus { - NotStarted, - InProgress, - Finished, -} - -export interface IPowerLevelsContent { - users?: Record<string, number>; - events?: Record<string, number>; - // eslint-disable-next-line camelcase - users_default?: number; - // eslint-disable-next-line camelcase - events_default?: number; - // eslint-disable-next-line camelcase - state_default?: number; - ban?: number; - kick?: number; - redact?: number; -} - -export enum RoomStateEvent { - Events = "RoomState.events", - Members = "RoomState.members", - NewMember = "RoomState.newMember", - Update = "RoomState.update", // signals batches of updates without specificity - BeaconLiveness = "RoomState.BeaconLiveness", - Marker = "RoomState.Marker", -} - -export type RoomStateEventHandlerMap = { - /** - * Fires whenever the event dictionary in room state is updated. - * @param event - The matrix event which caused this event to fire. - * @param state - The room state whose RoomState.events dictionary - * was updated. - * @param prevEvent - The event being replaced by the new state, if - * known. Note that this can differ from `getPrevContent()` on the new state event - * as this is the store's view of the last state, not the previous state provided - * by the server. - * @example - * ``` - * matrixClient.on("RoomState.events", function(event, state, prevEvent){ - * var newStateEvent = event; - * }); - * ``` - */ - [RoomStateEvent.Events]: (event: MatrixEvent, state: RoomState, lastStateEvent: MatrixEvent | null) => void; - /** - * Fires whenever a member in the members dictionary is updated in any way. - * @param event - The matrix event which caused this event to fire. - * @param state - The room state whose RoomState.members dictionary - * was updated. - * @param member - The room member that was updated. - * @example - * ``` - * matrixClient.on("RoomState.members", function(event, state, member){ - * var newMembershipState = member.membership; - * }); - * ``` - */ - [RoomStateEvent.Members]: (event: MatrixEvent, state: RoomState, member: RoomMember) => void; - /** - * Fires whenever a member is added to the members dictionary. The RoomMember - * will not be fully populated yet (e.g. no membership state) but will already - * be available in the members dictionary. - * @param event - The matrix event which caused this event to fire. - * @param state - The room state whose RoomState.members dictionary - * was updated with a new entry. - * @param member - The room member that was added. - * @example - * ``` - * matrixClient.on("RoomState.newMember", function(event, state, member){ - * // add event listeners on 'member' - * }); - * ``` - */ - [RoomStateEvent.NewMember]: (event: MatrixEvent, state: RoomState, member: RoomMember) => void; - [RoomStateEvent.Update]: (state: RoomState) => void; - [RoomStateEvent.BeaconLiveness]: (state: RoomState, hasLiveBeacons: boolean) => void; - [RoomStateEvent.Marker]: (event: MatrixEvent, setStateOptions?: IMarkerFoundOptions) => void; - [BeaconEvent.New]: (event: MatrixEvent, beacon: Beacon) => void; -}; - -type EmittedEvents = RoomStateEvent | BeaconEvent; -type EventHandlerMap = RoomStateEventHandlerMap & BeaconEventHandlerMap; - -export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap> { - public readonly reEmitter = new TypedReEmitter<EmittedEvents, EventHandlerMap>(this); - private sentinels: Record<string, RoomMember> = {}; // userId: RoomMember - // stores fuzzy matches to a list of userIDs (applies utils.removeHiddenChars to keys) - private displayNameToUserIds = new Map<string, string[]>(); - private userIdsToDisplayNames: Record<string, string> = {}; - private tokenToInvite: Record<string, MatrixEvent> = {}; // 3pid invite state_key to m.room.member invite - private joinedMemberCount: number | null = 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 - private summaryJoinedMemberCount: number | null = null; - // same for invited member count - private invitedMemberCount: number | null = null; - private summaryInvitedMemberCount: number | null = null; - private modified = -1; - - // XXX: Should be read-only - // The room member dictionary, keyed on the user's ID. - public members: Record<string, RoomMember> = {}; // userId: RoomMember - // The state events dictionary, keyed on the event type and then the state_key value. - public events = new Map<string, Map<string, MatrixEvent>>(); // Map<eventType, Map<stateKey, MatrixEvent>> - // The pagination token for this state. - public paginationToken: string | null = null; - - public readonly beacons = new Map<BeaconIdentifier, Beacon>(); - private _liveBeaconIds: BeaconIdentifier[] = []; - - /** - * 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. - */ - public constructor(public readonly roomId: string, private oobMemberFlags = { status: OobStatus.NotStarted }) { - super(); - 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' - */ - public getJoinedMemberCount(): number { - 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 - */ - public setJoinedMemberCount(count: number): void { - this.summaryJoinedMemberCount = count; - } - - /** - * Returns the number of invited members in this room - * @returns The number of members in this room whose membership is 'invite' - */ - public getInvitedMemberCount(): number { - 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 - */ - public setInvitedMemberCount(count: number): void { - this.summaryInvitedMemberCount = count; - } - - /** - * Get all RoomMembers in this room. - * @returns A list of RoomMembers. - */ - public getMembers(): RoomMember[] { - 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. - */ - public getMembersExcept(excludedIds: string[]): RoomMember[] { - 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. - */ - public getMember(userId: string): RoomMember | null { - 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. - */ - public getSentinelMember(userId: string): RoomMember | null { - if (!userId) return null; - let sentinel = this.sentinels[userId]; - - if (sentinel === undefined) { - sentinel = new RoomMember(this.roomId, userId); - const member = this.members[userId]; - if (member?.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). - */ - public getStateEvents(eventType: EventType | string): MatrixEvent[]; - public getStateEvents(eventType: EventType | string, stateKey: string): MatrixEvent | null; - public getStateEvents(eventType: EventType | string, stateKey?: string): MatrixEvent[] | MatrixEvent | null { - 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; - } - - public get hasLiveBeacons(): boolean { - return !!this.liveBeaconIds?.length; - } - - public get liveBeaconIds(): BeaconIdentifier[] { - 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 - */ - public clone(): RoomState { - 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 - */ - public setUnknownStateEvents(events: MatrixEvent[]): void { - 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} - */ - public setStateEvents(stateEvents: MatrixEvent[], markerFoundOptions?: IMarkerFoundOptions): void { - this.updateModifiedTime(); - - // update the core event dict - stateEvents.forEach((event) => { - if (event.getRoomId() !== this.roomId || !event.isState()) return; - - if (M_BEACON_INFO.matches(event.getType())) { - this.setBeacon(event); - } - - const lastStateEvent = this.getStateEventMatching(event); - this.setStateEvent(event); - if (event.getType() === 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() === 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() === 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 (UNSTABLE_MSC2716_MARKER.matches(event.getType())) { - this.emit(RoomStateEvent.Marker, event, markerFoundOptions); - } - }); - - this.emit(RoomStateEvent.Update, this); - } - - public async processBeaconEvents(events: MatrixEvent[], matrixClient: MatrixClient): Promise<void> { - if ( - !events.length || - // discard locations if we have no beacons - !this.beacons.size - ) { - return; - } - - const beaconByEventIdDict = [...this.beacons.values()].reduce<Record<string, Beacon>>((dict, beacon) => { - dict[beacon.beaconInfoId] = beacon; - return dict; - }, {}); - - const processBeaconRelation = (beaconInfoEventId: string, event: MatrixEvent): void => { - if (!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 (!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(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} - */ - private getOrCreateMember(userId: string, event: MatrixEvent): RoomMember { - let member = this.members[userId]; - if (!member) { - member = new RoomMember(this.roomId, userId); - // add member to members before emitting any events, - // as event handlers often lookup the member - this.members[userId] = member; - this.emit(RoomStateEvent.NewMember, event, this, member); - } - return member; - } - - private setStateEvent(event: MatrixEvent): void { - if (!this.events.has(event.getType())) { - this.events.set(event.getType(), new Map()); - } - this.events.get(event.getType())!.set(event.getStateKey()!, event); - } - - /** - * @experimental - */ - private setBeacon(event: MatrixEvent): void { - const beaconIdentifier = getBeaconInfoIdentifier(event); - - if (this.beacons.has(beaconIdentifier)) { - const beacon = this.beacons.get(beaconIdentifier)!; - - if (event.isRedacted()) { - if (beacon.beaconInfoId === (<IEvent>event.getRedactionEvent())?.redacts) { - beacon.destroy(); - this.beacons.delete(beaconIdentifier); - } - return; - } - - return beacon.update(event); - } - - if (event.isRedacted()) { - return; - } - - const beacon = new Beacon(event); - - this.reEmitter.reEmit<BeaconEvent, BeaconEvent>(beacon, [ - BeaconEvent.New, - BeaconEvent.Update, - BeaconEvent.Destroy, - BeaconEvent.LivenessChange, - ]); - - this.emit(BeaconEvent.New, event, beacon); - beacon.on(BeaconEvent.LivenessChange, this.onBeaconLivenessChange.bind(this)); - beacon.on(BeaconEvent.Destroy, this.onBeaconLivenessChange.bind(this)); - - this.beacons.set(beacon.identifier, beacon); - } - - /** - * @experimental - * Check liveness of room beacons - * emit RoomStateEvent.BeaconLiveness event - */ - private onBeaconLivenessChange(): void { - this._liveBeaconIds = Array.from(this.beacons.values()) - .filter((beacon) => beacon.isLive) - .map((beacon) => beacon.identifier); - - this.emit(RoomStateEvent.BeaconLiveness, this, this.hasLiveBeacons); - } - - private getStateEventMatching(event: MatrixEvent): MatrixEvent | null { - return this.events.get(event.getType())?.get(event.getStateKey()!) ?? null; - } - - private updateMember(member: RoomMember): void { - // this member may have a power level already, so set it. - const pwrLvlEvent = this.getStateEvents(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 - */ - public needsOutOfBandMembers(): boolean { - 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. - */ - public outOfBandMembersReady(): boolean { - 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 - */ - public markOutOfBandMembersStarted(): void { - 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 - */ - public markOutOfBandMembersFailed(): void { - if (this.oobMemberFlags.status !== OobStatus.InProgress) { - return; - } - this.oobMemberFlags.status = OobStatus.NotStarted; - } - - /** - * Clears the loaded out-of-band members - */ - public clearOutOfBandMembers(): void { - let count = 0; - Object.keys(this.members).forEach((userId) => { - const member = this.members[userId]; - if (member.isOutOfBand()) { - ++count; - delete this.members[userId]; - } - }); - logger.log(`LL: RoomState removed ${count} members...`); - this.oobMemberFlags.status = OobStatus.NotStarted; - } - - /** - * Sets the loaded out-of-band members. - * @param stateEvents - array of membership state events - */ - public setOutOfBandMembers(stateEvents: MatrixEvent[]): void { - logger.log(`LL: RoomState about to set ${stateEvents.length} OOB members ...`); - if (this.oobMemberFlags.status !== OobStatus.InProgress) { - return; - } - 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 - */ - private setOutOfBandMember(stateEvent: MatrixEvent): void { - if (stateEvent.getType() !== 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 - */ - public setTypingEvent(event: MatrixEvent): void { - 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 - */ - public getInviteForThreePidToken(token: string): MatrixEvent | null { - return this.tokenToInvite[token] || null; - } - - /** - * Update the last modified time to the current time. - */ - private updateModifiedTime(): void { - 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 - */ - public getLastModifiedTime(): number { - 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. - */ - public getUserIdsWithDisplayName(displayName: string): string[] { - return this.displayNameToUserIds.get(utils.removeHiddenChars(displayName)) ?? []; - } - - /** - * Returns true if userId is in room, event is not redacted and either sender of - * mxEvent or has power level sufficient to redact events other than their own. - * @param 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 - */ - public maySendRedactionForEvent(mxEvent: MatrixEvent, userId: string): boolean { - 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(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 - */ - public hasSufficientPowerLevelFor(action: "ban" | "kick" | "redact", powerLevel: number): boolean { - const powerLevelsEvent = this.getStateEvents(EventType.RoomPowerLevels, ""); - - let powerLevels: IPowerLevelsContent = {}; - if (powerLevelsEvent) { - powerLevels = powerLevelsEvent.getContent(); - } - - let requiredLevel = 50; - if (utils.isNumber(powerLevels[action])) { - requiredLevel = powerLevels[action]!; - } - - return powerLevel >= requiredLevel; - } - - /** - * Short-form for maySendEvent('m.room.message', userId) - * @param 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. - */ - public maySendMessage(userId: string): boolean { - return this.maySendEventOfType(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. - */ - public maySendEvent(eventType: EventType | string, userId: string): boolean { - 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. - */ - public mayClientSendStateEvent(stateEventType: EventType | string, cli: MatrixClient): boolean { - 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. - */ - public maySendStateEvent(stateEventType: EventType | string, userId: string): boolean { - 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. - */ - private maySendEventOfType(eventType: EventType | string, userId: string, state: boolean): boolean { - const powerLevelsEvent = this.getStateEvents(EventType.RoomPowerLevels, ""); - - let powerLevels: IPowerLevelsContent; - let eventsLevels: Record<EventType | string, number> = {}; - - 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. - */ - public mayTriggerNotifOfType(notifLevelKey: string, userId: string): boolean { - const member = this.getMember(userId); - if (!member) { - return false; - } - - const powerLevelsEvent = this.getStateEvents(EventType.RoomPowerLevels, ""); - - let notifLevel = 50; - if ( - powerLevelsEvent && - powerLevelsEvent.getContent() && - powerLevelsEvent.getContent().notifications && - utils.isNumber(powerLevelsEvent.getContent().notifications[notifLevelKey]) - ) { - notifLevel = powerLevelsEvent.getContent().notifications[notifLevelKey]; - } - - return member.powerLevel >= notifLevel; - } - - /** - * Returns the join rule based on the m.room.join_rule state event, defaulting to `invite`. - * @returns the join_rule applied to this room - */ - public getJoinRule(): JoinRule { - const joinRuleEvent = this.getStateEvents(EventType.RoomJoinRules, ""); - const joinRuleContent: Partial<IJoinRuleEventContent> = joinRuleEvent?.getContent() ?? {}; - return joinRuleContent["join_rule"] || 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 - */ - public getHistoryVisibility(): HistoryVisibility { - const historyVisibilityEvent = this.getStateEvents(EventType.RoomHistoryVisibility, ""); - const historyVisibilityContent = historyVisibilityEvent?.getContent() ?? {}; - return historyVisibilityContent["history_visibility"] || 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 - */ - public getGuestAccess(): GuestAccess { - const guestAccessEvent = this.getStateEvents(EventType.RoomGuestAccess, ""); - const guestAccessContent = guestAccessEvent?.getContent() ?? {}; - return guestAccessContent["guest_access"] || 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). - */ - public findPredecessor( - msc3946ProcessDynamicPredecessor = false, - ): { roomId: string; eventId?: string; viaServers?: string[] } | null { - // Note: the tests for this function are against Room.findPredecessor, - // which just calls through to here. - - if (msc3946ProcessDynamicPredecessor) { - const predecessorEvent = this.getStateEvents(EventType.RoomPredecessor, ""); - if (predecessorEvent) { - const content = predecessorEvent.getContent<{ - predecessor_room_id: string; - last_known_event_id?: string; - via_servers?: string[]; - }>(); - 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(EventType.RoomCreate, ""); - if (createEvent) { - const predecessor = createEvent.getContent<{ - predecessor?: Partial<{ - room_id: string; - event_id: string; - }>; - }>()["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; - } - - private updateThirdPartyTokenCache(memberEvent: MatrixEvent): void { - if (!memberEvent.getContent().third_party_invite) { - return; - } - const token = (memberEvent.getContent().third_party_invite.signed || {}).token; - if (!token) { - return; - } - const threePidInvite = this.getStateEvents(EventType.RoomThirdPartyInvite, token); - if (!threePidInvite) { - return; - } - this.tokenToInvite[token] = memberEvent; - } - - private updateDisplayNameCache(userId: string, displayName: string): void { - 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 = 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 && 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); - } - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-summary.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-summary.ts deleted file mode 100644 index 936ec1d..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-summary.ts +++ /dev/null @@ -1,44 +0,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. -*/ - -export interface IRoomSummary { - "m.heroes": string[]; - "m.joined_member_count"?: number; - "m.invited_member_count"?: number; -} - -interface IInfo { - /** The title of the room (e.g. `m.room.name`) */ - title: string; - /** The description of the room (e.g. `m.room.topic`) */ - desc?: string; - /** The number of joined users. */ - numMembers?: number; - /** The list of aliases for this room. */ - aliases?: string[]; - /** The timestamp for this room. */ - timestamp?: number; -} - -/** - * 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. - */ -export class RoomSummary { - public constructor(public readonly roomId: string, info?: IInfo) {} -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room.ts deleted file mode 100644 index 133b210..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room.ts +++ /dev/null @@ -1,3487 +0,0 @@ -/* -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. -*/ - -import { M_POLL_START, Optional } from "matrix-events-sdk"; - -import { - EventTimelineSet, - DuplicateStrategy, - IAddLiveEventOptions, - EventTimelineSetHandlerMap, -} from "./event-timeline-set"; -import { Direction, EventTimeline } from "./event-timeline"; -import { getHttpUriForMxc } from "../content-repo"; -import * as utils from "../utils"; -import { normalize, noUnsafeEventProps } from "../utils"; -import { IEvent, IThreadBundledRelationship, MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap } from "./event"; -import { EventStatus } from "./event-status"; -import { RoomMember } from "./room-member"; -import { IRoomSummary, RoomSummary } from "./room-summary"; -import { logger } from "../logger"; -import { TypedReEmitter } from "../ReEmitter"; -import { - EventType, - RoomCreateTypeField, - RoomType, - UNSTABLE_ELEMENT_FUNCTIONAL_USERS, - EVENT_VISIBILITY_CHANGE_TYPE, - RelationType, -} from "../@types/event"; -import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersionStability } from "../client"; -import { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials"; -import { Filter, IFilterDefinition } from "../filter"; -import { RoomState, RoomStateEvent, RoomStateEventHandlerMap } from "./room-state"; -import { BeaconEvent, BeaconEventHandlerMap } from "./beacon"; -import { - Thread, - ThreadEvent, - EventHandlerMap as ThreadHandlerMap, - FILTER_RELATED_BY_REL_TYPES, - THREAD_RELATION_TYPE, - FILTER_RELATED_BY_SENDERS, - ThreadFilterType, -} from "./thread"; -import { - CachedReceiptStructure, - MAIN_ROOM_TIMELINE, - Receipt, - ReceiptContent, - ReceiptType, -} from "../@types/read_receipts"; -import { IStateEventWithRoomId } from "../@types/search"; -import { RelationsContainer } from "./relations-container"; -import { ReadReceipt, synthesizeReceipt } from "./read-receipt"; -import { Poll, PollEvent } from "./poll"; - -// 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. -export const KNOWN_SAFE_ROOM_VERSION = "9"; -const SAFE_ROOM_VERSIONS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]; - -interface IOpts { - /** - * Controls where pending messages appear in a room's timeline. - * If "<b>chronological</b>", messages will appear in the timeline when the call to `sendEvent` was made. - * If "<b>detached</b>", pending messages will appear in a separate list, - * accessible via {@link Room#getPendingEvents}. - * Default: "chronological". - */ - pendingEventOrdering?: PendingEventOrdering; - /** - * Set to true to enable improved timeline support. - */ - timelineSupport?: boolean; - lazyLoadMembers?: boolean; -} - -export interface IRecommendedVersion { - version: string; - needsUpgrade: boolean; - urgent: boolean; -} - -// 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; - -export type NotificationCount = Partial<Record<NotificationCountType, number>>; - -export enum NotificationCountType { - Highlight = "highlight", - Total = "total", -} - -export interface ICreateFilterOpts { - // Populate the filtered timeline with already loaded events in the room - // timeline. Useful to disable for some filters that can't be achieved by the - // client in an efficient manner - prepopulateTimeline?: boolean; - useSyncEvents?: boolean; - pendingEvents?: boolean; -} - -export enum RoomEvent { - MyMembership = "Room.myMembership", - Tags = "Room.tags", - AccountData = "Room.accountData", - Receipt = "Room.receipt", - Name = "Room.name", - Redaction = "Room.redaction", - RedactionCancelled = "Room.redactionCancelled", - LocalEchoUpdated = "Room.localEchoUpdated", - Timeline = "Room.timeline", - TimelineReset = "Room.timelineReset", - TimelineRefresh = "Room.TimelineRefresh", - OldStateUpdated = "Room.OldStateUpdated", - CurrentStateUpdated = "Room.CurrentStateUpdated", - HistoryImportedWithinTimeline = "Room.historyImportedWithinTimeline", - UnreadNotifications = "Room.UnreadNotifications", -} - -export type RoomEmittedEvents = - | RoomEvent - | RoomStateEvent.Events - | RoomStateEvent.Members - | RoomStateEvent.NewMember - | RoomStateEvent.Update - | RoomStateEvent.Marker - | ThreadEvent.New - | ThreadEvent.Update - | ThreadEvent.NewReply - | ThreadEvent.Delete - | MatrixEventEvent.BeforeRedaction - | BeaconEvent.New - | BeaconEvent.Update - | BeaconEvent.Destroy - | BeaconEvent.LivenessChange - | PollEvent.New; - -export type RoomEventHandlerMap = { - /** - * Fires when the logged in user's membership in the room is updated. - * - * @param room - The room in which the membership has been updated - * @param membership - The new membership value - * @param prevMembership - The previous membership value - */ - [RoomEvent.MyMembership]: (room: Room, membership: string, prevMembership?: string) => void; - /** - * Fires whenever a room's tags are updated. - * @param event - The tags event - * @param room - The room whose Room.tags was updated. - * @example - * ``` - * matrixClient.on("Room.tags", function(event, room){ - * var newTags = event.getContent().tags; - * if (newTags["favourite"]) showStar(room); - * }); - * ``` - */ - [RoomEvent.Tags]: (event: MatrixEvent, room: Room) => void; - /** - * Fires whenever a room's account_data is updated. - * @param event - The account_data event - * @param room - The room whose account_data was updated. - * @param prevEvent - The event being replaced by - * the new account data, if known. - * @example - * ``` - * matrixClient.on("Room.accountData", function(event, room, oldEvent){ - * if (event.getType() === "m.room.colorscheme") { - * applyColorScheme(event.getContents()); - * } - * }); - * ``` - */ - [RoomEvent.AccountData]: (event: MatrixEvent, room: Room, lastEvent?: MatrixEvent) => void; - /** - * Fires whenever a receipt is received for a room - * @param event - The receipt event - * @param room - The room whose receipts was updated. - * @example - * ``` - * matrixClient.on("Room.receipt", function(event, room){ - * var receiptContent = event.getContent(); - * }); - * ``` - */ - [RoomEvent.Receipt]: (event: MatrixEvent, room: Room) => void; - /** - * Fires whenever the name of a room is updated. - * @param room - The room whose Room.name was updated. - * @example - * ``` - * matrixClient.on("Room.name", function(room){ - * var newName = room.name; - * }); - * ``` - */ - [RoomEvent.Name]: (room: Room) => void; - /** - * Fires when an event we had previously received is redacted. - * - * (Note this is *not* fired when the redaction happens before we receive the - * event). - * - * @param event - The matrix redaction event - * @param room - The room containing the redacted event - */ - [RoomEvent.Redaction]: (event: MatrixEvent, room: Room) => void; - /** - * Fires when an event that was previously redacted isn't anymore. - * This happens when the redaction couldn't be sent and - * was subsequently cancelled by the user. Redactions have a local echo - * which is undone in this scenario. - * - * @param event - The matrix redaction event that was cancelled. - * @param room - The room containing the unredacted event - */ - [RoomEvent.RedactionCancelled]: (event: MatrixEvent, room: Room) => void; - /** - * Fires when the status of a transmitted event is updated. - * - * <p>When an event is first transmitted, a temporary copy of the event is - * inserted into the timeline, with a temporary event id, and a status of - * 'SENDING'. - * - * <p>Once the echo comes back from the server, the content of the event - * (MatrixEvent.event) is replaced by the complete event from the homeserver, - * thus updating its event id, as well as server-generated fields such as the - * timestamp. Its status is set to null. - * - * <p>Once the /send request completes, if the remote echo has not already - * arrived, the event is updated with a new event id and the status is set to - * 'SENT'. The server-generated fields are of course not updated yet. - * - * <p>If the /send fails, In this case, the event's status is set to - * 'NOT_SENT'. If it is later resent, the process starts again, setting the - * status to 'SENDING'. Alternatively, the message may be cancelled, which - * removes the event from the room, and sets the status to 'CANCELLED'. - * - * <p>This event is raised to reflect each of the transitions above. - * - * @param event - The matrix event which has been updated - * - * @param room - The room containing the redacted event - * - * @param oldEventId - The previous event id (the temporary event id, - * except when updating a successfully-sent event when its echo arrives) - * - * @param oldStatus - The previous event status. - */ - [RoomEvent.LocalEchoUpdated]: ( - event: MatrixEvent, - room: Room, - oldEventId?: string, - oldStatus?: EventStatus | null, - ) => void; - [RoomEvent.OldStateUpdated]: (room: Room, previousRoomState: RoomState, roomState: RoomState) => void; - [RoomEvent.CurrentStateUpdated]: (room: Room, previousRoomState: RoomState, roomState: RoomState) => void; - [RoomEvent.HistoryImportedWithinTimeline]: (markerEvent: MatrixEvent, room: Room) => void; - [RoomEvent.UnreadNotifications]: (unreadNotifications?: NotificationCount, threadId?: string) => void; - [RoomEvent.TimelineRefresh]: (room: Room, eventTimelineSet: EventTimelineSet) => void; - [ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void; - /** - * Fires when a new poll instance is added to the room state - * @param poll - the new poll - */ - [PollEvent.New]: (poll: Poll) => void; -} & Pick<ThreadHandlerMap, ThreadEvent.Update | ThreadEvent.NewReply | ThreadEvent.Delete> & - EventTimelineSetHandlerMap & - Pick<MatrixEventHandlerMap, MatrixEventEvent.BeforeRedaction> & - Pick< - RoomStateEventHandlerMap, - | RoomStateEvent.Events - | RoomStateEvent.Members - | RoomStateEvent.NewMember - | RoomStateEvent.Update - | RoomStateEvent.Marker - | BeaconEvent.New - > & - Pick<BeaconEventHandlerMap, BeaconEvent.Update | BeaconEvent.Destroy | BeaconEvent.LivenessChange>; - -export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> { - public readonly reEmitter: TypedReEmitter<RoomEmittedEvents, RoomEventHandlerMap>; - private txnToEvent: Map<string, MatrixEvent> = new Map(); // Pending in-flight requests { string: MatrixEvent } - private notificationCounts: NotificationCount = {}; - private readonly threadNotifications = new Map<string, NotificationCount>(); - public readonly cachedThreadReadReceipts = new Map<string, CachedReceiptStructure[]>(); - // Useful to know at what point the current user has started using threads in this room - private 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 - */ - private unthreadedReceipts = new Map<string, Receipt>(); - private readonly timelineSets: EventTimelineSet[]; - public readonly polls: Map<string, Poll> = new Map<string, Poll>(); - public readonly threadsTimelineSets: EventTimelineSet[] = []; - // any filtered timeline sets we're maintaining for this room - private readonly filteredTimelineSets: Record<string, EventTimelineSet> = {}; // filter_id: timelineSet - private timelineNeedsRefresh = false; - private readonly pendingEventList?: MatrixEvent[]; - // read by megolm via getter; boolean value - null indicates "use global value" - private blacklistUnverifiedDevices?: boolean; - private selfMembership?: string; - private summaryHeroes: string[] | null = null; - // flags to stop logspam about missing m.room.create events - private getTypeWarning = false; - private getVersionWarning = false; - private membersPromise?: Promise<boolean>; - - // XXX: These should be read-only - /** - * The human-readable display name for this room. - */ - public name: string; - /** - * The un-homoglyphed name for this room. - */ - public normalizedName: string; - /** - * 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 } }` - */ - public tags: Record<string, Record<string, any>> = {}; // $tagName: { $metadata: $value } - /** - * accountData Dict of per-room account_data events; the keys are the - * event type and the values are the events. - */ - public accountData: Map<string, MatrixEvent> = new Map(); // $eventType: $event - /** - * The room summary. - */ - public summary: RoomSummary | null = null; - // legacy fields - /** - * The live event timeline for this room, with the oldest event at index 0. - * Present for backwards compatibility - prefer getLiveTimeline().getEvents() - */ - public timeline!: MatrixEvent[]; - /** - * oldState The state of the room at the time of the oldest - * event in the live timeline. Present for backwards compatibility - - * prefer getLiveTimeline().getState(EventTimeline.BACKWARDS). - */ - public oldState!: RoomState; - /** - * currentState The state of the room at the time of the - * newest event in the timeline. Present for backwards compatibility - - * prefer getLiveTimeline().getState(EventTimeline.FORWARDS). - */ - public currentState!: RoomState; - public readonly relations = new 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 - */ - private threads = new Map<string, Thread>(); - public lastThread?: Thread; - - /** - * 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 - */ - private visibilityEvents = new Map<string, MatrixEvent[]>(); - - /** - * 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 - */ - public constructor( - public readonly roomId: string, - public readonly client: MatrixClient, - public readonly myUserId: string, - private readonly opts: IOpts = {}, - ) { - 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.setMaxListeners(100); - this.reEmitter = new TypedReEmitter(this); - - opts.pendingEventOrdering = opts.pendingEventOrdering || 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(this, opts)]; - this.reEmitter.reEmit(this.getUnfilteredTimelineSet(), [RoomEvent.Timeline, RoomEvent.TimelineReset]); - - this.fixUpLegacyTimelineFields(); - - if (this.opts.pendingEventOrdering === 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: Partial<IEvent>) => { - const event = mapper(serializedEvent); - await client.decryptEventIfNeeded(event); - event.setStatus(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; - } - } - - private threadTimelineSetsPromise: Promise<[EventTimelineSet, EventTimelineSet]> | null = null; - public async createThreadsTimelineSets(): Promise<[EventTimelineSet, EventTimelineSet] | null> { - if (this.threadTimelineSetsPromise) { - return this.threadTimelineSetsPromise; - } - - if (this.client?.supportsThreads()) { - try { - this.threadTimelineSetsPromise = Promise.all([ - this.createThreadTimelineSet(), - this.createThreadTimelineSet(ThreadFilterType.My), - ]); - const timelineSets = await this.threadTimelineSetsPromise; - this.threadsTimelineSets.push(...timelineSets); - 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 - */ - public async decryptCriticalEvents(): Promise<void> { - 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 - */ - public async decryptAllEvents(): Promise<void> { - 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 - */ - public getCreator(): string | null { - const createEvent = this.currentState.getStateEvents(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 - */ - public getVersion(): string { - const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, ""); - if (!createEvent) { - if (!this.getVersionWarning) { - 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 - */ - public shouldUpgradeToVersion(): string | null { - // 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. - */ - public async getRecommendedVersion(): Promise<IRecommendedVersion> { - 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] = 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.warn( - "Refreshing room version capability because the server looks " + - "to be supporting a newer room version we don't know about.", - ); - - const caps = await this.client.getCapabilities(true); - versionCap = caps["m.room_versions"]; - if (!versionCap) { - logger.warn("No room version capability - assuming upgrade required."); - return result; - } else { - result = this.checkVersionAgainstCapability(versionCap); - } - } - - return result; - } - - private checkVersionAgainstCapability(versionCap: IRoomVersionsCapability): IRecommendedVersion { - const currentVersion = this.getVersion(); - logger.log(`[${this.roomId}] Current version: ${currentVersion}`); - logger.log(`[${this.roomId}] Version capability: `, versionCap); - - const result: IRecommendedVersion = { - version: currentVersion, - needsUpgrade: false, - urgent: false, - }; - - // If the room is on the default version then nothing needs to change - if (currentVersion === versionCap.default) return result; - - const stableVersions = Object.keys(versionCap.available).filter((v) => versionCap.available[v] === "stable"); - - // Check if the room is on an unstable version. We determine urgency based - // off the version being in the Matrix spec namespace or not (if the version - // is in the current namespace and unstable, the room is probably vulnerable). - if (!stableVersions.includes(currentVersion)) { - result.version = versionCap.default; - result.needsUpgrade = true; - result.urgent = !!this.getVersion().match(/^[0-9]+[0-9.]*$/g); - if (result.urgent) { - logger.warn(`URGENT upgrade required on ${this.roomId}`); - } else { - logger.warn(`Non-urgent upgrade required on ${this.roomId}`); - } - return result; - } - - // The room is on a stable, but non-default, version by this point. - // No upgrade needed. - return result; - } - - /** - * Determines whether the given user is permitted to perform a room upgrade - * @param userId - The ID of the user to test against - * @returns True if the given user is permitted to upgrade the room - */ - public userMayUpgradeRoom(userId: string): boolean { - return this.currentState.maySendStateEvent(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' - */ - public getPendingEvents(): MatrixEvent[] { - 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. - */ - public removePendingEvent(eventId: string): boolean { - if (!this.pendingEventList) { - throw new Error( - "Cannot call removePendingEvent with pendingEventOrdering == " + this.opts.pendingEventOrdering, - ); - } - - const removed = 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. - */ - public hasPendingEvent(eventId: string): boolean { - 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. - */ - public getPendingEvent(eventId: string): MatrixEvent | null { - return this.pendingEventList?.find((event) => event.getId() === eventId) ?? null; - } - - /** - * Get the live unfiltered timeline for this room. - * - * @returns live timeline - */ - public getLiveTimeline(): EventTimeline { - return this.getUnfilteredTimelineSet().getLiveTimeline(); - } - - /** - * Get the timestamp of the last message in the room - * - * @returns the timestamp of the last message in the room - */ - public getLastActiveTimestamp(): number { - 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 membership type (join | leave | invite) for the logged in user - */ - public getMyMembership(): string { - 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 - */ - public getDMInviter(): string | undefined { - 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) - */ - public guessDMUserId(): string { - 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; - } - - public getAvatarFallbackMember(): RoomMember | undefined { - const memberCount = this.getInvitedAndJoinedMemberCount(); - if (memberCount > 2) { - return; - } - const hasHeroes = Array.isArray(this.summaryHeroes) && this.summaryHeroes.length; - if (hasHeroes) { - const availableMember = this.summaryHeroes!.map((userId) => { - return this.getMember(userId); - }).find((member) => !!member); - if (availableMember) { - return availableMember; - } - } - const members = this.currentState.getMembers(); - // could be different than memberCount - // as this includes left members - if (members.length <= 2) { - const availableMember = members.find((m) => { - return m.userId !== this.myUserId; - }); - if (availableMember) { - return availableMember; - } - } - // if all else fails, try falling back to a user, - // and create a one-off member for it - if (hasHeroes) { - const availableUser = this.summaryHeroes!.map((userId) => { - return this.client.getUser(userId); - }).find((user) => !!user); - if (availableUser) { - const member = new RoomMember(this.roomId, availableUser.userId); - member.user = availableUser; - return member; - } - } - } - - /** - * Sets the membership this room was received as during sync - * @param membership - join | leave | invite - */ - public updateMyMembership(membership: string): void { - const prevMembership = this.selfMembership; - this.selfMembership = membership; - if (prevMembership !== membership) { - if (membership === "leave") { - this.cleanupAfterLeaving(); - } - this.emit(RoomEvent.MyMembership, this, membership, prevMembership); - } - } - - private async loadMembersFromServer(): Promise<IStateEventWithRoomId[]> { - const lastSyncToken = this.client.store.getSyncToken(); - const response = await this.client.members(this.roomId, undefined, "leave", lastSyncToken ?? undefined); - return response.chunk; - } - - private async loadMembers(): Promise<{ memberEvents: MatrixEvent[]; fromServer: boolean }> { - // 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.log(`LL: got ${rawMembersEvents.length} ` + `members from server for room ${this.roomId}`); - } - const memberEvents = rawMembersEvents.filter(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. - */ - public membersLoaded(): boolean { - 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 - */ - public loadMembersIfNeeded(): Promise<boolean> { - 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 as IStateEventWithRoomId); - logger.log(`LL: telling store to write ${oobMembers.length}` + ` members for room ${this.roomId}`); - const store = this.client.store; - return ( - store - .setOutOfBandMembers(this.roomId, oobMembers) - // swallow any IDB error as we don't want to fail - // because of this - .catch((err) => { - logger.log("LL: storing OOB room members failed, oh well", err); - }) - ); - } - }) - .catch((err) => { - // as this is not awaited anywhere, - // at least show the error in the console - logger.error(err); - }); - - this.membersPromise = inMemoryUpdate; - - return this.membersPromise; - } - - /** - * Removes the lazily loaded members from storage if needed - */ - public async clearLoadedMembersIfNeeded(): Promise<void> { - 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. - */ - private cleanupAfterLeaving(): void { - this.clearLoadedMembersIfNeeded().catch((err) => { - logger.error(`error after clearing loaded members from ` + `room ${this.roomId} after leaving`); - 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()`. - */ - public async refreshLiveTimeline(): Promise<void> { - const liveTimelineBefore = this.getLiveTimeline(); - const forwardPaginationToken = liveTimelineBefore.getPaginationToken(EventTimeline.FORWARDS); - const backwardPaginationToken = liveTimelineBefore.getPaginationToken(EventTimeline.BACKWARDS); - const eventsBefore = liveTimelineBefore.getEvents(); - const mostRecentEventInTimeline = eventsBefore[eventsBefore.length - 1]; - 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: Optional<EventTimeline>; - // 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(Direction.Forward) === null && - liveTimeline.getPaginationToken(Direction.Backward) === null && - liveTimeline.getEvents().length === 0) - ) { - 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.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.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. - */ - public resetLiveTimeline(backPaginationToken?: string | null, forwardPaginationToken?: string | null): void { - 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 - */ - private fixUpLegacyTimelineFields(): void { - 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.BACKWARDS)!; - this.currentState = this.getLiveTimeline().getState(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, [ - RoomStateEvent.Events, - RoomStateEvent.Members, - RoomStateEvent.NewMember, - RoomStateEvent.Update, - RoomStateEvent.Marker, - BeaconEvent.New, - BeaconEvent.Update, - BeaconEvent.Destroy, - BeaconEvent.LivenessChange, - ]); - this.reEmitter.reEmit(this.currentState, [ - RoomStateEvent.Events, - RoomStateEvent.Members, - RoomStateEvent.NewMember, - RoomStateEvent.Update, - RoomStateEvent.Marker, - BeaconEvent.New, - BeaconEvent.Update, - BeaconEvent.Destroy, - 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 - */ - public async hasUnverifiedDevices(): Promise<boolean> { - 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 - */ - public getTimelineSets(): EventTimelineSet[] { - return this.timelineSets; - } - - /** - * Helper to return the main unfiltered timeline set for this room - * @returns room's unfiltered timeline set - */ - public getUnfilteredTimelineSet(): EventTimelineSet { - 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 - */ - public getTimelineForEvent(eventId: string): EventTimeline | null { - 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 - */ - public addTimeline(): EventTimeline { - 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 - */ - public setTimelineNeedsRefresh(value: boolean): void { - this.timelineNeedsRefresh = value; - } - - /** - * Whether the timeline needs to be refreshed in order to pull in new - * historical messages that were imported. - * @returns . - */ - public getTimelineNeedsRefresh(): boolean { - 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 - */ - public findEventById(eventId: string): MatrixEvent | undefined { - 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. - */ - public getUnreadNotificationCount(type = NotificationCountType.Total): number { - 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) - */ - public getUnreadCountForEventContext(type = NotificationCountType.Total, event: MatrixEvent): number { - 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. - */ - public getRoomUnreadNotificationCount(type = NotificationCountType.Total): number { - 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. - */ - public getThreadUnreadNotificationCount(threadId: string, type = NotificationCountType.Total): number { - return this.threadNotifications.get(threadId)?.[type] ?? 0; - } - - /** - * Checks if the current room has unread thread notifications - * @returns - */ - public hasThreadUnreadNotification(): boolean { - 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 - */ - public setThreadUnreadNotificationCount(threadId: string, type: NotificationCountType, count: number): void { - const notification: NotificationCount = { - 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 - */ - public get threadsAggregateNotificationType(): NotificationCountType | null { - let type: NotificationCountType | null = 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 - */ - public resetThreadUnreadNotificationCount(notificationsToKeep?: string[]): void { - 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 - */ - public setUnreadNotificationCount(type: NotificationCountType, count: number): void { - this.notificationCounts[type] = count; - this.emit(RoomEvent.UnreadNotifications, this.notificationCounts); - } - - public setUnread(type: NotificationCountType, count: number): void { - return this.setUnreadNotificationCount(type, count); - } - - public setSummary(summary: IRoomSummary): void { - 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. - */ - public setBlacklistUnverifiedDevices(value: boolean): void { - 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. - */ - public getBlacklistUnverifiedDevices(): boolean | null { - 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. - */ - public getAvatarUrl( - baseUrl: string, - width: number, - height: number, - resizeMethod: ResizeMethod, - allowDefault = true, - ): string | null { - const roomAvatarEvent = this.currentState.getStateEvents(EventType.RoomAvatar, ""); - if (!roomAvatarEvent && !allowDefault) { - return null; - } - - const mainUrl = roomAvatarEvent ? roomAvatarEvent.getContent().url : null; - if (mainUrl) { - return getHttpUriForMxc(baseUrl, mainUrl, width, height, resizeMethod); - } - - return null; - } - - /** - * Get the mxc avatar url for the room, if one was set. - * @returns the mxc avatar url or falsy - */ - public getMxcAvatarUrl(): string | null { - return this.currentState.getStateEvents(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 - */ - public getCanonicalAlias(): string | null { - const canonicalAlias = this.currentState.getStateEvents(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 - */ - public getAltAliases(): string[] { - const canonicalAlias = this.currentState.getStateEvents(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} - */ - public addEventsToTimeline( - events: MatrixEvent[], - toStartOfTimeline: boolean, - timeline: EventTimeline, - paginationToken?: string, - ): void { - 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 - */ - public getThread(eventId: string): Thread | null { - return this.threads.get(eventId) ?? null; - } - - /** - * Get all the known threads in the room - */ - public getThreads(): Thread[] { - 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`. - */ - public getMember(userId: string): RoomMember | null { - return this.currentState.getMember(userId); - } - - /** - * Get all currently loaded members from the current - * room state. - * @returns Room members - */ - public getMembers(): RoomMember[] { - return this.currentState.getMembers(); - } - - /** - * Get a list of members whose membership state is "join". - * @returns A list of currently joined members. - */ - public getJoinedMembers(): RoomMember[] { - 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' - */ - public getJoinedMemberCount(): number { - 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' - */ - public getInvitedMemberCount(): number { - 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' - */ - public getInvitedAndJoinedMemberCount(): number { - 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. - */ - public getMembersWithMembership(membership: string): RoomMember[] { - 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. - */ - public async getEncryptionTargetMembers(): Promise<RoomMember[]> { - 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 - */ - public shouldEncryptForInvitedMembers(): boolean { - const ev = this.currentState.getStateEvents(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 - */ - public getDefaultRoomName(userId: string): string { - 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. - */ - public hasMembershipState(userId: string, membership: string): boolean { - 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 - */ - public getOrCreateFilteredTimelineSet( - filter: Filter, - { prepopulateTimeline = true, useSyncEvents = true, pendingEvents = true }: ICreateFilterOpts = {}, - ): EventTimelineSet { - if (this.filteredTimelineSets[filter.filterId!]) { - return this.filteredTimelineSets[filter.filterId!]; - } - const opts = Object.assign({ filter, pendingEvents }, this.opts); - const timelineSet = new 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.BACKWARDS)) { - timeline = timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS)!; - } - - timelineSet - .getLiveTimeline() - .setPaginationToken(timeline.getPaginationToken(EventTimeline.BACKWARDS), EventTimeline.BACKWARDS); - } else if (useSyncEvents) { - const livePaginationToken = unfilteredLiveTimeline.getPaginationToken(Direction.Forward); - timelineSet.getLiveTimeline().setPaginationToken(livePaginationToken, 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; - } - - private async getThreadListFilter(filterType = ThreadFilterType.All): Promise<Filter> { - const myUserId = this.client.getUserId()!; - const filter = new Filter(myUserId); - - const definition: IFilterDefinition = { - room: { - timeline: { - [FILTER_RELATED_BY_REL_TYPES.name]: [THREAD_RELATION_TYPE.name], - }, - }, - }; - - if (filterType === ThreadFilterType.My) { - definition!.room!.timeline![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; - } - - private async createThreadTimelineSet(filterType?: ThreadFilterType): Promise<EventTimelineSet> { - let timelineSet: EventTimelineSet; - if (Thread.hasServerSideListSupport) { - timelineSet = new EventTimelineSet( - this, - { - ...this.opts, - pendingEvents: false, - }, - undefined, - undefined, - filterType ?? ThreadFilterType.All, - ); - this.reEmitter.reEmit(timelineSet, [RoomEvent.Timeline, RoomEvent.TimelineReset]); - } else if (Thread.hasServerSideSupport) { - const filter = await this.getThreadListFilter(filterType); - - timelineSet = this.getOrCreateFilteredTimelineSet(filter, { - prepopulateTimeline: false, - useSyncEvents: false, - pendingEvents: false, - }); - } else { - timelineSet = new 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 !== ThreadFilterType.My || currentUserParticipated) { - timelineSet.getLiveTimeline().addEvent(thread.rootEvent!, { - toStartOfTimeline: false, - }); - } - }); - } - - return timelineSet; - } - - private threadsReady = false; - - /** - * Takes the given thread root events and creates threads for them. - */ - public processThreadRoots(events: MatrixEvent[], toStartOfTimeline: boolean): void { - for (const rootEvent of events) { - 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. - */ - public async fetchRoomThreads(): Promise<void> { - if (this.threadsReady || !this.client.supportsThreads()) { - return; - } - - if (Thread.hasServerSideListSupport) { - await Promise.all([ - this.fetchRoomThreadList(ThreadFilterType.All), - this.fetchRoomThreadList(ThreadFilterType.My), - ]); - } else { - const allThreadsFilter = await this.getThreadListFilter(); - - const { chunk: events } = await this.client.createMessagesRequest( - this.roomId, - "", - Number.MAX_SAFE_INTEGER, - 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<IThreadBundledRelationship>( - THREAD_RELATION_TYPE.name, - )!; - const threadBMetadata = eventB.getServerAggregatedRelation<IThreadBundledRelationship>( - THREAD_RELATION_TYPE.name, - )!; - return threadAMetadata.latest_event.origin_server_ts - threadBMetadata.latest_event.origin_server_ts; - }); - - let latestMyThreadsRootEvent: MatrixEvent | undefined; - const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); - for (const rootEvent of threadRoots) { - const opts = { - duplicateStrategy: DuplicateStrategy.Ignore, - fromCache: false, - roomState, - }; - this.threadsTimelineSets[0]?.addLiveEvent(rootEvent, opts); - - const threadRelationship = rootEvent.getServerAggregatedRelation<IThreadBundledRelationship>( - 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(ThreadEvent.NewReply, this.onThreadNewReply); - this.on(ThreadEvent.Delete, this.onThreadDelete); - this.threadsReady = true; - } - - public async processPollEvents(events: MatrixEvent[]): Promise<void> { - const processPollStartEvent = (event: MatrixEvent): void => { - if (!M_POLL_START.matches(event.getType())) return; - try { - const poll = new Poll(event, this.client, this); - this.polls.set(event.getId()!, poll); - this.emit(PollEvent.New, poll); - } catch {} - // poll creation can fail for malformed poll start events - }; - - const processPollRelationEvent = (event: MatrixEvent): void => { - const relationEventId = event.relationEventId; - if (relationEventId && this.polls.has(relationEventId)) { - const poll = this.polls.get(relationEventId); - poll?.onNewRelation(event); - } - }; - - const processPollEvent = (event: MatrixEvent): void => { - processPollStartEvent(event); - processPollRelationEvent(event); - }; - - for (const event of events) { - try { - await this.client.decryptEventIfNeeded(event); - processPollEvent(event); - } catch {} - } - } - - /** - * Fetch a single page of threadlist messages for the specific thread filter - * @internal - */ - private async fetchRoomThreadList(filter?: ThreadFilterType): Promise<void> { - const timelineSet = filter === ThreadFilterType.My ? this.threadsTimelineSets[1] : this.threadsTimelineSets[0]; - - const { chunk: events, end } = await this.client.createThreadListMessagesRequest( - this.roomId, - null, - undefined, - Direction.Backward, - timelineSet.threadListType, - timelineSet.getFilter(), - ); - - timelineSet.getLiveTimeline().setPaginationToken(end ?? null, Direction.Backward); - - if (!events.length) return; - - const matrixEvents = events.map(this.client.getEventMapper()); - this.processThreadRoots(matrixEvents, true); - const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); - for (const rootEvent of matrixEvents) { - timelineSet.addLiveEvent(rootEvent, { - duplicateStrategy: DuplicateStrategy.Replace, - fromCache: false, - roomState, - }); - } - } - - private onThreadNewReply(thread: Thread): void { - this.updateThreadRootEvents(thread, false, true); - } - - private onThreadDelete(thread: Thread): void { - 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.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 - */ - public removeFilteredTimelineSet(filter: Filter): void { - 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); - } - } - - public eventShouldLiveIn( - event: MatrixEvent, - events?: MatrixEvent[], - roots?: Set<string>, - ): { - shouldLiveInRoom: boolean; - shouldLiveInThread: boolean; - threadId?: string; - } { - 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_RELATION_TYPE.name)) { - return { - shouldLiveInRoom: false, - shouldLiveInThread: true, - threadId: event.threadRootId, - }; - } - - const parentEventId = event.getAssociatedId(); - let parentEvent: MatrixEvent | undefined; - 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); - } - - // 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, - }; - } - - // We've exhausted all scenarios, can safely assume that this event should live in the room timeline only - return { - shouldLiveInRoom: true, - shouldLiveInThread: false, - }; - } - - public findThreadForEvent(event?: MatrixEvent): Thread | null { - if (!event) return null; - - const { threadId } = this.eventShouldLiveIn(event); - return threadId ? this.getThread(threadId) : null; - } - - private addThreadedEvents(threadId: string, events: MatrixEvent[], toStartOfTimeline = false): void { - let thread = this.getThread(threadId); - - if (!thread) { - const rootEvent = this.findEventById(threadId) ?? events.find((e) => e.getId() === threadId); - thread = this.createThread(threadId, rootEvent, events, toStartOfTimeline); - } - - thread.addEvents(events, toStartOfTimeline); - } - - /** - * Adds events to a thread's timeline. Will fire "Thread.update" - */ - public processThreadedEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void { - events.forEach(this.applyRedaction); - - const eventsByThread: { [threadId: string]: MatrixEvent[] } = {}; - 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), - ); - } - - private updateThreadRootEvents = (thread: Thread, toStartOfTimeline: boolean, recreateEvent: boolean): void => { - if (thread.length) { - this.updateThreadRootEvent(this.threadsTimelineSets?.[0], thread, toStartOfTimeline, recreateEvent); - if (thread.hasCurrentUserParticipated) { - this.updateThreadRootEvent(this.threadsTimelineSets?.[1], thread, toStartOfTimeline, recreateEvent); - } - } - }; - - private updateThreadRootEvent = ( - timelineSet: Optional<EventTimelineSet>, - thread: Thread, - toStartOfTimeline: boolean, - recreateEvent: boolean, - ): void => { - if (timelineSet && thread.rootEvent) { - if (recreateEvent) { - timelineSet.removeEvent(thread.id); - } - if (Thread.hasServerSideSupport) { - timelineSet.addLiveEvent(thread.rootEvent, { - duplicateStrategy: DuplicateStrategy.Replace, - fromCache: false, - roomState: this.currentState, - }); - } else { - timelineSet.addEventToTimeline(thread.rootEvent, timelineSet.getLiveTimeline(), { toStartOfTimeline }); - } - } - }; - - public createThread( - threadId: string, - rootEvent: MatrixEvent | undefined, - events: MatrixEvent[] = [], - toStartOfTimeline: boolean, - ): Thread { - 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(RelationType.Replace))); - } - } - - const thread = new 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, [ - ThreadEvent.Delete, - ThreadEvent.Update, - 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(ThreadEvent.New, thread, toStartOfTimeline); - - return thread; - } - - private applyRedaction = (event: MatrixEvent): void => { - 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. - } - }; - - private processLiveEvent(event: MatrixEvent): void { - 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.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} - */ - private addLiveEvent(event: MatrixEvent, addLiveEventOptions: IAddLiveEventOptions): void { - 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() !== EventType.RoomRedaction) { - this.addReceipt(synthesizeReceipt(event.sender.userId, event, 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} - */ - public addPendingEvent(event: MatrixEvent, txnId: string): void { - if (event.status !== EventStatus.SENDING && event.status !== 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.setEventMetadata(event, this.getLiveTimeline().getState(EventTimeline.FORWARDS)!, false); - - this.txnToEvent.set(txnId, event); - if (this.pendingEventList) { - if (this.pendingEventList.some((e) => e.status === EventStatus.NOT_SENT)) { - logger.warn("Setting event as NOT_SENT due to messages in the same state"); - event.setStatus(EventStatus.NOT_SENT); - } - this.pendingEventList.push(event); - 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 - */ - private savePendingEvents(): void { - if (this.pendingEventList) { - const pendingEvents = this.pendingEventList - .map((event) => { - return { - ...event.event, - txn_id: event.getTxnId(), - }; - }) - .filter((event) => { - // Filter out the unencrypted messages if the room is encrypted - const isEventEncrypted = event.type === 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. - */ - private aggregateNonLiveRelation(event: MatrixEvent): void { - this.relations.aggregateChildEvent(event); - } - - public getEventForTxnId(txnId: string): MatrixEvent | undefined { - 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} - */ - public handleRemoteEcho(remoteEvent: MatrixEvent, localEvent: MatrixEvent): void { - const oldEventId = localEvent.getId()!; - const newEventId = remoteEvent.getId()!; - const oldStatus = localEvent.status; - - logger.debug(`Got remote echo for event ${oldEventId} -> ${newEventId} old status ${oldStatus}`); - - // no longer pending - 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} - */ - public updatePendingEvent(event: MatrixEvent, newStatus: EventStatus, newEventId?: string): void { - logger.log( - `setting pendingEvent status to ${newStatus} in ${event.getRoomId()} ` + - `event ID ${event.getId()} -> ${newEventId}`, - ); - - // if the message was sent, we expect an event id - if (newStatus == EventStatus.SENT && !newEventId) { - throw new Error("updatePendingEvent called with status=SENT, but no new event id"); - } - - // SENT races against /sync, so we have to special-case it. - if (newStatus == EventStatus.SENT) { - const timeline = this.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.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.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); - } - - private revertRedactionLocalEcho(redactionEvent: MatrixEvent): void { - 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'. - */ - public addLiveEvents(events: MatrixEvent[], addLiveEventOptions?: IAddLiveEventOptions): void; - /** - * @deprecated In favor of the overload with `IAddLiveEventOptions` - */ - public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: DuplicateStrategy, fromCache?: boolean): void; - public addLiveEvents( - events: MatrixEvent[], - duplicateStrategyOrOpts?: DuplicateStrategy | IAddLiveEventOptions, - fromCache = false, - ): void { - let duplicateStrategy: DuplicateStrategy | undefined = duplicateStrategyOrOpts as DuplicateStrategy; - let timelineWasEmpty: boolean | undefined = 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.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.FORWARDS)) { - throw new Error( - "live timeline " + - i + - " is no longer live - it has a pagination token " + - "(" + - liveTimeline.getPaginationToken(EventTimeline.FORWARDS) + - ")", - ); - } - if (liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) { - throw new Error(`live timeline ${i} is no longer live - it has a neighbouring timeline`); - } - } - - const threadRoots = this.findThreadRoots(events); - const eventsByThread: { [threadId: string]: MatrixEvent[] } = {}; - - const options: IAddLiveEventOptions = { - duplicateStrategy, - fromCache, - timelineWasEmpty, - }; - - 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 - } - } - - const { shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn( - event, - events, - threadRoots, - ); - - if (shouldLiveInThread && !eventsByThread[threadId ?? ""]) { - eventsByThread[threadId ?? ""] = []; - } - eventsByThread[threadId ?? ""]?.push(event); - - if (shouldLiveInRoom) { - this.addLiveEvent(event, options); - } - } - - Object.entries(eventsByThread).forEach(([threadId, threadEvents]) => { - this.addThreadedEvents(threadId, threadEvents, false); - }); - } - - public partitionThreadedEvents( - events: MatrixEvent[], - ): [timelineEvents: MatrixEvent[], threadedEvents: MatrixEvent[]] { - // Indices to the events array, for readability - const ROOM = 0; - const THREAD = 1; - if (this.client.supportsThreads()) { - const threadRoots = this.findThreadRoots(events); - return events.reduce( - (memo, event: MatrixEvent) => { - const { shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn( - event, - events, - threadRoots, - ); - - if (shouldLiveInRoom) { - memo[ROOM].push(event); - } - - if (shouldLiveInThread) { - event.setThreadId(threadId ?? ""); - memo[THREAD].push(event); - } - - return memo; - }, - [[] as MatrixEvent[], [] as MatrixEvent[]], - ); - } else { - // When `experimentalThreadSupport` is disabled treat all events as timelineEvents - return [events as MatrixEvent[], [] as MatrixEvent[]]; - } - } - - /** - * Given some events, find the IDs of all the thread roots that are referred to by them. - */ - private findThreadRoots(events: MatrixEvent[]): Set<string> { - const threadRoots = new Set<string>(); - for (const event of events) { - if (event.isRelation(THREAD_RELATION_TYPE.name)) { - threadRoots.add(event.relationEventId ?? ""); - } - } - return threadRoots; - } - - /** - * Add a receipt event to the room. - * @param event - The m.receipt event. - * @param synthetic - True if this event is implicit. - */ - public addReceipt(event: MatrixEvent, synthetic = false): void { - const content = event.getContent<ReceiptContent>(); - Object.keys(content).forEach((eventId: string) => { - Object.keys(content[eventId]).forEach((receiptType: ReceiptType | string) => { - Object.keys(content[eventId][receiptType]).forEach((userId: string) => { - const receipt = content[eventId][receiptType][userId] as Receipt; - const receiptForMainTimeline = !receipt.thread_id || receipt.thread_id === MAIN_ROOM_TIMELINE; - const receiptDestination: Thread | this | undefined = receiptForMainTimeline - ? this - : this.threads.get(receipt.thread_id ?? ""); - - if (receiptDestination) { - receiptDestination.addReceiptToStructure( - eventId, - receiptType as 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 - */ - public addEphemeralEvents(events: MatrixEvent[]): void { - for (const event of events) { - if (event.getType() === EventType.Typing) { - this.currentState.setTypingEvent(event); - } else if (event.getType() === 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. - */ - public removeEvents(eventIds: string[]): void { - 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 - */ - public removeEvent(eventId: string): boolean { - 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} - */ - public recalculate(): void { - // set fake stripped state events if this is an invite room so logic remains - // consistent elsewhere. - const membershipEvent = this.currentState.getStateEvents(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 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 = normalize(this.name); - this.summary = new 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 - */ - public addTags(event: MatrixEvent): void { - // 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 - */ - public addAccountData(events: MatrixEvent[]): void { - 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 - */ - public getAccountData(type: EventType | string): MatrixEvent | undefined { - 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. - */ - public maySendMessage(): boolean { - return ( - this.getMyMembership() === "join" && - (this.client.isRoomEncrypted(this.roomId) - ? this.currentState.maySendEvent(EventType.RoomMessageEncrypted, this.myUserId) - : this.currentState.maySendEvent(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. - */ - public canInvite(userId: string): boolean { - let canInvite = this.getMyMembership() === "join"; - const powerLevelsEvent = this.currentState.getStateEvents(EventType.RoomPowerLevels, ""); - const powerLevels = powerLevelsEvent && powerLevelsEvent.getContent(); - const me = this.getMember(userId); - if (powerLevels && me && powerLevels.invite > me.powerLevel) { - canInvite = false; - } - return canInvite; - } - - /** - * Returns the join rule based on the m.room.join_rule state event, defaulting to `invite`. - * @returns the join_rule applied to this room - */ - public getJoinRule(): JoinRule { - 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 - */ - public getHistoryVisibility(): HistoryVisibility { - 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 - */ - public getGuestAccess(): GuestAccess { - 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. - */ - public getType(): RoomType | string | undefined { - const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, ""); - if (!createEvent) { - if (!this.getTypeWarning) { - logger.warn("[getType] Room " + this.roomId + " does not have an m.room.create event"); - this.getTypeWarning = true; - } - return undefined; - } - return createEvent.getContent()[RoomCreateTypeField]; - } - - /** - * Returns whether the room is a space-room as defined by MSC1772. - * @returns true if the room's type is RoomType.Space - */ - public isSpaceRoom(): boolean { - return this.getType() === RoomType.Space; - } - - /** - * Returns whether the room is a call-room as defined by MSC3417. - * @returns true if the room's type is RoomType.UnstableCall - */ - public isCallRoom(): boolean { - return this.getType() === RoomType.UnstableCall; - } - - /** - * Returns whether the room is a video room. - * @returns true if the room's type is RoomType.ElementVideo - */ - public isElementVideoRoom(): boolean { - return this.getType() === 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). - */ - public findPredecessor( - msc3946ProcessDynamicPredecessor = false, - ): { roomId: string; eventId?: string; viaServers?: string[] } | null { - const currentState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); - if (!currentState) { - return null; - } - return currentState.findPredecessor(msc3946ProcessDynamicPredecessor); - } - - private roomNameGenerator(state: RoomNameState): string { - 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. - */ - private calculateRoomName(userId: string, ignoreRoomNameEvent = false): string { - if (!ignoreRoomNameEvent) { - // check for an alias, if any. for now, assume first alias is the - // official one. - const mRoomName = this.currentState.getStateEvents(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: string[] = []; - const mFunctionalMembers = this.currentState.getStateEvents(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: string[] = []; - 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) => 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(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: string | undefined; - 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. - */ - private applyNewVisibilityEvent(event: MatrixEvent): void { - 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 = - (EVENT_VISIBILITY_CHANGE_TYPE.name && - this.currentState.maySendStateEvent(EVENT_VISIBILITY_CHANGE_TYPE.name, userId)) || - (EVENT_VISIBILITY_CHANGE_TYPE.altName && - this.currentState.maySendStateEvent(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); - } - - private redactVisibilityChangeEvent(event: MatrixEvent): void { - // 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. - */ - private applyPendingVisibilityEvents(event: MatrixEvent): void { - 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 - */ - public getOldestThreadedReceiptTs(): number { - 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). - */ - public getLastUnthreadedReceiptFor(userId: string): Receipt | undefined { - 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 - */ - public fixupNotifications(userId: string): void { - 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 -const ALLOWED_TRANSITIONS: Record<EventStatus, EventStatus[]> = { - [EventStatus.ENCRYPTING]: [EventStatus.SENDING, EventStatus.NOT_SENT, EventStatus.CANCELLED], - [EventStatus.SENDING]: [EventStatus.ENCRYPTING, EventStatus.QUEUED, EventStatus.NOT_SENT, EventStatus.SENT], - [EventStatus.QUEUED]: [EventStatus.SENDING, EventStatus.NOT_SENT, EventStatus.CANCELLED], - [EventStatus.SENT]: [], - [EventStatus.NOT_SENT]: [EventStatus.SENDING, EventStatus.QUEUED, EventStatus.CANCELLED], - [EventStatus.CANCELLED]: [], -}; - -export enum RoomNameType { - EmptyRoom, - Generated, - Actual, -} - -export interface EmptyRoomNameState { - type: RoomNameType.EmptyRoom; - oldName?: string; -} - -export interface GeneratedRoomNameState { - type: RoomNameType.Generated; - subtype?: "Inviting"; - names: string[]; - count: number; -} - -export interface ActualRoomNameState { - type: RoomNameType.Actual; - name: string; -} - -export type RoomNameState = EmptyRoomNameState | GeneratedRoomNameState | ActualRoomNameState; - -// Can be overriden by IMatrixClientCreateOpts::memberNamesToRoomNameFn -function memberNamesToRoomName(names: string[], count: number): string { - 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`; - } - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/search-result.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/search-result.ts deleted file mode 100644 index 21192a6..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/search-result.ts +++ /dev/null @@ -1,54 +0,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. -*/ - -import { EventContext } from "./event-context"; -import { EventMapper } from "../event-mapper"; -import { IResultContext, ISearchResult } from "../@types/search"; - -export class SearchResult { - /** - * Create a SearchResponse from the response to /search - */ - - public static fromJson(jsonObj: ISearchResult, eventMapper: EventMapper): SearchResult { - const jsonContext = jsonObj.context || ({} as IResultContext); - let eventsBefore = (jsonContext.events_before || []).map(eventMapper); - let eventsAfter = (jsonContext.events_after || []).map(eventMapper); - - const context = new 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 - */ - public constructor(public readonly rank: number, public readonly context: EventContext) {} -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/thread.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/thread.ts deleted file mode 100644 index 9a4ead3..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/thread.ts +++ /dev/null @@ -1,669 +0,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. -*/ - -import { Optional } from "matrix-events-sdk"; - -import { MatrixClient, PendingEventOrdering } from "../client"; -import { TypedReEmitter } from "../ReEmitter"; -import { RelationType } from "../@types/event"; -import { IThreadBundledRelationship, MatrixEvent, MatrixEventEvent } from "./event"; -import { Direction, EventTimeline } from "./event-timeline"; -import { EventTimelineSet, EventTimelineSetHandlerMap } from "./event-timeline-set"; -import { NotificationCountType, Room, RoomEvent } from "./room"; -import { RoomState } from "./room-state"; -import { ServerControlledNamespacedValue } from "../NamespacedValue"; -import { logger } from "../logger"; -import { ReadReceipt } from "./read-receipt"; -import { CachedReceiptStructure, ReceiptType } from "../@types/read_receipts"; - -export enum ThreadEvent { - New = "Thread.new", - Update = "Thread.update", - NewReply = "Thread.newReply", - ViewThread = "Thread.viewThread", - Delete = "Thread.delete", -} - -type EmittedEvents = Exclude<ThreadEvent, ThreadEvent.New> | RoomEvent.Timeline | RoomEvent.TimelineReset; - -export type EventHandlerMap = { - [ThreadEvent.Update]: (thread: Thread) => void; - [ThreadEvent.NewReply]: (thread: Thread, event: MatrixEvent) => void; - [ThreadEvent.ViewThread]: () => void; - [ThreadEvent.Delete]: (thread: Thread) => void; -} & EventTimelineSetHandlerMap; - -interface IThreadOpts { - room: Room; - client: MatrixClient; - pendingEventOrdering?: PendingEventOrdering; - receipts?: CachedReceiptStructure[]; -} - -export enum FeatureSupport { - None = 0, - Experimental = 1, - Stable = 2, -} - -export function determineFeatureSupport(stable: boolean, unstable: boolean): FeatureSupport { - if (stable) { - return FeatureSupport.Stable; - } else if (unstable) { - return FeatureSupport.Experimental; - } else { - return FeatureSupport.None; - } -} - -export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> { - public static hasServerSideSupport = FeatureSupport.None; - public static hasServerSideListSupport = FeatureSupport.None; - public static hasServerSideFwdPaginationSupport = FeatureSupport.None; - - /** - * A reference to all the events ID at the bottom of the threads - */ - public readonly timelineSet: EventTimelineSet; - public timeline: MatrixEvent[] = []; - - private _currentUserParticipated = false; - - private reEmitter: TypedReEmitter<EmittedEvents, EventHandlerMap>; - - private lastEvent: MatrixEvent | undefined; - private replyCount = 0; - private lastPendingEvent: MatrixEvent | undefined; - private pendingReplyCount = 0; - - public readonly room: Room; - public readonly client: MatrixClient; - private readonly pendingEventOrdering: PendingEventOrdering; - - public initialEventsFetched = !Thread.hasServerSideSupport; - /** - * An array of events to add to the timeline once the thread has been initialised - * with server suppport. - */ - public replayEvents: MatrixEvent[] | null = []; - - public constructor(public readonly id: string, public rootEvent: MatrixEvent | undefined, opts: IThreadOpts) { - super(); - - 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 ?? PendingEventOrdering.Chronological; - this.timelineSet = new EventTimelineSet( - this.room, - { - timelineSupport: true, - pendingEvents: true, - }, - this.client, - this, - ); - this.reEmitter = new TypedReEmitter(this); - - this.reEmitter.reEmit(this.timelineSet, [RoomEvent.Timeline, RoomEvent.TimelineReset]); - - this.room.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); - this.room.on(RoomEvent.Redaction, this.onRedaction); - this.room.on(RoomEvent.LocalEchoUpdated, this.onLocalEcho); - this.timelineSet.on(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); - } - - private async fetchRootEvent(): Promise<void> { - 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.error("Failed to fetch thread root to construct thread with", e); - } - await this.processEvent(this.rootEvent); - } - - public static setServerSideSupport(status: FeatureSupport): void { - 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); - } - } - - public static setServerSideListSupport(status: FeatureSupport): void { - Thread.hasServerSideListSupport = status; - } - - public static setServerSideFwdPaginationSupport(status: FeatureSupport): void { - Thread.hasServerSideFwdPaginationSupport = status; - } - - private onBeforeRedaction = (event: MatrixEvent, redaction: MatrixEvent): void => { - 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); - } - }; - - private onRedaction = async (event: MatrixEvent): Promise<void> => { - 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(); - } - }; - - private onTimelineEvent = ( - event: MatrixEvent, - room: Room | undefined, - toStartOfTimeline: boolean | undefined, - ): void => { - // Add a synthesized receipt when paginating forward in the timeline - if (!toStartOfTimeline) { - room!.addLocalEchoReceipt(event.getSender()!, event, ReceiptType.Read); - } - this.onEcho(event, toStartOfTimeline ?? false); - }; - - private onLocalEcho = (event: MatrixEvent): void => { - this.onEcho(event, false); - }; - - private onEcho = async (event: MatrixEvent, toStartOfTimeline: boolean): Promise<void> => { - 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); - }; - - public get roomState(): RoomState { - return this.room.getLiveTimeline().getState(EventTimeline.FORWARDS)!; - } - - private addEventToTimeline(event: MatrixEvent, toStartOfTimeline: boolean): void { - if (!this.findEventById(event.getId()!)) { - this.timelineSet.addEventToTimeline(event, this.liveTimeline, { - toStartOfTimeline, - fromCache: false, - roomState: this.roomState, - }); - this.timeline = this.events; - } - } - - public addEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void { - 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. - */ - public async addEvent(event: MatrixEvent, toStartOfTimeline: boolean, emit = true): Promise<void> { - 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(RelationType.Annotation) || event.isRelation(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 { - 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(); - } - } - - public async processEvent(event: Optional<MatrixEvent>): Promise<void> { - 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 - */ - private processReceipts(receipts: CachedReceiptStructure[] = []): void { - for (const { eventId, receiptType, userId, receipt, synthetic } of receipts) { - this.addReceiptToStructure(eventId, receiptType as ReceiptType, userId, receipt, synthetic); - } - } - - private getRootEventBundledRelationship(rootEvent = this.rootEvent): IThreadBundledRelationship | undefined { - return rootEvent?.getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name); - } - - private async processRootEvent(): Promise<void> { - 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({ - ...bundledRelationship.latest_event, - room_id: this.roomId, - }); - this.updatePendingReplyCount(); - await this.processEvent(this.lastEvent); - } - } - - private updatePendingReplyCount(): void { - const unfilteredPendingEvents = - this.pendingEventOrdering === 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. - */ - public async resetLiveTimeline( - backPaginationToken?: string | null, - forwardPaginationToken?: string | null, - ): Promise<void> { - 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: string | undefined; - let oldForward: string | undefined; - if (backPaginationToken) { - const res = await this.client.createMessagesRequest(this.roomId, backPaginationToken, 1, Direction.Forward); - newBackward = res.end; - } - if (forwardPaginationToken) { - const res = await this.client.createMessagesRequest( - this.roomId, - forwardPaginationToken, - 1, - 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(Direction.Forward) === forwardPaginationToken) { - oldLive.setPaginationToken(oldForward ?? null, Direction.Forward); - } - if (backPaginationToken && newLive.getPaginationToken(Direction.Backward) === backPaginationToken) { - newLive.setPaginationToken(newBackward ?? null, Direction.Backward); - } - } - - private async updateThreadMetadata(): Promise<void> { - 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, 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(RoomEvent.TimelineReset, this.room, this.timelineSet, true); - } catch (e) { - 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 - private async fetchEditsWhereNeeded(...events: MatrixEvent[]): Promise<unknown> { - return Promise.all( - events - .filter((e) => e.isEncrypted()) - .map((event: MatrixEvent) => { - if (event.isRelation()) return; // skip - relations don't get edits - return this.client - .relations(this.roomId, event.getId()!, RelationType.Replace, event.getType(), { - limit: 1, - }) - .then((relations) => { - if (relations.events.length) { - event.makeReplaced(relations.events[0]); - } - }) - .catch((e) => { - logger.error("Failed to load edits for encrypted thread event", e); - }); - }), - ); - } - - public setEventMetadata(event: Optional<MatrixEvent>): void { - if (event) { - EventTimeline.setEventMetadata(event, this.roomState, false); - event.setThread(this); - } - } - - public clearEventMetadata(event: Optional<MatrixEvent>): void { - if (event) { - event.setThread(undefined); - delete event.event?.unsigned?.["m.relations"]?.[THREAD_RELATION_TYPE.name]; - } - } - - /** - * Finds an event by ID in the current thread - */ - public findEventById(eventId: string): MatrixEvent | undefined { - return this.timelineSet.findEventById(eventId); - } - - /** - * Return last reply to the thread, if known. - */ - public lastReply(matches: (ev: MatrixEvent) => boolean = (): boolean => true): MatrixEvent | null { - for (let i = this.timeline.length - 1; i >= 0; i--) { - const event = this.timeline[i]; - if (matches(event)) { - return event; - } - } - return null; - } - - public get roomId(): string { - 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 - */ - public get length(): number { - 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. - */ - public get replyToEvent(): Optional<MatrixEvent> { - return this.lastPendingEvent ?? this.lastEvent ?? this.lastReply(); - } - - public get events(): MatrixEvent[] { - return this.liveTimeline.getEvents(); - } - - public has(eventId: string): boolean { - return this.timelineSet.findEventById(eventId) instanceof MatrixEvent; - } - - public get hasCurrentUserParticipated(): boolean { - return this._currentUserParticipated; - } - - public get liveTimeline(): EventTimeline { - return this.timelineSet.getLiveTimeline(); - } - - public getUnfilteredTimelineSet(): EventTimelineSet { - return this.timelineSet; - } - - public addReceipt(event: MatrixEvent, synthetic: boolean): void { - 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. - */ - public getEventReadUpTo(userId: string, ignoreSynthesized?: boolean): string | null { - 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. - */ - public hasUserReadEvent(userId: string, eventId: string): boolean { - 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); - } - - public setUnread(type: NotificationCountType, count: number): void { - return this.room.setThreadUnreadNotificationCount(this.id, type, count); - } -} - -export const FILTER_RELATED_BY_SENDERS = new ServerControlledNamespacedValue( - "related_by_senders", - "io.element.relation_senders", -); -export const FILTER_RELATED_BY_REL_TYPES = new ServerControlledNamespacedValue( - "related_by_rel_types", - "io.element.relation_types", -); -export const THREAD_RELATION_TYPE = new ServerControlledNamespacedValue("m.thread", "io.element.thread"); - -export enum ThreadFilterType { - "My", - "All", -} - -export function threadFilterTypeToFilter(type: ThreadFilterType | null): "all" | "participated" { - switch (type) { - case ThreadFilterType.My: - return "participated"; - default: - return "all"; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/typed-event-emitter.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/typed-event-emitter.ts deleted file mode 100644 index 3cfe602..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/typed-event-emitter.ts +++ /dev/null @@ -1,114 +0,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. -*/ - -// eslint-disable-next-line no-restricted-imports -import { EventEmitter } from "events"; - -export enum EventEmitterEvents { - NewListener = "newListener", - RemoveListener = "removeListener", - Error = "error", -} - -type AnyListener = (...args: any) => any; -export type ListenerMap<E extends string> = { [eventName in E]: AnyListener }; -type EventEmitterEventListener = (eventName: string, listener: AnyListener) => void; -type EventEmitterErrorListener = (error: Error) => void; - -export type Listener<E extends string, A extends ListenerMap<E>, T extends E | EventEmitterEvents> = T extends E - ? A[T] - : T extends EventEmitterEvents - ? EventEmitterErrorListener - : EventEmitterEventListener; - -/** - * 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. - */ -export class TypedEventEmitter< - Events extends string, - Arguments extends ListenerMap<Events>, - SuperclassArguments extends ListenerMap<any> = Arguments, -> extends EventEmitter { - public addListener<T extends Events | EventEmitterEvents>( - event: T, - listener: Listener<Events, Arguments, T>, - ): this { - return super.addListener(event, listener); - } - - public emit<T extends Events>(event: T, ...args: Parameters<SuperclassArguments[T]>): boolean; - public emit<T extends Events>(event: T, ...args: Parameters<Arguments[T]>): boolean; - public emit<T extends Events>(event: T, ...args: any[]): boolean { - return super.emit(event, ...args); - } - - public eventNames(): (Events | EventEmitterEvents)[] { - return super.eventNames() as Array<Events | EventEmitterEvents>; - } - - public listenerCount(event: Events | EventEmitterEvents): number { - return super.listenerCount(event); - } - - public listeners(event: Events | EventEmitterEvents): ReturnType<EventEmitter["listeners"]> { - return super.listeners(event); - } - - public off<T extends Events | EventEmitterEvents>(event: T, listener: Listener<Events, Arguments, T>): this { - return super.off(event, listener); - } - - public on<T extends Events | EventEmitterEvents>(event: T, listener: Listener<Events, Arguments, T>): this { - return super.on(event, listener); - } - - public once<T extends Events | EventEmitterEvents>(event: T, listener: Listener<Events, Arguments, T>): this { - return super.once(event, listener); - } - - public prependListener<T extends Events | EventEmitterEvents>( - event: T, - listener: Listener<Events, Arguments, T>, - ): this { - return super.prependListener(event, listener); - } - - public prependOnceListener<T extends Events | EventEmitterEvents>( - event: T, - listener: Listener<Events, Arguments, T>, - ): this { - return super.prependOnceListener(event, listener); - } - - public removeAllListeners(event?: Events | EventEmitterEvents): this { - return super.removeAllListeners(event); - } - - public removeListener<T extends Events | EventEmitterEvents>( - event: T, - listener: Listener<Events, Arguments, T>, - ): this { - return super.removeListener(event, listener); - } - - public rawListeners(event: Events | EventEmitterEvents): ReturnType<EventEmitter["rawListeners"]> { - return super.rawListeners(event); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/user.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/user.ts deleted file mode 100644 index 054a174..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/user.ts +++ /dev/null @@ -1,281 +0,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. -*/ - -import { MatrixEvent } from "./event"; -import { TypedEventEmitter } from "./typed-event-emitter"; - -export enum UserEvent { - DisplayName = "User.displayName", - AvatarUrl = "User.avatarUrl", - Presence = "User.presence", - CurrentlyActive = "User.currentlyActive", - LastPresenceTs = "User.lastPresenceTs", -} - -export type UserEventHandlerMap = { - /** - * Fires whenever any user's display name changes. - * @param event - The matrix event which caused this event to fire. - * @param user - The user whose User.displayName changed. - * @example - * ``` - * matrixClient.on("User.displayName", function(event, user){ - * var newName = user.displayName; - * }); - * ``` - */ - [UserEvent.DisplayName]: (event: MatrixEvent | undefined, user: User) => void; - /** - * Fires whenever any user's avatar URL changes. - * @param event - The matrix event which caused this event to fire. - * @param user - The user whose User.avatarUrl changed. - * @example - * ``` - * matrixClient.on("User.avatarUrl", function(event, user){ - * var newUrl = user.avatarUrl; - * }); - * ``` - */ - [UserEvent.AvatarUrl]: (event: MatrixEvent | undefined, user: User) => void; - /** - * Fires whenever any user's presence changes. - * @param event - The matrix event which caused this event to fire. - * @param user - The user whose User.presence changed. - * @example - * ``` - * matrixClient.on("User.presence", function(event, user){ - * var newPresence = user.presence; - * }); - * ``` - */ - [UserEvent.Presence]: (event: MatrixEvent | undefined, user: User) => void; - /** - * Fires whenever any user's currentlyActive changes. - * @param event - The matrix event which caused this event to fire. - * @param user - The user whose User.currentlyActive changed. - * @example - * ``` - * matrixClient.on("User.currentlyActive", function(event, user){ - * var newCurrentlyActive = user.currentlyActive; - * }); - * ``` - */ - [UserEvent.CurrentlyActive]: (event: MatrixEvent | undefined, user: User) => void; - /** - * Fires whenever any user's lastPresenceTs changes, - * ie. whenever any presence event is received for a user. - * @param event - The matrix event which caused this event to fire. - * @param user - The user whose User.lastPresenceTs changed. - * @example - * ``` - * matrixClient.on("User.lastPresenceTs", function(event, user){ - * var newlastPresenceTs = user.lastPresenceTs; - * }); - * ``` - */ - [UserEvent.LastPresenceTs]: (event: MatrixEvent | undefined, user: User) => void; -}; - -export class User extends TypedEventEmitter<UserEvent, UserEventHandlerMap> { - private modified = -1; - - /** - * The 'displayname' of the user if known. - * @privateRemarks - * Should be read-only - */ - public displayName?: string; - public rawDisplayName?: string; - /** - * The 'avatar_url' of the user if known. - * @privateRemarks - * Should be read-only - */ - public avatarUrl?: string; - /** - * The presence status message if known. - * @privateRemarks - * Should be read-only - */ - public presenceStatusMsg?: string; - /** - * The presence enum if known. - * @privateRemarks - * Should be read-only - */ - public 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 - */ - public 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 - */ - public 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 - */ - public currentlyActive = false; - /** - * The events describing this user. - * @privateRemarks - * Should be read-only - */ - public events: { - /** The m.presence event for this user. */ - presence?: MatrixEvent; - profile?: MatrixEvent; - } = {}; - - /** - * 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. - */ - public constructor(public readonly userId: string) { - super(); - 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} - */ - public setPresenceEvent(event: MatrixEvent): void { - if (event.getType() !== "m.presence") { - return; - } - const firstFire = this.events.presence === null; - this.events.presence = event; - - const eventsToFire: UserEvent[] = []; - 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. - */ - public setDisplayName(name: string): void { - 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. - */ - public setRawDisplayName(name?: string): void { - 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. - */ - public setAvatarUrl(url?: string): void { - const oldUrl = this.avatarUrl; - this.avatarUrl = url; - if (url !== oldUrl) { - this.updateModifiedTime(); - } - } - - /** - * Update the last modified time to the current time. - */ - private updateModifiedTime(): void { - 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 - */ - public getLastModifiedTime(): number { - 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 - */ - public getLastActiveTs(): number { - return this.lastPresenceTs - this.lastActiveAgo; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/pushprocessor.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/pushprocessor.ts deleted file mode 100644 index 78d26fe..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/pushprocessor.ts +++ /dev/null @@ -1,770 +0,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. -*/ - -import { deepCompare, escapeRegExp, globToRegexp, isNullOrUndefined } from "./utils"; -import { logger } from "./logger"; -import { MatrixClient } from "./client"; -import { MatrixEvent } from "./models/event"; -import { - ConditionKind, - IAnnotatedPushRule, - ICallStartedCondition, - ICallStartedPrefixCondition, - IContainsDisplayNameCondition, - IEventMatchCondition, - IEventPropertyIsCondition, - IEventPropertyContainsCondition, - IPushRule, - IPushRules, - IRoomMemberCountCondition, - ISenderNotificationPermissionCondition, - PushRuleAction, - PushRuleActionName, - PushRuleCondition, - PushRuleKind, - PushRuleSet, - RuleId, - TweakName, -} from "./@types/PushRules"; -import { EventType } from "./@types/event"; - -const RULEKINDS_IN_ORDER = [ - PushRuleKind.Override, - PushRuleKind.ContentSpecific, - PushRuleKind.RoomSpecific, - PushRuleKind.SenderSpecific, - 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: IPushRule[] = [ - { - // For homeservers which don't support MSC2153 yet - rule_id: ".m.rule.reaction", - default: true, - enabled: true, - conditions: [ - { - kind: ConditionKind.EventMatch, - key: "type", - pattern: "m.reaction", - }, - ], - actions: [PushRuleActionName.DontNotify], - }, - { - rule_id: RuleId.IsUserMention, - default: true, - enabled: true, - conditions: [ - { - kind: ConditionKind.EventPropertyContains, - key: "content.org\\.matrix\\.msc3952\\.mentions.user_ids", - value: "", // The user ID is dynamically added in rewriteDefaultRules. - }, - ], - actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight }], - }, - { - rule_id: RuleId.IsRoomMention, - default: true, - enabled: true, - conditions: [ - { - kind: ConditionKind.EventPropertyIs, - key: "content.org\\.matrix\\.msc3952\\.mentions.room", - value: true, - }, - { - kind: ConditionKind.SenderNotificationPermission, - key: "room", - }, - ], - actions: [PushRuleActionName.Notify, { set_tweak: 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: ConditionKind.EventMatch, - key: "type", - pattern: EventType.RoomServerAcl, - }, - { - kind: ConditionKind.EventMatch, - key: "state_key", - pattern: "", - }, - ], - actions: [], - }, -]; - -const DEFAULT_UNDERRIDE_RULES: IPushRule[] = [ - { - // For homeservers which don't support MSC3914 yet - rule_id: ".org.matrix.msc3914.rule.room.call", - default: true, - enabled: true, - conditions: [ - { - kind: ConditionKind.EventMatch, - key: "type", - pattern: "org.matrix.msc3401.call", - }, - { - kind: ConditionKind.CallStarted, - }, - ], - actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Sound, value: "default" }], - }, -]; - -export interface IActionsObject { - /** Whether this event should notify the user or not. */ - notify: boolean; - /** How this event should be notified. */ - tweaks: Partial<Record<TweakName, any>>; -} - -export class PushProcessor { - /** - * Construct a Push Processor. - * @param client - The Matrix client object to use - */ - public constructor(private readonly client: MatrixClient) {} - - /** - * Maps the original key from the push rules to a list of property names - * after unescaping. - */ - private readonly parsedKeys = new Map<string, string[]>(); - - /** - * 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 - */ - public static actionListToActionsObject(actionList: PushRuleAction[]): IActionsObject { - const actionObj: IActionsObject = { notify: false, tweaks: {} }; - for (const action of actionList) { - if (action === 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 - */ - public static rewriteDefaultRules(incomingRules: IPushRules, userId: string | undefined = undefined): IPushRules { - let newRules: IPushRules = 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 = {} as IPushRules; - if (!newRules.global) newRules.global = {} as PushRuleSet; - 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: IPushRule; - if (originalOverride.rule_id === 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.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.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. - */ - public updateCachedPushRuleKeys(newRules: IPushRules): void { - // These lines are mostly to make the tests happy. We shouldn't run into these - // properties missing in practice. - if (!newRules) newRules = {} as IPushRules; - if (!newRules.global) newRules.global = {} as PushRuleSet; - 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 !== 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)); - } - - private static cachedGlobToRegex: Record<string, RegExp> = {}; // $glob: RegExp - - private matchingRuleFromKindSet(ev: MatrixEvent, kindset: PushRuleSet): IAnnotatedPushRule | null { - 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 { - ...rule, - kind, - }; - } - } - } - return null; - } - - private templateRuleToRaw( - kind: PushRuleKind, - tprule: IPushRule, - ): Pick<IPushRule, "rule_id" | "actions" | "conditions"> | null { - const rawrule: Pick<IPushRule, "rule_id" | "actions" | "conditions"> = { - rule_id: tprule.rule_id, - actions: tprule.actions, - conditions: [], - }; - switch (kind) { - case PushRuleKind.Underride: - case PushRuleKind.Override: - rawrule.conditions = tprule.conditions; - break; - case PushRuleKind.RoomSpecific: - if (!tprule.rule_id) { - return null; - } - rawrule.conditions!.push({ - kind: ConditionKind.EventMatch, - key: "room_id", - value: tprule.rule_id, - }); - break; - case PushRuleKind.SenderSpecific: - if (!tprule.rule_id) { - return null; - } - rawrule.conditions!.push({ - kind: ConditionKind.EventMatch, - key: "user_id", - value: tprule.rule_id, - }); - break; - case PushRuleKind.ContentSpecific: - if (!tprule.pattern) { - return null; - } - rawrule.conditions!.push({ - kind: ConditionKind.EventMatch, - key: "content.body", - pattern: tprule.pattern, - }); - break; - } - return rawrule; - } - - private eventFulfillsCondition(cond: PushRuleCondition, ev: MatrixEvent): boolean { - switch (cond.kind) { - case ConditionKind.EventMatch: - return this.eventFulfillsEventMatchCondition(cond, ev); - case ConditionKind.EventPropertyIs: - return this.eventFulfillsEventPropertyIsCondition(cond, ev); - case ConditionKind.EventPropertyContains: - return this.eventFulfillsEventPropertyContains(cond, ev); - case ConditionKind.ContainsDisplayName: - return this.eventFulfillsDisplayNameCondition(cond, ev); - case ConditionKind.RoomMemberCount: - return this.eventFulfillsRoomMemberCountCondition(cond, ev); - case ConditionKind.SenderNotificationPermission: - return this.eventFulfillsSenderNotifPermCondition(cond, ev); - case ConditionKind.CallStarted: - case 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; - } - - private eventFulfillsSenderNotifPermCondition( - cond: ISenderNotificationPermissionCondition, - ev: MatrixEvent, - ): boolean { - 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()!); - } - - private eventFulfillsRoomMemberCountCondition(cond: IRoomMemberCountCondition, ev: MatrixEvent): boolean { - 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; - } - } - - private eventFulfillsDisplayNameCondition(cond: IContainsDisplayNameCondition, ev: MatrixEvent): boolean { - 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)" + 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. - */ - private eventFulfillsEventMatchCondition(cond: IEventMatchCondition, ev: MatrixEvent): boolean { - 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. - */ - private eventFulfillsEventPropertyIsCondition(cond: IEventPropertyIsCondition, ev: MatrixEvent): boolean { - 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. - */ - private eventFulfillsEventPropertyContains(cond: IEventPropertyContainsCondition, ev: MatrixEvent): boolean { - 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); - } - - private eventFulfillsCallStartedCondition( - _cond: ICallStartedCondition | ICallStartedPrefixCondition, - ev: MatrixEvent, - ): boolean { - // 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"] || - deepCompare(ev.getPrevContent(), {})) - ); - } - - private createCachedRegex(prefix: string, glob: string, suffix: string): RegExp { - if (PushProcessor.cachedGlobToRegex[glob]) { - return PushProcessor.cachedGlobToRegex[glob]; - } - PushProcessor.cachedGlobToRegex[glob] = new RegExp( - prefix + 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 - */ - public static partsForDottedKey(str: string): string[] { - 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. - */ - private valueForDottedKey(key: string, ev: MatrixEvent): any { - // 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: any; - - // 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 (isNullOrUndefined(val)) { - return undefined; - } - - const thisPart = parts[currentIndex]; - val = val[thisPart]; - } - return val; - } - - private matchingRuleForEventWithRulesets(ev: MatrixEvent, rulesets?: IPushRules): IAnnotatedPushRule | null { - if (!rulesets) { - return null; - } - if (ev.getSender() === this.client.credentials.userId) { - return null; - } - - return this.matchingRuleFromKindSet(ev, rulesets.global); - } - - private pushActionsForEventAndRulesets(ev: MatrixEvent, rulesets?: IPushRules): IActionsObject { - const rule = this.matchingRuleForEventWithRulesets(ev, rulesets); - if (!rule) { - return {} as IActionsObject; - } - - 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 == PushRuleKind.ContentSpecific; - } - - return actionObj; - } - - public ruleMatchesEvent(rule: Partial<IPushRule> & Pick<IPushRule, "conditions">, ev: MatrixEvent): boolean { - // 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 === RuleId.ContainsUserName || - rule.rule_id === RuleId.ContainsDisplayName || - rule.rule_id === RuleId.AtRoomNotification) - ) { - return false; - } - - return !rule.conditions?.some((cond) => !this.eventFulfillsCondition(cond, ev)); - } - - /** - * Get the user's push actions for the given event - */ - public actionsForEvent(ev: MatrixEvent): IActionsObject { - 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 - */ - public getPushRuleById(ruleId: string): IPushRule | null { - 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 - */ - public getPushRuleAndKindById(ruleId: string): { rule: IPushRule; kind: PushRuleKind } | null { - for (const scope of ["global"] as const) { - 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; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/randomstring.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/randomstring.ts deleted file mode 100644 index 0ed46fb..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/randomstring.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -const LOWERCASE = "abcdefghijklmnopqrstuvwxyz"; -const UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; -const DIGITS = "0123456789"; - -export function randomString(len: number): string { - return randomStringFrom(len, UPPERCASE + LOWERCASE + DIGITS); -} - -export function randomLowercaseString(len: number): string { - return randomStringFrom(len, LOWERCASE); -} - -export function randomUppercaseString(len: number): string { - return randomStringFrom(len, UPPERCASE); -} - -function randomStringFrom(len: number, chars: string): string { - let ret = ""; - - for (let i = 0; i < len; ++i) { - ret += chars.charAt(Math.floor(Math.random() * chars.length)); - } - - return ret; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/realtime-callbacks.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/realtime-callbacks.ts deleted file mode 100644 index 1b03a57..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/realtime-callbacks.ts +++ /dev/null @@ -1,191 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/* A re-implementation of the javascript callback functions (setTimeout, - * clearTimeout; setInterval and clearInterval are not yet implemented) which - * try to improve handling of large clock jumps (as seen when - * suspending/resuming the system). - * - * In particular, if a timeout would have fired while the system was suspended, - * it will instead fire as soon as possible after resume. - */ - -import { logger } from "./logger"; - -// we schedule a callback at least this often, to check if we've missed out on -// some wall-clock time due to being suspended. -const TIMER_CHECK_PERIOD_MS = 1000; - -// counter, for making up ids to return from setTimeout -let count = 0; - -// the key for our callback with the real global.setTimeout -let realCallbackKey: NodeJS.Timeout | number; - -type Callback = { - runAt: number; - func: (...params: any[]) => void; - params: any[]; - key: number; -}; - -// a sorted list of the callbacks to be run. -// each is an object with keys [runAt, func, params, key]. -const callbackList: Callback[] = []; - -// var debuglog = logger.log.bind(logger); -/* istanbul ignore next */ -const debuglog = function (...params: any[]): void {}; - -/** - * 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. - */ -export function setTimeout(func: (...params: any[]) => void, delayMs: number, ...params: any[]): number { - 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 - */ -export function clearTimeout(key: number): void { - if (callbackList.length === 0) { - return; - } - - // remove the element from the list - let i: number; - 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(): void { - if (realCallbackKey) { - global.clearTimeout(realCallbackKey as NodeJS.Timeout); - } - - 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(): void { - const timestamp = Date.now(); - debuglog("runCallbacks: now:", timestamp); - - // get the list of things to call - const callbacksToRun: Callback[] = []; - // 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.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<T>(array: T[], func: (v: T) => number): number { - // 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; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/MSC3906Rendezvous.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/MSC3906Rendezvous.ts deleted file mode 100644 index f431c83..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/MSC3906Rendezvous.ts +++ /dev/null @@ -1,264 +0,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. -*/ - -import { UnstableValue } from "matrix-events-sdk"; - -import { RendezvousChannel, RendezvousFailureListener, RendezvousFailureReason, RendezvousIntent } from "."; -import { MatrixClient } from "../client"; -import { CrossSigningInfo } from "../crypto/CrossSigning"; -import { DeviceInfo } from "../crypto/deviceinfo"; -import { buildFeatureSupportMap, Feature, ServerSupport } from "../feature"; -import { logger } from "../logger"; -import { sleep } from "../utils"; - -enum PayloadType { - Start = "m.login.start", - Finish = "m.login.finish", - Progress = "m.login.progress", -} - -enum Outcome { - Success = "success", - Failure = "failure", - Verified = "verified", - Declined = "declined", - Unsupported = "unsupported", -} - -export interface MSC3906RendezvousPayload { - type: PayloadType; - intent?: RendezvousIntent; - outcome?: Outcome; - device_id?: string; - device_key?: string; - verifying_device_id?: string; - verifying_device_key?: string; - master_key?: string; - protocols?: string[]; - protocol?: string; - login_token?: string; - homeserver?: string; -} - -const LOGIN_TOKEN_PROTOCOL = new 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. - */ -export class MSC3906Rendezvous { - private newDeviceId?: string; - private newDeviceKey?: string; - private ourIntent: RendezvousIntent = RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE; - private _code?: string; - - /** - * @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 - */ - public constructor( - private channel: RendezvousChannel<MSC3906RendezvousPayload>, - private client: MatrixClient, - public onFailure?: RendezvousFailureListener, - ) {} - - /** - * Returns the code representing the rendezvous suitable for rendering in a QR code or undefined if not generated yet. - */ - public get code(): string | undefined { - return this._code; - } - - /** - * Generate the code including doing partial set up of the channel where required. - */ - public async generateCode(): Promise<void> { - if (this._code) { - return; - } - - this._code = JSON.stringify(await this.channel.generateCode(this.ourIntent)); - } - - public async startAfterShowingCode(): Promise<string | undefined> { - const checksum = await this.channel.connect(); - - logger.info(`Connected to secure channel with checksum: ${checksum} our intent is ${this.ourIntent}`); - - const features = await buildFeatureSupportMap(await this.client.getVersions()); - // determine available protocols - if (features.get(Feature.LoginTokenRequest) === ServerSupport.Unsupported) { - 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.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; - } - - private async receive(): Promise<MSC3906RendezvousPayload> { - return (await this.channel.receive()) as MSC3906RendezvousPayload; - } - - private async send(payload: MSC3906RendezvousPayload): Promise<void> { - await this.channel.send(payload); - } - - public async declineLoginOnExistingDevice(): Promise<void> { - logger.info("User declined sign in"); - await this.send({ type: PayloadType.Finish, outcome: Outcome.Declined }); - } - - public async approveLoginOnExistingDevice(loginToken: string): Promise<string | undefined> { - // eslint-disable-next-line camelcase - await this.send({ type: PayloadType.Progress, login_token: loginToken, homeserver: this.client.baseUrl }); - - 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; - } - - private async verifyAndCrossSignDevice(deviceInfo: DeviceInfo): Promise<CrossSigningInfo | 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.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 - */ - public async verifyNewDeviceOnExistingDevice( - timeout = 10 * 1000, - ): Promise<DeviceInfo | CrossSigningInfo | undefined> { - if (!this.newDeviceId) { - throw new Error("No new device to sign"); - } - - if (!this.newDeviceKey) { - 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.info("Going to wait for new device to be online"); - await 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"); - } - - public async cancel(reason: RendezvousFailureReason): Promise<void> { - this.onFailure?.(reason); - await this.channel.cancel(reason); - } - - public async close(): Promise<void> { - await this.channel.close(); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousChannel.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousChannel.ts deleted file mode 100644 index 549ebc8..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousChannel.ts +++ /dev/null @@ -1,48 +0,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. -*/ - -import { RendezvousCode, RendezvousIntent, RendezvousFailureReason } from "."; - -export interface RendezvousChannel<T> { - /** - * @returns the checksum/confirmation digits to be shown to the user - */ - connect(): Promise<string>; - - /** - * Send a payload via the channel. - * @param data - payload to send - */ - send(data: T): Promise<void>; - - /** - * Receive a payload from the channel. - * @returns the received payload - */ - receive(): Promise<Partial<T> | undefined>; - - /** - * Close the channel and clear up any resources. - */ - close(): Promise<void>; - - /** - * @returns a representation of the channel that can be encoded in a QR or similar - */ - generateCode(intent: RendezvousIntent): Promise<RendezvousCode>; - - cancel(reason: RendezvousFailureReason): Promise<void>; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousCode.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousCode.ts deleted file mode 100644 index 86608aa..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousCode.ts +++ /dev/null @@ -1,25 +0,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. -*/ - -import { RendezvousTransportDetails, RendezvousIntent } from "."; - -export interface RendezvousCode { - intent: RendezvousIntent; - rendezvous?: { - transport: RendezvousTransportDetails; - algorithm: string; - }; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousError.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousError.ts deleted file mode 100644 index 8b76fc1..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousError.ts +++ /dev/null @@ -1,23 +0,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. -*/ - -import { RendezvousFailureReason } from "."; - -export class RendezvousError extends Error { - public constructor(message: string, public readonly code: RendezvousFailureReason) { - super(message); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousFailureReason.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousFailureReason.ts deleted file mode 100644 index b19a91c..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousFailureReason.ts +++ /dev/null @@ -1,31 +0,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. -*/ - -export type RendezvousFailureListener = (reason: RendezvousFailureReason) => void; - -export enum RendezvousFailureReason { - UserDeclined = "user_declined", - OtherDeviceNotSignedIn = "other_device_not_signed_in", - OtherDeviceAlreadySignedIn = "other_device_already_signed_in", - Unknown = "unknown", - Expired = "expired", - UserCancelled = "user_cancelled", - InvalidCode = "invalid_code", - UnsupportedAlgorithm = "unsupported_algorithm", - DataMismatch = "data_mismatch", - UnsupportedTransport = "unsupported_transport", - HomeserverLacksSupport = "homeserver_lacks_support", -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousIntent.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousIntent.ts deleted file mode 100644 index db53ef9..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousIntent.ts +++ /dev/null @@ -1,20 +0,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. -*/ - -export enum RendezvousIntent { - LOGIN_ON_NEW_DEVICE = "login.start", - RECIPROCATE_LOGIN_ON_EXISTING_DEVICE = "login.reciprocate", -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousTransport.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousTransport.ts deleted file mode 100644 index 08905be..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousTransport.ts +++ /dev/null @@ -1,58 +0,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. -*/ - -import { RendezvousFailureListener, RendezvousFailureReason } from "."; - -export interface RendezvousTransportDetails { - type: string; -} - -/** - * Interface representing a generic rendezvous transport. - */ -export interface RendezvousTransport<T> { - /** - * Ready state of the transport. This is set to true when the transport is ready to be used. - */ - readonly ready: boolean; - - /** - * Listener for cancellation events. This is called when the rendezvous is cancelled or fails. - */ - onFailure?: RendezvousFailureListener; - - /** - * @returns the transport details that can be encoded in a QR or similar - */ - details(): Promise<RendezvousTransportDetails>; - - /** - * Send data via the transport. - * @param data - the data itself - */ - send(data: T): Promise<void>; - - /** - * Receive data from the transport. - */ - receive(): Promise<Partial<T> | undefined>; - - /** - * Cancel the rendezvous. This will call `onCancelled()` if it is set. - * @param reason - the reason for the cancellation/failure - */ - cancel(reason: RendezvousFailureReason): Promise<void>; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts deleted file mode 100644 index be60ee5..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts +++ /dev/null @@ -1,259 +0,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. -*/ - -import { SAS } from "@matrix-org/olm"; - -import { - RendezvousError, - RendezvousCode, - RendezvousIntent, - RendezvousChannel, - RendezvousTransportDetails, - RendezvousTransport, - RendezvousFailureReason, -} from ".."; -import { encodeUnpaddedBase64, decodeBase64 } from "../../crypto/olmlib"; -import { crypto, subtleCrypto, TextEncoder } from "../../crypto/crypto"; -import { generateDecimalSas } from "../../crypto/verification/SASDecimal"; -import { UnstableValue } from "../../NamespacedValue"; - -const ECDH_V2 = new UnstableValue( - "m.rendezvous.v2.curve25519-aes-sha256", - "org.matrix.msc3903.rendezvous.v2.curve25519-aes-sha256", -); - -export interface ECDHv2RendezvousCode extends RendezvousCode { - rendezvous: { - transport: RendezvousTransportDetails; - algorithm: typeof ECDH_V2.name | typeof ECDH_V2.altName; - key: string; - }; -} - -export type MSC3903ECDHPayload = PlainTextPayload | EncryptedPayload; - -export interface PlainTextPayload { - algorithm: typeof ECDH_V2.name | typeof ECDH_V2.altName; - key?: string; -} - -export interface EncryptedPayload { - iv: string; - ciphertext: string; -} - -async function importKey(key: Uint8Array): Promise<CryptoKey> { - if (!subtleCrypto) { - throw new Error("Web Crypto is not available"); - } - - const imported = 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. - */ -export class MSC3903ECDHv2RendezvousChannel<T> implements RendezvousChannel<T> { - private olmSAS?: SAS; - private ourPublicKey: Uint8Array; - private aesKey?: CryptoKey; - private connected = false; - - public constructor( - private transport: RendezvousTransport<MSC3903ECDHPayload>, - private theirPublicKey?: Uint8Array, - public onFailure?: (reason: RendezvousFailureReason) => void, - ) { - this.olmSAS = new global.Olm.SAS(); - this.ourPublicKey = decodeBase64(this.olmSAS.get_pubkey()); - } - - public async generateCode(intent: RendezvousIntent): Promise<ECDHv2RendezvousCode> { - if (this.transport.ready) { - throw new Error("Code already generated"); - } - - await this.transport.send({ algorithm: ECDH_V2.name }); - - const rendezvous: ECDHv2RendezvousCode = { - rendezvous: { - algorithm: ECDH_V2.name, - key: encodeUnpaddedBase64(this.ourPublicKey), - transport: await this.transport.details(), - }, - intent, - }; - - return rendezvous; - } - - public async connect(): Promise<string> { - 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 as Partial<PlainTextPayload>; - const { key, algorithm } = res; - if (!algorithm || !ECDH_V2.matches(algorithm) || !key) { - throw new RendezvousError( - "Unsupported algorithm: " + algorithm, - RendezvousFailureReason.UnsupportedAlgorithm, - ); - } - - this.theirPublicKey = decodeBase64(key); - } else { - // send our public key unencrypted - await this.transport.send({ - algorithm: ECDH_V2.name, - key: encodeUnpaddedBase64(this.ourPublicKey), - }); - } - - this.connected = true; - - this.olmSAS.set_their_key(encodeUnpaddedBase64(this.theirPublicKey!)); - - const initiatorKey = isInitiator ? this.ourPublicKey : this.theirPublicKey!; - const recipientKey = isInitiator ? this.theirPublicKey! : this.ourPublicKey; - let aesInfo = ECDH_V2.name; - aesInfo += `|${encodeUnpaddedBase64(initiatorKey)}`; - aesInfo += `|${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 generateDecimalSas(Array.from(rawChecksum)).join("-"); - } - - private async encrypt(data: T): Promise<MSC3903ECDHPayload> { - if (!subtleCrypto) { - throw new Error("Web Crypto is not available"); - } - - const iv = new Uint8Array(32); - crypto.getRandomValues(iv); - - const encodedData = new TextEncoder().encode(JSON.stringify(data)); - - const ciphertext = await subtleCrypto.encrypt( - { - name: "AES-GCM", - iv, - tagLength: 128, - }, - this.aesKey as CryptoKey, - encodedData, - ); - - return { - iv: encodeUnpaddedBase64(iv), - ciphertext: encodeUnpaddedBase64(ciphertext), - }; - } - - public async send(payload: T): Promise<void> { - 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)); - } - - private async decrypt({ iv, ciphertext }: EncryptedPayload): Promise<Partial<T>> { - if (!ciphertext || !iv) { - throw new Error("Missing ciphertext and/or iv"); - } - - const ciphertextBytes = decodeBase64(ciphertext); - - if (!subtleCrypto) { - throw new Error("Web Crypto is not available"); - } - - const plaintext = await subtleCrypto.decrypt( - { - name: "AES-GCM", - iv: decodeBase64(iv), - tagLength: 128, - }, - this.aesKey as CryptoKey, - ciphertextBytes, - ); - - return JSON.parse(new TextDecoder().decode(new Uint8Array(plaintext))); - } - - public async receive(): Promise<Partial<T> | undefined> { - 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 as Partial<EncryptedPayload>; - if (data.ciphertext && data.iv) { - return this.decrypt(data as EncryptedPayload); - } - - throw new Error("Data received but no ciphertext"); - } - - public async close(): Promise<void> { - if (this.olmSAS) { - this.olmSAS.free(); - this.olmSAS = undefined; - } - } - - public async cancel(reason: RendezvousFailureReason): Promise<void> { - try { - await this.transport.cancel(reason); - } finally { - await this.close(); - } - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/channels/index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/channels/index.ts deleted file mode 100644 index f157bbe..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/channels/index.ts +++ /dev/null @@ -1,17 +0,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. -*/ - -export * from "./MSC3903ECDHv2RendezvousChannel"; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/index.ts deleted file mode 100644 index 379b133..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/index.ts +++ /dev/null @@ -1,23 +0,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. -*/ - -export * from "./MSC3906Rendezvous"; -export * from "./RendezvousChannel"; -export * from "./RendezvousCode"; -export * from "./RendezvousError"; -export * from "./RendezvousFailureReason"; -export * from "./RendezvousIntent"; -export * from "./RendezvousTransport"; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts deleted file mode 100644 index 430ee92..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts +++ /dev/null @@ -1,193 +0,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. -*/ - -import { UnstableValue } from "matrix-events-sdk"; - -import { logger } from "../../logger"; -import { sleep } from "../../utils"; -import { - RendezvousFailureListener, - RendezvousFailureReason, - RendezvousTransport, - RendezvousTransportDetails, -} from ".."; -import { MatrixClient } from "../../matrix"; -import { ClientPrefix } from "../../http-api"; - -const TYPE = new UnstableValue("http.v1", "org.matrix.msc3886.http.v1"); - -export interface MSC3886SimpleHttpRendezvousTransportDetails extends RendezvousTransportDetails { - uri: string; -} - -/** - * 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. - */ -export class MSC3886SimpleHttpRendezvousTransport<T extends {}> implements RendezvousTransport<T> { - private uri?: string; - private etag?: string; - private expiresAt?: Date; - private client: MatrixClient; - private fallbackRzServer?: string; - private fetchFn?: typeof global.fetch; - private cancelled = false; - private _ready = false; - public onFailure?: RendezvousFailureListener; - - public constructor({ - onFailure, - client, - fallbackRzServer, - fetchFn, - }: { - fetchFn?: typeof global.fetch; - onFailure?: RendezvousFailureListener; - client: MatrixClient; - fallbackRzServer?: string; - }) { - this.fetchFn = fetchFn; - this.onFailure = onFailure; - this.client = client; - this.fallbackRzServer = fallbackRzServer; - } - - public get ready(): boolean { - return this._ready; - } - - public async details(): Promise<MSC3886SimpleHttpRendezvousTransportDetails> { - if (!this.uri) { - throw new Error("Rendezvous not set up"); - } - - return { - type: TYPE.name, - uri: this.uri, - }; - } - - private fetch(resource: URL | string, options?: RequestInit): ReturnType<typeof global.fetch> { - if (this.fetchFn) { - return this.fetchFn(resource, options); - } - return global.fetch(resource, options); - } - - private async getPostEndpoint(): Promise<string | undefined> { - try { - if (await this.client.doesServerSupportUnstableFeature("org.matrix.msc3886")) { - return `${this.client.baseUrl}${ClientPrefix.Unstable}/org.matrix.msc3886/rendezvous`; - } - } catch (err) { - logger.warn("Failed to get unstable features", err); - } - - return this.fallbackRzServer; - } - - public async send(data: T): Promise<void> { - 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: Record<string, string> = { "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; - } - } - - public async receive(): Promise<Partial<T> | undefined> { - 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: Record<string, string> = {}; - 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 sleep(1000); - } - } - - public async cancel(reason: RendezvousFailureReason): Promise<void> { - 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.warn(e); - } - } - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/transports/index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/transports/index.ts deleted file mode 100644 index 6d8d642..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/transports/index.ts +++ /dev/null @@ -1,17 +0,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. -*/ - -export * from "./MSC3886SimpleHttpRendezvousTransport"; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/room-hierarchy.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/room-hierarchy.ts deleted file mode 100644 index 5c0b61d..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/room-hierarchy.ts +++ /dev/null @@ -1,152 +0,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. -*/ - -import { Room } from "./models/room"; -import { IHierarchyRoom, IHierarchyRelation } from "./@types/spaces"; -import { MatrixClient } from "./client"; -import { EventType } from "./@types/event"; -import { MatrixError } from "./http-api"; - -export class RoomHierarchy { - // Map from room id to list of servers which are listed as a via somewhere in the loaded hierarchy - public readonly viaMap = new Map<string, Set<string>>(); - // Map from room id to list of rooms which claim this room as their child - public readonly backRefs = new Map<string, string[]>(); - // Map from room id to object - public readonly roomMap = new Map<string, IHierarchyRoom>(); - private loadRequest?: ReturnType<MatrixClient["getRoomHierarchy"]>; - private nextBatch?: string; - private _rooms?: IHierarchyRoom[]; - private serverSupportError?: Error; - - /** - * 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. - */ - public constructor( - public readonly root: Room, - private readonly pageSize?: number, - private readonly maxDepth?: number, - private readonly suggestedOnly = false, - ) {} - - public get noSupport(): boolean { - return !!this.serverSupportError; - } - - public get canLoadMore(): boolean { - return !!this.serverSupportError || !!this.nextBatch || !this._rooms; - } - - public get loading(): boolean { - return !!this.loadRequest; - } - - public get rooms(): IHierarchyRoom[] | undefined { - return this._rooms; - } - - public async load(pageSize = this.pageSize): Promise<IHierarchyRoom[]> { - 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: IHierarchyRoom[]; - try { - ({ rooms, next_batch: this.nextBatch } = await this.loadRequest); - } catch (e) { - if ((<MatrixError>e).errcode === "M_UNRECOGNIZED") { - this.serverSupportError = <MatrixError>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 !== 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; - } - - public getRelation(parentId: string, childId: string): IHierarchyRelation | undefined { - return this.roomMap.get(parentId)?.children_state.find((e) => e.state_key === childId); - } - - public isSuggested(parentId: string, childId: string): boolean | undefined { - return this.getRelation(parentId, childId)?.content.suggested; - } - - // locally remove a relation as a form of local echo - public removeRelation(parentId: string, childId: string): void { - 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); - } - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/KeyClaimManager.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/KeyClaimManager.ts deleted file mode 100644 index 9df8f89..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/KeyClaimManager.ts +++ /dev/null @@ -1,77 +0,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. -*/ - -import { OlmMachine, UserId } from "@matrix-org/matrix-sdk-crypto-js"; - -import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor"; - -/** - * KeyClaimManager: linearises calls to OlmMachine.getMissingSessions to avoid races - * - * We have one of these per `RustCrypto` (and hence per `MatrixClient`). - */ -export class KeyClaimManager { - private currentClaimPromise: Promise<void>; - private stopped = false; - - public constructor( - private readonly olmMachine: OlmMachine, - private readonly outgoingRequestProcessor: OutgoingRequestProcessor, - ) { - 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. - */ - public stop(): void { - 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 - */ - public ensureSessionsForUsers(userList: Array<UserId>): Promise<void> { - // 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; - } - - private async ensureSessionsForUsersInner(userList: Array<UserId>): Promise<void> { - // 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); - } - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/OutgoingRequestProcessor.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/OutgoingRequestProcessor.ts deleted file mode 100644 index 7ac9a21..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/OutgoingRequestProcessor.ts +++ /dev/null @@ -1,116 +0,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. -*/ - -import { - OlmMachine, - KeysBackupRequest, - KeysClaimRequest, - KeysQueryRequest, - KeysUploadRequest, - RoomMessageRequest, - SignatureUploadRequest, - ToDeviceRequest, -} from "@matrix-org/matrix-sdk-crypto-js"; - -import { logger } from "../logger"; -import { IHttpOpts, MatrixHttpApi, Method } from "../http-api"; -import { QueryDict } from "../utils"; - -/** - * Common interface for all the request types returned by `OlmMachine.outgoingRequests`. - */ -export interface OutgoingRequest { - readonly id: string | undefined; - readonly type: number; -} - -/** - * 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. - */ -export class OutgoingRequestProcessor { - public constructor( - private readonly olmMachine: OlmMachine, - private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>, - ) {} - - public async makeOutgoingRequest(msg: OutgoingRequest): Promise<void> { - let resp: string; - - /* 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 KeysUploadRequest) { - resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/upload", {}, msg.body); - } else if (msg instanceof KeysQueryRequest) { - resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/query", {}, msg.body); - } else if (msg instanceof KeysClaimRequest) { - resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/claim", {}, msg.body); - } else if (msg instanceof SignatureUploadRequest) { - resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/signatures/upload", {}, msg.body); - } else if (msg instanceof KeysBackupRequest) { - resp = await this.rawJsonRequest(Method.Put, "/_matrix/client/v3/room_keys/keys", {}, msg.body); - } else if (msg instanceof ToDeviceRequest) { - const path = - `/_matrix/client/v3/sendToDevice/${encodeURIComponent(msg.event_type)}/` + - encodeURIComponent(msg.txn_id); - resp = await this.rawJsonRequest(Method.Put, path, {}, msg.body); - } else if (msg instanceof RoomMessageRequest) { - const path = - `/_matrix/client/v3/room/${encodeURIComponent(msg.room_id)}/send/` + - `${encodeURIComponent(msg.event_type)}/${encodeURIComponent(msg.txn_id)}`; - resp = await this.rawJsonRequest(Method.Put, path, {}, msg.body); - } else { - logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg)); - resp = ""; - } - - if (msg.id) { - await this.olmMachine.markRequestAsSent(msg.id, msg.type, resp); - } - } - - private async rawJsonRequest(method: Method, path: string, queryParams: QueryDict, body: string): Promise<string> { - 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<string>(method, path, queryParams, body, opts); - logger.info(`rust-crypto: successfully made HTTP request: ${method} ${path}`); - return response; - } catch (e) { - logger.warn(`rust-crypto: error making HTTP request: ${method} ${path}: ${e}`); - throw e; - } - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/RoomEncryptor.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/RoomEncryptor.ts deleted file mode 100644 index 1649a69..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/RoomEncryptor.ts +++ /dev/null @@ -1,153 +0,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. -*/ - -import { EncryptionSettings, OlmMachine, RoomId, UserId } from "@matrix-org/matrix-sdk-crypto-js"; - -import { EventType } from "../@types/event"; -import { IContent, MatrixEvent } from "../models/event"; -import { Room } from "../models/room"; -import { logger, PrefixedLogger } from "../logger"; -import { KeyClaimManager } from "./KeyClaimManager"; -import { RoomMember } from "../models/room-member"; -import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor"; - -/** - * RoomEncryptor: responsible for encrypting messages to a given room - */ -export class RoomEncryptor { - private readonly prefixedLogger: PrefixedLogger; - - /** - * @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 - */ - public constructor( - private readonly olmMachine: OlmMachine, - private readonly keyClaimManager: KeyClaimManager, - private readonly outgoingRequestProcessor: OutgoingRequestProcessor, - private readonly room: Room, - private encryptionSettings: IContent, - ) { - this.prefixedLogger = logger.withPrefix(`[${room.roomId} encryption]`); - } - - /** - * Handle a new `m.room.encryption` event in this room - * - * @param config - The content of the encryption event - */ - public onCryptoEvent(config: IContent): void { - 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 - */ - public onRoomMembership(member: RoomMember): void { - 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 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. - */ - public async ensureEncryptionSession(): Promise<void> { - 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 UserId(u.userId)); - await this.keyClaimManager.ensureSessionsForUsers(userList); - - this.prefixedLogger.debug("Sessions for users are ready; now sharing room key"); - - const rustEncryptionSettings = new EncryptionSettings(); - /* FIXME historyVisibility, rotation, etc */ - - const shareMessages = await this.olmMachine.shareRoomKey( - new 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 - */ - public async forceDiscardSession(): Promise<void> { - const r = await this.olmMachine.invalidateGroupSession(new 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. - */ - public async encryptEvent(event: MatrixEvent): Promise<void> { - await this.ensureEncryptionSession(); - - const encryptedContent = await this.olmMachine.encryptRoomEvent( - new RoomId(this.room.roomId), - event.getType(), - JSON.stringify(event.getContent()), - ); - - event.makeEncrypted( - EventType.RoomMessageEncrypted, - JSON.parse(encryptedContent), - this.olmMachine.identityKeys.curve25519.toBase64(), - this.olmMachine.identityKeys.ed25519.toBase64(), - ); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/browserify-index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/browserify-index.ts deleted file mode 100644 index 7f91e90..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/browserify-index.ts +++ /dev/null @@ -1,31 +0,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. -*/ - -/* 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. - */ - -import { IHttpOpts, MatrixHttpApi } from "../http-api"; - -export async function initRustCrypto( - _http: MatrixHttpApi<IHttpOpts & { onlyData: true }>, - _userId: string, - _deviceId: string, -): Promise<Crypto> { - throw new Error("Rust crypto is not supported under browserify."); -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/constants.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/constants.ts deleted file mode 100644 index 9d72060..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/constants.ts +++ /dev/null @@ -1,18 +0,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 */ -export const RUST_SDK_STORE_PREFIX = "matrix-js-sdk"; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/index.ts deleted file mode 100644 index e2c541f..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/index.ts +++ /dev/null @@ -1,45 +0,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. -*/ - -import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js"; - -import { RustCrypto } from "./rust-crypto"; -import { logger } from "../logger"; -import { RUST_SDK_STORE_PREFIX } from "./constants"; -import { IHttpOpts, MatrixHttpApi } from "../http-api"; - -export async function initRustCrypto( - http: MatrixHttpApi<IHttpOpts & { onlyData: true }>, - userId: string, - deviceId: string, -): Promise<RustCrypto> { - // 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.info("Init OlmMachine"); - - // TODO: use the pickle key for the passphrase - const olmMachine = await RustSdkCryptoJs.OlmMachine.initialize(u, d, RUST_SDK_STORE_PREFIX, "test pass"); - const rustCrypto = new RustCrypto(olmMachine, http, userId, deviceId); - - logger.info("Completed rust crypto-sdk setup"); - return rustCrypto; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/rust-crypto.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/rust-crypto.ts deleted file mode 100644 index 4a0b1f8..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/rust-crypto.ts +++ /dev/null @@ -1,334 +0,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. -*/ - -import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js"; - -import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto"; -import type { IToDeviceEvent } from "../sync-accumulator"; -import type { IEncryptedEventInfo } from "../crypto/api"; -import { MatrixEvent } from "../models/event"; -import { Room } from "../models/room"; -import { RoomMember } from "../models/room-member"; -import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend"; -import { logger } from "../logger"; -import { IHttpOpts, MatrixHttpApi } from "../http-api"; -import { DeviceTrustLevel, UserTrustLevel } from "../crypto/CrossSigning"; -import { RoomEncryptor } from "./RoomEncryptor"; -import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProcessor"; -import { KeyClaimManager } from "./KeyClaimManager"; - -/** - * An implementation of {@link CryptoBackend} using the Rust matrix-sdk-crypto. - */ -export class RustCrypto implements CryptoBackend { - public globalErrorOnUnknownDevices = false; - - /** whether {@link stop} has been called */ - private stopped = false; - - /** whether {@link outgoingRequestLoop} is currently running */ - private outgoingRequestLoopRunning = false; - - /** mapping of roomId → encryptor class */ - private roomEncryptors: Record<string, RoomEncryptor> = {}; - - private keyClaimManager: KeyClaimManager; - private outgoingRequestProcessor: OutgoingRequestProcessor; - - public constructor( - private readonly olmMachine: RustSdkCryptoJs.OlmMachine, - http: MatrixHttpApi<IHttpOpts & { onlyData: true }>, - _userId: string, - _deviceId: string, - ) { - this.outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, http); - this.keyClaimManager = new KeyClaimManager(olmMachine, this.outgoingRequestProcessor); - } - - /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // - // CryptoBackend implementation - // - /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - - public stop(): void { - // 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(); - } - - public async encryptEvent(event: MatrixEvent, _room: Room): Promise<void> { - 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); - } - - public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> { - 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"); - } - 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()!), - )) as RustSdkCryptoJs.DecryptedRoomEvent; - return { - clearEvent: JSON.parse(res.event), - claimedEd25519Key: res.senderClaimedEd25519Key, - senderCurve25519Key: res.senderCurve25519Key, - forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain, - }; - } - - public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo { - // TODO: make this work properly. Or better, replace it. - - const ret: Partial<IEncryptedEventInfo> = {}; - - ret.senderKey = event.getSenderKey() ?? undefined; - ret.algorithm = event.getWireContent().algorithm; - - if (!ret.senderKey || !ret.algorithm) { - ret.encrypted = false; - return ret as IEncryptedEventInfo; - } - ret.encrypted = true; - ret.authenticated = true; - ret.mismatchedSender = true; - return ret as IEncryptedEventInfo; - } - - public checkUserTrust(userId: string): UserTrustLevel { - // TODO - return new UserTrustLevel(false, false, false); - } - - public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel { - // TODO - return new DeviceTrustLevel(false, false, false, false); - } - - /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // - // CryptoApi implementation - // - /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - - public globalBlacklistUnverifiedDevices = false; - - public async userHasCrossSigningKeys(): Promise<boolean> { - // TODO - return false; - } - - public prepareToEncrypt(room: Room): void { - const encryptor = this.roomEncryptors[room.roomId]; - - if (encryptor) { - encryptor.ensureEncryptionSession(); - } - } - - public forceDiscardSession(roomId: string): Promise<void> { - return this.roomEncryptors[roomId]?.forceDiscardSession(); - } - - public async exportRoomKeys(): Promise<IMegolmSessionData[]> { - // TODO - return []; - } - - /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // - // 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 - * @returns A list of preprocessed to-device messages. - */ - private async receiveSyncChanges({ - events, - oneTimeKeysCounts = new Map<string, number>(), - unusedFallbackKeys = new Set<string>(), - }: { - events?: IToDeviceEvent[]; - oneTimeKeysCounts?: Map<string, number>; - unusedFallbackKeys?: Set<string>; - }): Promise<IToDeviceEvent[]> { - const result = await this.olmMachine.receiveSyncChanges( - events ? JSON.stringify(events) : "[]", - new RustSdkCryptoJs.DeviceLists(), - 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. - */ - public preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]> { - // 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 preprocess one time key counts - * - * @param oneTimeKeysCounts - the received one time key counts - * @returns A list of preprocessed to-device messages. - */ - public async preprocessOneTimeKeyCounts(oneTimeKeysCounts: Map<string, number>): Promise<void> { - await this.receiveSyncChanges({ oneTimeKeysCounts }); - } - - /** called by the sync loop to preprocess unused fallback keys - * - * @param unusedFallbackKeys - the received unused fallback keys - * @returns A list of preprocessed to-device messages. - */ - public async preprocessUnusedFallbackKeys(unusedFallbackKeys: Set<string>): Promise<void> { - await this.receiveSyncChanges({ unusedFallbackKeys }); - } - - /** 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 - */ - public async onCryptoEvent(room: Room, event: MatrixEvent): Promise<void> { - const config = event.getContent(); - - const existingEncryptor = this.roomEncryptors[room.roomId]; - if (existingEncryptor) { - existingEncryptor.onCryptoEvent(config); - } else { - this.roomEncryptors[room.roomId] = new 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.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. - */ - public onSyncCompleted(syncState: OnSyncCompletedData): void { - // 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. - */ - public onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void { - const enc = this.roomEncryptors[event.getRoomId()!]; - if (!enc) { - // not encrypting in this room - return; - } - enc.onRoomMembership(member); - } - - /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // - // Outgoing requests - // - /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - - private async outgoingRequestLoop(): Promise<void> { - if (this.outgoingRequestLoopRunning) { - return; - } - this.outgoingRequestLoopRunning = true; - try { - while (!this.stopped) { - const outgoingRequests: Object[] = 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 as OutgoingRequest); - } - } - } catch (e) { - logger.error("Error processing outgoing-message requests from rust crypto-sdk", e); - } finally { - this.outgoingRequestLoopRunning = false; - } - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/scheduler.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/scheduler.ts deleted file mode 100644 index 6b6bae1..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/scheduler.ts +++ /dev/null @@ -1,335 +0,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. -*/ - -/** - * This is an internal module which manages queuing, scheduling and retrying - * of requests. - */ -import * as utils from "./utils"; -import { logger } from "./logger"; -import { MatrixEvent } from "./models/event"; -import { EventType } from "./@types/event"; -import { IDeferred } from "./utils"; -import { ConnectionError, MatrixError } from "./http-api"; -import { ISendEventResponse } from "./@types/requests"; - -const DEBUG = false; // set true to enable console logging. - -interface IQueueEntry<T> { - event: MatrixEvent; - defer: IDeferred<T>; - attempts: number; -} - -/** - * 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. - */ -type ProcessFunction<T> = (event: MatrixEvent) => Promise<T>; - -// eslint-disable-next-line camelcase -export class MatrixScheduler<T = ISendEventResponse> { - /** - * 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 - public static RETRY_BACKOFF_RATELIMIT(event: MatrixEvent | null, attempts: number, err: MatrixError): number { - 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 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 - public static QUEUE_MESSAGES(event: MatrixEvent): string | null { - // enqueue messages or events that associate with another event (redactions and relations) - if (event.getType() === 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 - // }, ...] - private readonly queues: Record<string, IQueueEntry<T>[]> = {}; - private activeQueues: string[] = []; - private procFn: ProcessFunction<T> | null = null; - - /** - * 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}. - */ - public 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. - */ - public readonly 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. - */ - public readonly queueAlgorithm = MatrixScheduler.QUEUE_MESSAGES, - ) {} - - /** - * 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. - */ - public getQueueForEvent(event: MatrixEvent): MatrixEvent[] | null { - 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. - */ - public removeEventFromQueue(event: MatrixEvent): boolean { - const name = this.queueAlgorithm(event); - if (!name || !this.queues[name]) { - return false; - } - let removed = false; - 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. - */ - public setProcessFunction(fn: ProcessFunction<T>): void { - 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. - */ - public queueEvent(event: MatrixEvent): Promise<T> | null { - const queueName = this.queueAlgorithm(event); - if (!queueName) { - return null; - } - // add the event to the queue and make a deferred for it. - if (!this.queues[queueName]) { - this.queues[queueName] = []; - } - const defer = utils.defer<T>(); - this.queues[queueName].push({ - event: event, - defer: defer, - attempts: 0, - }); - debuglog("Queue algorithm dumped event %s into queue '%s'", event.getId(), queueName); - this.startProcessingQueues(); - return defer.promise; - } - - private startProcessingQueues(): void { - 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); - }); - } - - private processQueue = (queueName: string): void => { - // 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!) - debuglog("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); - } - }, - ); - }; - - private disableQueue(queueName: string): void { - // queue is empty. Mark as inactive and stop recursing. - const index = this.activeQueues.indexOf(queueName); - if (index >= 0) { - this.activeQueues.splice(index, 1); - } - debuglog("Stopping queue '%s' as it is now empty", queueName); - } - - private clearQueue(queueName: string, err: unknown): void { - debuglog("clearing queue '%s'", queueName); - let obj: IQueueEntry<T> | undefined; - while ((obj = this.removeNextEvent(queueName))) { - obj.defer.reject(err); - } - this.disableQueue(queueName); - } - - private peekNextEvent(queueName: string): IQueueEntry<T> | undefined { - const queue = this.queues[queueName]; - if (!Array.isArray(queue)) { - return undefined; - } - return queue[0]; - } - - private removeNextEvent(queueName: string): IQueueEntry<T> | undefined { - const queue = this.queues[queueName]; - if (!Array.isArray(queue)) { - return undefined; - } - return queue.shift(); - } -} - -/* istanbul ignore next */ -function debuglog(...args: any[]): void { - if (DEBUG) { - logger.log(...args); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/secret-storage.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/secret-storage.ts deleted file mode 100644 index f0c19c4..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/secret-storage.ts +++ /dev/null @@ -1,88 +0,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. -*/ - -/** - * Implementation of server-side secret storage - * - * @see https://spec.matrix.org/v1.6/client-server-api/#storage - */ - -/** - * 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 - */ -export interface SecretStorageKeyDescriptionCommon { - /** A human-readable name for this key. */ - // XXX: according to the spec, this is optional - name: string; - - /** The encryption algorithm used with this key. */ - algorithm: string; - - /** Information for deriving this key from a passphrase. */ - // XXX: according to the spec, this is optional - passphrase: PassphraseInfo; -} - -/** - * 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 - */ -export interface SecretStorageKeyDescriptionAesV1 extends SecretStorageKeyDescriptionCommon { - // XXX: strictly speaking, we should be able to enforce the algorithm here. But - // this interface ends up being incorrectly used where other algorithms are in use (notably - // in device-dehydration support), and unpicking that is too much like hard work - // at the moment. - // algorithm: "m.secret_storage.v1.aes-hmac-sha2"; - - /** The 16-byte AES initialization vector, encoded as base64. */ - iv: string; - - /** The MAC of the result of encrypting 32 bytes of 0, encoded as base64. */ - mac: string; -} - -/** - * Union type for secret storage keys. - * - * For now, this is only {@link SecretStorageKeyDescriptionAesV1}, but other interfaces may be added in future. - */ -export type SecretStorageKeyDescription = SecretStorageKeyDescriptionAesV1; - -/** - * Information on how to generate the key from a passphrase. - * - * @see https://spec.matrix.org/v1.6/client-server-api/#deriving-keys-from-passphrases - */ -export interface PassphraseInfo { - /** The algorithm to be used to derive the key. */ - algorithm: "m.pbkdf2"; - - /** The number of PBKDF2 iterations to use. */ - iterations: number; - - /** The salt to be used for PBKDF2. */ - salt: string; - - /** The number of bits to generate. Defaults to 256. */ - bits?: number; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/service-types.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/service-types.ts deleted file mode 100644 index 3ed08bb..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/service-types.ts +++ /dev/null @@ -1,20 +0,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. -*/ - -export enum SERVICE_TYPES { - IS = "SERVICE_TYPE_IS", // An identity server - IM = "SERVICE_TYPE_IM", // An integration manager -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/sliding-sync-sdk.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/sliding-sync-sdk.ts deleted file mode 100644 index 93e29e0..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/sliding-sync-sdk.ts +++ /dev/null @@ -1,1027 +0,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. -*/ - -import type { SyncCryptoCallbacks } from "./common-crypto/CryptoBackend"; -import { NotificationCountType, Room, RoomEvent } from "./models/room"; -import { logger } from "./logger"; -import * as utils from "./utils"; -import { EventTimeline } from "./models/event-timeline"; -import { ClientEvent, IStoredClientOpts, MatrixClient } from "./client"; -import { - ISyncStateData, - SyncState, - _createAndReEmitRoom, - SyncApiOptions, - defaultClientOpts, - defaultSyncApiOpts, -} from "./sync"; -import { MatrixEvent } from "./models/event"; -import { Crypto } from "./crypto"; -import { IMinimalEvent, IRoomEvent, IStateEvent, IStrippedState, ISyncResponse } from "./sync-accumulator"; -import { MatrixError } from "./http-api"; -import { - Extension, - ExtensionState, - MSC3575RoomData, - MSC3575SlidingSyncResponse, - SlidingSync, - SlidingSyncEvent, - SlidingSyncState, -} from "./sliding-sync"; -import { EventType } from "./@types/event"; -import { IPushRules } from "./@types/PushRules"; -import { RoomStateEvent } from "./models/room-state"; -import { RoomMemberEvent } from "./models/room-member"; - -// 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; - -type ExtensionE2EERequest = { - enabled: boolean; -}; - -type ExtensionE2EEResponse = Pick< - ISyncResponse, - | "device_lists" - | "device_one_time_keys_count" - | "device_unused_fallback_key_types" - | "org.matrix.msc2732.device_unused_fallback_key_types" ->; - -class ExtensionE2EE implements Extension<ExtensionE2EERequest, ExtensionE2EEResponse> { - public constructor(private readonly crypto: Crypto) {} - - public name(): string { - return "e2ee"; - } - - public when(): ExtensionState { - return ExtensionState.PreProcess; - } - - public onRequest(isInitial: boolean): ExtensionE2EERequest | undefined { - if (!isInitial) { - return undefined; - } - return { - enabled: true, // this is sticky so only send it on the initial request - }; - } - - public async onResponse(data: ExtensionE2EEResponse): Promise<void> { - // Handle device list updates - if (data["device_lists"]) { - await this.crypto.handleDeviceListChanges( - { - oldSyncToken: "yep", // XXX need to do this so the device list changes get processed :( - }, - data["device_lists"], - ); - } - - // Handle one_time_keys_count - if (data["device_one_time_keys_count"]) { - const currentCount = data["device_one_time_keys_count"].signed_curve25519 || 0; - this.crypto.updateOneTimeKeyCount(currentCount); - } - if (data["device_unused_fallback_key_types"] || data["org.matrix.msc2732.device_unused_fallback_key_types"]) { - // The presence of device_unused_fallback_key_types indicates that the - // server supports fallback keys. If there's no unused - // signed_curve25519 fallback key we need a new one. - const unusedFallbackKeys = - data["device_unused_fallback_key_types"] || data["org.matrix.msc2732.device_unused_fallback_key_types"]; - this.crypto.setNeedsNewFallback( - Array.isArray(unusedFallbackKeys) && !unusedFallbackKeys.includes("signed_curve25519"), - ); - } - this.crypto.onSyncCompleted({}); - } -} - -type ExtensionToDeviceRequest = { - since?: string; - limit?: number; - enabled?: boolean; -}; - -type ExtensionToDeviceResponse = { - events: Required<ISyncResponse>["to_device"]["events"]; - next_batch: string | null; -}; - -class ExtensionToDevice implements Extension<ExtensionToDeviceRequest, ExtensionToDeviceResponse> { - private nextBatch: string | null = null; - - public constructor(private readonly client: MatrixClient, private readonly cryptoCallbacks?: SyncCryptoCallbacks) {} - - public name(): string { - return "to_device"; - } - - public when(): ExtensionState { - return ExtensionState.PreProcess; - } - - public onRequest(isInitial: boolean): ExtensionToDeviceRequest { - const extReq: ExtensionToDeviceRequest = { - since: this.nextBatch !== null ? this.nextBatch : undefined, - }; - if (isInitial) { - extReq["limit"] = 100; - extReq["enabled"] = true; - } - return extReq; - } - - public async onResponse(data: ExtensionToDeviceResponse): Promise<void> { - const cancelledKeyVerificationTxns: string[] = []; - 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: string | undefined = 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.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(ClientEvent.ToDeviceEvent, toDeviceEvent); - }); - - this.nextBatch = data.next_batch; - } -} - -type ExtensionAccountDataRequest = { - enabled: boolean; -}; - -type ExtensionAccountDataResponse = { - global: IMinimalEvent[]; - rooms: Record<string, IMinimalEvent[]>; -}; - -class ExtensionAccountData implements Extension<ExtensionAccountDataRequest, ExtensionAccountDataResponse> { - public constructor(private readonly client: MatrixClient) {} - - public name(): string { - return "account_data"; - } - - public when(): ExtensionState { - return ExtensionState.PostProcess; - } - - public onRequest(isInitial: boolean): ExtensionAccountDataRequest | undefined { - if (!isInitial) { - return undefined; - } - return { - enabled: true, - }; - } - - public onResponse(data: ExtensionAccountDataResponse): void { - 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.warn("got account data for room but room doesn't exist on client:", roomId); - continue; - } - room.addAccountData(accountDataEvents); - accountDataEvents.forEach((e) => { - this.client.emit(ClientEvent.Event, e); - }); - } - } - - private processGlobalAccountData(globalAccountData: IMinimalEvent[]): void { - const events = mapEvents(this.client, undefined, globalAccountData); - const prevEventsMap = events.reduce<Record<string, MatrixEvent | undefined>>((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() === EventType.PushRules) { - const rules = accountDataEvent.getContent<IPushRules>(); - this.client.setPushRules(rules); - } - const prevEvent = prevEventsMap[accountDataEvent.getType()]; - this.client.emit(ClientEvent.AccountData, accountDataEvent, prevEvent); - return accountDataEvent; - }); - } -} - -type ExtensionTypingRequest = { - enabled: boolean; -}; - -type ExtensionTypingResponse = { - rooms: Record<string, IMinimalEvent>; -}; - -class ExtensionTyping implements Extension<ExtensionTypingRequest, ExtensionTypingResponse> { - public constructor(private readonly client: MatrixClient) {} - - public name(): string { - return "typing"; - } - - public when(): ExtensionState { - return ExtensionState.PostProcess; - } - - public onRequest(isInitial: boolean): ExtensionTypingRequest | undefined { - if (!isInitial) { - return undefined; // don't send a JSON object for subsequent requests, we don't need to. - } - return { - enabled: true, - }; - } - - public onResponse(data: ExtensionTypingResponse): void { - if (!data?.rooms) { - return; - } - - for (const roomId in data.rooms) { - processEphemeralEvents(this.client, roomId, [data.rooms[roomId]]); - } - } -} - -type ExtensionReceiptsRequest = { - enabled: boolean; -}; - -type ExtensionReceiptsResponse = { - rooms: Record<string, IMinimalEvent>; -}; - -class ExtensionReceipts implements Extension<ExtensionReceiptsRequest, ExtensionReceiptsResponse> { - public constructor(private readonly client: MatrixClient) {} - - public name(): string { - return "receipts"; - } - - public when(): ExtensionState { - return ExtensionState.PostProcess; - } - - public onRequest(isInitial: boolean): ExtensionReceiptsRequest | undefined { - if (isInitial) { - return { - enabled: true, - }; - } - return undefined; // don't send a JSON object for subsequent requests, we don't need to. - } - - public onResponse(data: ExtensionReceiptsResponse): void { - 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. - */ -export class SlidingSyncSdk { - private readonly opts: IStoredClientOpts; - private readonly syncOpts: SyncApiOptions; - private syncState: SyncState | null = null; - private syncStateData?: ISyncStateData; - private lastPos: string | null = null; - private failCount = 0; - private notifEvents: MatrixEvent[] = []; // accumulator of sync events in the current sync response - - public constructor( - private readonly slidingSync: SlidingSync, - private readonly client: MatrixClient, - opts?: IStoredClientOpts, - syncOpts?: SyncApiOptions, - ) { - this.opts = defaultClientOpts(opts); - this.syncOpts = defaultSyncApiOpts(syncOpts); - - if (client.getNotifTimelineSet()) { - client.reEmitter.reEmit(client.getNotifTimelineSet()!, [RoomEvent.Timeline, RoomEvent.TimelineReset]); - } - - this.slidingSync.on(SlidingSyncEvent.Lifecycle, this.onLifecycle.bind(this)); - this.slidingSync.on(SlidingSyncEvent.RoomData, this.onRoomData.bind(this)); - const extensions: Extension<any, any>[] = [ - 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); - }); - } - - private onRoomData(roomId: string, roomData: MSC3575RoomData): void { - let room = this.client.store.getRoom(roomId); - if (!room) { - if (!roomData.initial) { - logger.debug("initial flag not set but no stored room exists for room ", roomId, roomData); - return; - } - room = _createAndReEmitRoom(this.client, roomId, this.opts); - } - this.processRoomData(this.client, room, roomData); - } - - private onLifecycle(state: SlidingSyncState, resp: MSC3575SlidingSyncResponse | null, err?: Error): void { - if (err) { - logger.debug("onLifecycle", state, err); - } - switch (state) { - case 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(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(SyncState.Syncing, { - oldSyncToken: this.lastPos!, - nextSyncToken: resp.pos, - catchingUp: false, - fromCache: false, - }); - this.lastPos = resp.pos; - break; - case SlidingSyncState.RequestFinished: - if (err) { - this.failCount += 1; - this.updateSyncState( - this.failCount > FAILED_SYNC_ERROR_THRESHOLD ? SyncState.Error : SyncState.Reconnecting, - { - error: new MatrixError(err), - }, - ); - if (this.shouldAbortSync(new 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. - */ - public async syncLeftRooms(): Promise<Room[]> { - 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. - */ - public async peek(_roomId: string): Promise<Room> { - return null!; // TODO - } - - /** - * Stop polling for updates in the peeked room. NOPs if there is no room being - * peeked. - */ - public stopPeeking(): void { - // TODO - } - - /** - * Returns the current state of this sync object - * @see MatrixClient#event:"sync" - */ - public getSyncState(): SyncState | null { - 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. - */ - public getSyncStateData(): ISyncStateData | null { - return this.syncStateData ?? null; - } - - // Helper functions which set up JS SDK structs are below and are identical to the sync v2 counterparts - - public createRoom(roomId: string): Room { - // XXX cargoculted from sync.ts - const { timelineSupport } = this.client; - const room = new Room(roomId, this.client, this.client.getUserId()!, { - lazyLoadMembers: this.opts.lazyLoadMembers, - pendingEventOrdering: this.opts.pendingEventOrdering, - timelineSupport, - }); - this.client.reEmitter.reEmit(room, [ - RoomEvent.Name, - RoomEvent.Redaction, - RoomEvent.RedactionCancelled, - RoomEvent.Receipt, - RoomEvent.Tags, - RoomEvent.LocalEchoUpdated, - RoomEvent.AccountData, - RoomEvent.MyMembership, - RoomEvent.Timeline, - RoomEvent.TimelineReset, - ]); - this.registerStateListeners(room); - return room; - } - - private registerStateListeners(room: Room): void { - // 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, [ - RoomStateEvent.Events, - RoomStateEvent.Members, - RoomStateEvent.NewMember, - RoomStateEvent.Update, - ]); - room.currentState.on(RoomStateEvent.NewMember, (event, state, member) => { - member.user = this.client.getUser(member.userId) ?? undefined; - this.client.reEmitter.reEmit(member, [ - RoomMemberEvent.Name, - RoomMemberEvent.Typing, - RoomMemberEvent.PowerLevel, - 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); - } */ - - private shouldAbortSync(error: MatrixError): boolean { - if (error.errcode === "M_UNKNOWN_TOKEN") { - // The logout already happened, we just need to stop. - logger.warn("Token no longer valid - assuming logout"); - this.stop(); - this.updateSyncState(SyncState.Error, { error }); - return true; - } - return false; - } - - private async processRoomData(client: MatrixClient, room: Room, roomData: MSC3575RoomData): Promise<void> { - 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: MatrixEvent[] = []; // 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<string>(); - 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: MatrixEvent[] = []; - const newEvents: MatrixEvent[] = []; - 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(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(NotificationCountType.Highlight) <= 0)) { - room.setUnreadNotificationCount(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); - this.injectRoomEvents(room, inviteStateEvents); - if (roomData.initial) { - room.recalculate(); - this.client.store.storeRoom(room); - this.client.emit(ClientEvent.Room, room); - } - inviteStateEvents.forEach((e) => { - this.client.emit(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.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); - } - } */ - - 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(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: MatrixEvent): Promise<void> => { - client.emit(ClientEvent.Event, e); - if (e.isState() && e.getType() == EventType.RoomEncryption && this.syncOpts.cryptoCallbacks) { - await this.syncOpts.cryptoCallbacks.onCryptoEvent(room, e); - } - }; - - await utils.promiseMapSeries(stateEvents, processRoomEvent); - await utils.promiseMapSeries(timelineEvents, processRoomEvent); - ephemeralEvents.forEach(function (e) { - client.emit(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. - */ - public injectRoomEvents( - room: Room, - stateEventList: MatrixEvent[], - timelineEventList?: MatrixEvent[], - numLive?: number, - ): void { - 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: MatrixEvent[] = []; - 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. - room.addLiveEvents(timelineEventList, { - fromCache: true, - }); - if (liveTimelineEvents.length > 0) { - room.addLiveEvents(liveTimelineEvents, { - fromCache: false, - }); - } - - room.recalculate(); - - // resolve invites now we have set the latest state - this.resolveInvites(room); - } - - private resolveInvites(room: Room): void { - 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: ReturnType<MatrixClient["getProfileInfo"]>; - 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. - }, - ); - }); - } - - public retryImmediately(): boolean { - return true; - } - - /** - * Main entry point. Blocks until stop() is called. - */ - public async sync(): Promise<void> { - 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.debug("Getting push rules..."); - const result = await this.client.getPushRules(); - logger.debug("Got push rules"); - this.client.pushRules = result; - break; - } catch (err) { - logger.error("Getting push rules failed", err); - if (this.shouldAbortSync(<MatrixError>err)) { - return; - } - } - } - - // start syncing - await this.slidingSync.start(); - } - - /** - * Stops the sync object from syncing. - */ - public stop(): void { - 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 - */ - private updateSyncState(newState: SyncState, data?: ISyncStateData): void { - const old = this.syncState; - this.syncState = newState; - this.syncStateData = data; - this.client.emit(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. - */ - private addNotifications(timelineEventList: MatrixEvent[]): void { - // 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. - */ - private purgeNotifications(): void { - this.notifEvents.sort(function (a, b) { - return a.getTs() - b.getTs(); - }); - this.notifEvents.forEach((event) => { - this.client.getNotifTimelineSet()?.addLiveEvent(event); - }); - this.notifEvents = []; - } -} - -function ensureNameEvent(client: MatrixClient, roomId: string, roomData: MSC3575RoomData): MSC3575RoomData { - // 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 === 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: EventType.RoomName, - content: { - name: roomData.name, - }, - sender: client.getUserId()!, - origin_server_ts: new Date().getTime(), - }); - return roomData; -} - -type TaggedEvent = (IStrippedState | IRoomEvent | IStateEvent | IMinimalEvent) & { room_id?: string }; - -// 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: MatrixClient, roomId: string | undefined, events: object[], decrypt = true): MatrixEvent[] { - const mapper = client.getEventMapper({ decrypt }); - return (events as TaggedEvent[]).map(function (e) { - e.room_id = roomId; - return mapper(e); - }); -} - -function processEphemeralEvents(client: MatrixClient, roomId: string, ephEvents: IMinimalEvent[]): void { - const ephemeralEvents = mapEvents(client, roomId, ephEvents); - const room = client.getRoom(roomId); - if (!room) { - logger.warn("got ephemeral events for room but room doesn't exist on client:", roomId); - return; - } - room.addEphemeralEvents(ephemeralEvents); - ephemeralEvents.forEach((e) => { - client.emit(ClientEvent.Event, e); - }); -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/sliding-sync.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/sliding-sync.ts deleted file mode 100644 index dde5f1b..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/sliding-sync.ts +++ /dev/null @@ -1,961 +0,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. -*/ - -import { logger } from "./logger"; -import { MatrixClient } from "./client"; -import { IRoomEvent, IStateEvent } from "./sync-accumulator"; -import { TypedEventEmitter } from "./models/typed-event-emitter"; -import { sleep, IDeferred, defer } from "./utils"; -import { HTTPError } from "./http-api"; - -// /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; - -export const MSC3575_WILDCARD = "*"; -export const MSC3575_STATE_KEY_ME = "$ME"; -export const MSC3575_STATE_KEY_LAZY = "$LAZY"; - -/** - * Represents a subscription to a room or set of rooms. Controls which events are returned. - */ -export interface MSC3575RoomSubscription { - required_state?: string[][]; - timeline_limit?: number; - include_old_rooms?: MSC3575RoomSubscription; -} - -/** - * Controls which rooms are returned in a given list. - */ -export interface MSC3575Filter { - is_dm?: boolean; - is_encrypted?: boolean; - is_invite?: boolean; - room_name_like?: string; - room_types?: string[]; - not_room_types?: string[]; - spaces?: string[]; - tags?: string[]; - not_tags?: string[]; -} - -/** - * Represents a list subscription. - */ -export interface MSC3575List extends MSC3575RoomSubscription { - ranges: number[][]; - sort?: string[]; - filters?: MSC3575Filter; - slow_get_all_rooms?: boolean; -} - -/** - * A complete Sliding Sync request. - */ -export interface MSC3575SlidingSyncRequest { - // json body params - lists?: Record<string, MSC3575List>; - unsubscribe_rooms?: string[]; - room_subscriptions?: Record<string, MSC3575RoomSubscription>; - extensions?: object; - txn_id?: string; - - // query params - pos?: string; - timeout?: number; - clientTimeout?: number; -} - -export interface MSC3575RoomData { - name: string; - required_state: IStateEvent[]; - timeline: (IRoomEvent | IStateEvent)[]; - notification_count?: number; - highlight_count?: number; - joined_count?: number; - invited_count?: number; - invite_state?: IStateEvent[]; - initial?: boolean; - limited?: boolean; - is_dm?: boolean; - prev_batch?: string; - num_live?: number; -} - -interface ListResponse { - count: number; - ops: Operation[]; -} - -interface BaseOperation { - op: string; -} - -interface DeleteOperation extends BaseOperation { - op: "DELETE"; - index: number; -} - -interface InsertOperation extends BaseOperation { - op: "INSERT"; - index: number; - room_id: string; -} - -interface InvalidateOperation extends BaseOperation { - op: "INVALIDATE"; - range: [number, number]; -} - -interface SyncOperation extends BaseOperation { - op: "SYNC"; - range: [number, number]; - room_ids: string[]; -} - -type Operation = DeleteOperation | InsertOperation | InvalidateOperation | SyncOperation; - -/** - * A complete Sliding Sync response - */ -export interface MSC3575SlidingSyncResponse { - pos: string; - txn_id?: string; - lists: Record<string, ListResponse>; - rooms: Record<string, MSC3575RoomData>; - extensions: Record<string, object>; -} - -export enum SlidingSyncState { - /** - * Fired by SlidingSyncEvent.Lifecycle event immediately before processing the response. - */ - RequestFinished = "FINISHED", - /** - * Fired by SlidingSyncEvent.Lifecycle event immediately after all room data listeners have been - * invoked, but before list listeners. - */ - Complete = "COMPLETE", -} - -/** - * 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. - */ -class SlidingList { - private list!: MSC3575List; - private isModified?: boolean; - - // returned data - public roomIndexToRoomId: Record<number, string> = {}; - public joinedCount = 0; - - /** - * Construct a new sliding list. - * @param list - The range, sort and filter values to use for this list. - */ - public constructor(list: MSC3575List) { - 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. - */ - public setModified(modified: boolean): void { - 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 - */ - public updateListRange(newRanges: number[][]): void { - 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 - */ - public replaceList(list: MSC3575List): void { - 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. - */ - public getList(forceIncludeAllParams: boolean): MSC3575List { - 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 - */ - public isIndexInRange(i: number): boolean { - 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. - */ -export enum ExtensionState { - // Call onResponse before processing the response body. This is useful when your extension is - // preparing the ground for the response body e.g. processing to-device messages before the - // encrypted event arrives. - PreProcess = "ExtState.PreProcess", - // Call onResponse after processing the response body. This is useful when your extension is - // decorating data from the client, and you rely on MatrixClient.getRoom returning the Room object - // e.g. room account data. - PostProcess = "ExtState.PostProcess", -} - -/** - * An interface that must be satisfied to register extensions - */ -export interface Extension<Req extends {}, Res extends {}> { - /** - * The extension name to go under 'extensions' in the request body. - * @returns The JSON key. - */ - name(): string; - /** - * A function which is called when the request JSON is being formed. - * Returns the data to insert under this key. - * @param isInitial - True when this is part of the initial request (send sticky params) - * @returns The request JSON to send. - */ - onRequest(isInitial: boolean): Req | undefined; - /** - * A function which is called when there is response JSON under this extension. - * @param data - The response JSON under the extension name. - */ - onResponse(data: Res): void; - /** - * Controls when onResponse should be called. - * @returns The state when it should be called. - */ - when(): 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) - */ -export enum SlidingSyncEvent { - /** - * This event fires when there are updates for a room. Fired as and when rooms are encountered - * in the response. - */ - RoomData = "SlidingSync.RoomData", - /** - * This event fires at various points in the /sync loop lifecycle. - * - SlidingSyncState.RequestFinished: Fires after we receive a valid response but before the - * response has been processed. Perform any pre-process steps here. If there was a problem syncing, - * `err` will be set (e.g network errors). - * - SlidingSyncState.Complete: Fires after all SlidingSyncEvent.RoomData have been fired but before - * SlidingSyncEvent.List. - */ - Lifecycle = "SlidingSync.Lifecycle", - /** - * This event fires whenever there has been a change to this list index. It fires exactly once - * per list, even if there were multiple operations for the list. - * It fires AFTER Lifecycle and RoomData events. - */ - List = "SlidingSync.List", -} - -export type SlidingSyncEventHandlerMap = { - [SlidingSyncEvent.RoomData]: (roomId: string, roomData: MSC3575RoomData) => void; - [SlidingSyncEvent.Lifecycle]: ( - state: SlidingSyncState, - resp: MSC3575SlidingSyncResponse | null, - err?: Error, - ) => void; - [SlidingSyncEvent.List]: (listKey: string, joinedCount: number, roomIndexToRoomId: Record<number, string>) => void; -}; - -/** - * 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. - */ -export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSyncEventHandlerMap> { - private lists: Map<string, SlidingList>; - private listModifiedCount = 0; - private terminated = false; - // flag set when resend() is called because we cannot rely on detecting AbortError in JS SDK :( - private needsResend = false; - // the txn_id to send with the next request. - private txnId: string | null = 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. - private txnIdDefers: (IDeferred<string> & { txnId: string })[] = []; - // map of extension name to req/resp handler - private extensions: Record<string, Extension<any, any>> = {}; - - private desiredRoomSubscriptions = new Set<string>(); // the *desired* room subscriptions - private confirmedRoomSubscriptions = new Set<string>(); - - // map of custom subscription name to the subscription - private customSubscriptions: Map<string, MSC3575RoomSubscription> = new Map(); - // map of room ID to custom subscription name - private roomIdToCustomSubscription: Map<string, string> = new Map(); - - private pendingReq?: Promise<MSC3575SlidingSyncResponse>; - private abortController?: AbortController; - - /** - * 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. - */ - public constructor( - private readonly proxyBaseUrl: string, - lists: Map<string, MSC3575List>, - private roomSubscriptionInfo: MSC3575RoomSubscription, - private readonly client: MatrixClient, - private readonly timeoutMS: number, - ) { - super(); - this.lists = new Map<string, SlidingList>(); - 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. - */ - public addCustomSubscription(name: string, sub: MSC3575RoomSubscription): void { - if (this.customSubscriptions.has(name)) { - 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. - */ - public useCustomSubscription(roomId: string, name: string): void { - // 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 - */ - public getListData(key: string): { joinedCount: number; roomIndexToRoomId: Record<number, string> } | null { - 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. - */ - public getListParams(key: string): MSC3575List | null { - 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) - */ - public setListRanges(key: string, ranges: number[][]): Promise<string> { - 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) - */ - public setList(key: string, list: MSC3575List): Promise<string> { - 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. - */ - public getRoomSubscriptions(): Set<string> { - 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) - */ - public modifyRoomSubscriptions(s: Set<string>): Promise<string> { - 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) - */ - public modifyRoomSubscriptionInfo(rs: MSC3575RoomSubscription): Promise<string> { - this.roomSubscriptionInfo = rs; - this.confirmedRoomSubscriptions = new Set<string>(); - return this.resend(); - } - - /** - * Register an extension to send with the /sync request. - * @param ext - The extension to register. - */ - public registerExtension(ext: Extension<any, any>): void { - if (this.extensions[ext.name()]) { - throw new Error(`registerExtension: ${ext.name()} already exists as an extension`); - } - this.extensions[ext.name()] = ext; - } - - private getExtensionRequest(isInitial: boolean): Record<string, object | undefined> { - const ext: Record<string, object | undefined> = {}; - Object.keys(this.extensions).forEach((extName) => { - ext[extName] = this.extensions[extName].onRequest(isInitial); - }); - return ext; - } - - private onPreExtensionsResponse(ext: Record<string, object>): void { - Object.keys(ext).forEach((extName) => { - if (this.extensions[extName].when() == ExtensionState.PreProcess) { - this.extensions[extName].onResponse(ext[extName]); - } - }); - } - - private onPostExtensionsResponse(ext: Record<string, object>): void { - 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. - */ - private invokeRoomDataListeners(roomId: string, roomData: MSC3575RoomData): void { - 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. - */ - private invokeLifecycleListeners( - state: SlidingSyncState, - resp: MSC3575SlidingSyncResponse | null, - err?: Error, - ): void { - this.emit(SlidingSyncEvent.Lifecycle, state, resp, err); - } - - private shiftRight(listKey: string, hi: number, low: number): void { - 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]; - } - } - } - - private shiftLeft(listKey: string, hi: number, low: number): void { - 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]; - } - } - } - - private removeEntry(listKey: string, index: number): void { - 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]; - } - - private addEntry(listKey: string, index: number): void { - 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); - } - - private processListOps(list: ListResponse, listKey: string): void { - let gapIndex = -1; - const listData = this.lists.get(listKey); - if (!listData) { - return; - } - list.ops.forEach((op: Operation) => { - if (!listData) { - return; - } - switch (op.op) { - case "DELETE": { - 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.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.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.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. - */ - public resend(): Promise<string> { - 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 = defer<string>(); - this.txnIdDefers.push({ - ...d, - txnId: this.txnId, - }); - this.abortController?.abort(); - this.abortController = new AbortController(); - return d.promise; - } - - private resolveTransactionDefers(txnId?: string): void { - 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.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. - */ - public stop(): void { - 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. - */ - private resetup(): void { - 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<string>(); // 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. - */ - public async start(): Promise<void> { - this.abortController = new AbortController(); - - let currentPos: string | undefined; - while (!this.terminated) { - this.needsResend = false; - let doNotUpdateList = false; - let resp: MSC3575SlidingSyncResponse | undefined; - try { - const listModifiedCount = this.listModifiedCount; - const reqLists: Record<string, MSC3575List> = {}; - this.lists.forEach((l: SlidingList, key: string) => { - reqLists[key] = l.getList(false); - }); - const reqBody: MSC3575SlidingSyncRequest = { - 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.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: string) => { - const list = this.lists.get(key); - if (!list || !resp) { - return; - } - list.joinedCount = resp.lists[key].count; - }); - this.invokeLifecycleListeners(SlidingSyncState.RequestFinished, resp); - } catch (err) { - if ((<HTTPError>err).httpStatus) { - this.invokeLifecycleListeners(SlidingSyncState.RequestFinished, null, <Error>err); - if ((<HTTPError>err).httpStatus === 400) { - // session probably expired TODO: assign an errcode - // so drop state and re-request - this.resetup(); - currentPos = undefined; - await sleep(50); // in case the 400 was for something else; don't tightloop - continue; - } // else fallthrough to generic error handling - } else if (this.needsResend || (<Error>err).name === "AbortError") { - continue; // don't sleep as we caused this error by abort()ing the request. - } - logger.error(err); - await sleep(5000); - } - if (!resp) { - continue; - } - this.onPreExtensionsResponse(resp.extensions); - - Object.keys(resp.rooms).forEach((roomId) => { - this.invokeRoomDataListeners(roomId, resp!.rooms[roomId]); - }); - - const listKeysWithUpdates: Set<string> = 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: string) => { - 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); - } - } -} - -const difference = (setA: Set<string>, setB: Set<string>): Set<string> => { - const diff = new Set(setA); - for (const elem of setB) { - diff.delete(elem); - } - return diff; -}; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/index.ts deleted file mode 100644 index 650dd9a..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/index.ts +++ /dev/null @@ -1,248 +0,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. -*/ - -import { EventType } from "../@types/event"; -import { Room } from "../models/room"; -import { User } from "../models/user"; -import { IEvent, MatrixEvent } from "../models/event"; -import { Filter } from "../filter"; -import { RoomSummary } from "../models/room-summary"; -import { IMinimalEvent, IRooms, ISyncResponse } from "../sync-accumulator"; -import { IStartClientOpts } from "../client"; -import { IStateEventWithRoomId } from "../@types/search"; -import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage"; -import { EventEmitterEvents } from "../models/typed-event-emitter"; - -export interface ISavedSync { - nextBatch: string; - roomsData: IRooms; - accountData: IMinimalEvent[]; -} - -/** - * A store for most of the data js-sdk needs to store, apart from crypto data - */ -export interface IStore { - readonly accountData: Map<string, MatrixEvent>; // type : content - - // XXX: The indexeddb store exposes a non-standard emitter for: - // "degraded" event for when it falls back to being a memory store due to errors. - // "closed" event for when the database closes unexpectedly - on?: (event: EventEmitterEvents | "degraded" | "closed", handler: (...args: any[]) => void) => void; - - /** @returns whether or not the database was newly created in this session. */ - isNewlyCreated(): Promise<boolean>; - - /** - * Get the sync token. - */ - getSyncToken(): string | null; - - /** - * Set the sync token. - */ - setSyncToken(token: string): void; - - /** - * Store the given room. - * @param room - The room to be stored. All properties must be stored. - */ - storeRoom(room: Room): void; - - /** - * Retrieve a room by its' room ID. - * @param roomId - The room ID. - * @returns The room or null. - */ - getRoom(roomId: string): Room | null; - - /** - * Retrieve all known rooms. - * @returns A list of rooms, which may be empty. - */ - getRooms(): Room[]; - - /** - * Permanently delete a room. - */ - removeRoom(roomId: string): void; - - /** - * Retrieve a summary of all the rooms. - * @returns A summary of each room. - */ - getRoomSummaries(): RoomSummary[]; - - /** - * Store a User. - * @param user - The user to store. - */ - storeUser(user: User): void; - - /** - * Retrieve a User by its' user ID. - * @param userId - The user ID. - * @returns The user or null. - */ - getUser(userId: string): User | null; - - /** - * Retrieve all known users. - * @returns A list of users, which may be empty. - */ - getUsers(): User[]; - - /** - * 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: Room, limit: number): MatrixEvent[]; - - /** - * 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: Room, events: MatrixEvent[], token: string | null, toStart: boolean): void; - - /** - * Store a filter. - */ - storeFilter(filter: Filter): void; - - /** - * Retrieve a filter. - * @returns A filter or null. - */ - getFilter(userId: string, filterId: string): Filter | null; - - /** - * Retrieve a filter ID with the given name. - * @param filterName - The filter name. - * @returns The filter ID or null. - */ - getFilterIdByName(filterName: string): string | null; - - /** - * Set a filter name to ID mapping. - */ - setFilterIdByName(filterName: string, filterId?: string): void; - - /** - * Store user-scoped account data events - * @param events - The events to store. - */ - storeAccountDataEvents(events: MatrixEvent[]): void; - - /** - * Get account data event by event type - * @param eventType - The event type being queried - */ - getAccountData(eventType: EventType | string): MatrixEvent | undefined; - - /** - * setSyncData does nothing as there is no backing data store. - * - * @param syncData - The sync data - * @returns An immediately resolved promise. - */ - setSyncData(syncData: ISyncResponse): Promise<void>; - - /** - * We never want to save because we have nothing to save to. - * - * @returns If the store wants to save - */ - wantsSave(): boolean; - - /** - * Save does nothing as there is no backing data store. - */ - save(force?: boolean): void; - - /** - * Startup does nothing. - * @returns An immediately resolved promise. - */ - startup(): Promise<void>; - - /** - * @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(): Promise<ISavedSync | null>; - - /** - * @returns If there is a saved sync, the nextBatch token - * for this sync, otherwise null. - */ - getSavedSyncToken(): Promise<string | null>; - - /** - * Delete all data from this store. Does nothing since this store - * doesn't store anything. - * @returns An immediately resolved promise. - */ - deleteAllData(): Promise<void>; - - /** - * 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: string): Promise<IStateEventWithRoomId[] | 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: string, membershipEvents: IStateEventWithRoomId[]): Promise<void>; - - clearOutOfBandMembers(roomId: string): Promise<void>; - - getClientOptions(): Promise<IStartClientOpts | undefined>; - - storeClientOptions(options: IStartClientOpts): Promise<void>; - - getPendingEvents(roomId: string): Promise<Partial<IEvent>[]>; - - setPendingEvents(roomId: string, events: Partial<IEvent>[]): Promise<void>; - - /** - * Stores batches of outgoing to-device messages - */ - saveToDeviceBatches(batch: ToDeviceBatchWithTxnId[]): Promise<void>; - - /** - * Fetches the oldest batch of to-device messages in the queue - */ - getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch | null>; - - /** - * Removes a specific batch of to-device messages from the queue - */ - removeToDeviceBatch(id: number): Promise<void>; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-backend.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-backend.ts deleted file mode 100644 index 008867d..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-backend.ts +++ /dev/null @@ -1,40 +0,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. -*/ - -import { ISavedSync } from "./index"; -import { IEvent, IStateEventWithRoomId, IStoredClientOpts, ISyncResponse } from "../matrix"; -import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage"; - -export interface IIndexedDBBackend { - connect(onClose?: () => void): Promise<void>; - syncToDatabase(userTuples: UserTuple[]): Promise<void>; - isNewlyCreated(): Promise<boolean>; - setSyncData(syncData: ISyncResponse): Promise<void>; - getSavedSync(): Promise<ISavedSync | null>; - getNextBatchToken(): Promise<string>; - clearDatabase(): Promise<void>; - getOutOfBandMembers(roomId: string): Promise<IStateEventWithRoomId[] | null>; - setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void>; - clearOutOfBandMembers(roomId: string): Promise<void>; - getUserPresenceEvents(): Promise<UserTuple[]>; - getClientOptions(): Promise<IStoredClientOpts | undefined>; - storeClientOptions(options: IStoredClientOpts): Promise<void>; - saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise<void>; - getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch | null>; - removeToDeviceBatch(id: number): Promise<void>; -} - -export type UserTuple = [userId: string, presenceEvent: Partial<IEvent>]; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-local-backend.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-local-backend.ts deleted file mode 100644 index 80fed44..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-local-backend.ts +++ /dev/null @@ -1,597 +0,0 @@ -/* -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. -*/ - -import { IMinimalEvent, ISyncData, ISyncResponse, SyncAccumulator } from "../sync-accumulator"; -import * as utils from "../utils"; -import * as IndexedDBHelpers from "../indexeddb-helpers"; -import { logger } from "../logger"; -import { IStateEventWithRoomId, IStoredClientOpts } from "../matrix"; -import { ISavedSync } from "./index"; -import { IIndexedDBBackend, UserTuple } from "./indexeddb-backend"; -import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage"; - -type DbMigration = (db: IDBDatabase) => void; -const DB_MIGRATIONS: DbMigration[] = [ - (db): void => { - // 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): void => { - const oobMembersStore = db.createObjectStore("oob_membership_events", { - keyPath: ["room_id", "state_key"], - }); - oobMembersStore.createIndex("room", "room_id"); - }, - (db): void => { - db.createObjectStore("client_options", { keyPath: ["clobber"] }); - }, - (db): void => { - 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<T>( - store: IDBObjectStore, - keyRange: IDBKeyRange | IDBValidKey | undefined, - resultMapper: (cursor: IDBCursorWithValue) => T, -): Promise<T[]> { - const query = store.openCursor(keyRange); - return new Promise((resolve, reject) => { - const results: T[] = []; - query.onerror = (): void => { - reject(new Error("Query failed: " + query.error)); - }; - // collect results - query.onsuccess = (): void => { - const cursor = query.result; - if (!cursor) { - resolve(results); - return; // end of results - } - results.push(resultMapper(cursor)); - cursor.continue(); - }; - }); -} - -function txnAsPromise(txn: IDBTransaction): Promise<Event> { - return new Promise((resolve, reject) => { - txn.oncomplete = function (event): void { - resolve(event); - }; - txn.onerror = function (): void { - reject(txn.error); - }; - }); -} - -function reqAsEventPromise(req: IDBRequest): Promise<Event> { - return new Promise((resolve, reject) => { - req.onsuccess = function (event): void { - resolve(event); - }; - req.onerror = function (): void { - reject(req.error); - }; - }); -} - -function reqAsPromise(req: IDBRequest): Promise<IDBRequest> { - return new Promise((resolve, reject) => { - req.onsuccess = (): void => resolve(req); - req.onerror = (err): void => reject(err); - }); -} - -function reqAsCursorPromise<T>(req: IDBRequest<T>): Promise<T> { - return reqAsEventPromise(req).then((event) => req.result); -} - -export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { - public static exists(indexedDB: IDBFactory, dbName: string): Promise<boolean> { - dbName = "matrix-js-sdk:" + (dbName || "default"); - return IndexedDBHelpers.exists(indexedDB, dbName); - } - - private readonly dbName: string; - private readonly syncAccumulator: SyncAccumulator; - private db?: IDBDatabase; - private disconnected = true; - private _isNewlyCreated = false; - private syncToDatabasePromise?: Promise<void>; - private pendingUserPresenceData: UserTuple[] = []; - - /** - * 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. - */ - public constructor(private readonly indexedDB: IDBFactory, dbName = "default") { - this.dbName = "matrix-js-sdk:" + dbName; - this.syncAccumulator = new SyncAccumulator(); - } - - /** - * Attempt to connect to the database. This can fail if the user does not - * grant permission. - * @returns Promise which resolves if successfully connected. - */ - public connect(onClose?: () => void): Promise<void> { - if (!this.disconnected) { - logger.log(`LocalIndexedDBStoreBackend.connect: already connected or connecting`); - return Promise.resolve(); - } - - this.disconnected = false; - - logger.log(`LocalIndexedDBStoreBackend.connect: connecting...`); - const req = this.indexedDB.open(this.dbName, VERSION); - req.onupgradeneeded = (ev): void => { - const db = req.result; - const oldVersion = ev.oldVersion; - 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 = (): void => { - logger.log(`can't yet open LocalIndexedDBStoreBackend because it is open elsewhere`); - }; - - logger.log(`LocalIndexedDBStoreBackend.connect: awaiting connection...`); - return reqAsEventPromise(req).then(async () => { - 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 = (): void => { - this.db?.close(); // this does not call onclose - this.disconnected = true; - this.db = undefined; - onClose?.(); - }; - this.db.onclose = (): void => { - this.disconnected = true; - this.db = undefined; - onClose?.(); - }; - - await this.init(); - }); - } - - /** @returns whether or not the database was newly created in this session. */ - public isNewlyCreated(): Promise<boolean> { - return Promise.resolve(this._isNewlyCreated); - } - - /** - * Having connected, load initial data from the database and prepare for use - * @returns Promise which resolves on success - */ - private init(): Promise<unknown> { - return Promise.all([this.loadAccountData(), this.loadSyncData()]).then(([accountData, syncData]) => { - logger.log(`LocalIndexedDBStoreBackend: loaded initial data`); - this.syncAccumulator.accumulate( - { - next_batch: syncData.nextBatch, - rooms: syncData.roomsData, - 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 - */ - public getOutOfBandMembers(roomId: string): Promise<IStateEventWithRoomId[] | null> { - return new Promise<IStateEventWithRoomId[] | null>((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: IStateEventWithRoomId[] = []; - // 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 = (): void => { - 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): void => { - reject(err); - }; - }).then((events) => { - 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 - */ - public async setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void> { - logger.log(`LL: backend about to store ${membershipEvents.length}` + ` members for ${roomId}`); - const tx = this.db!.transaction(["oob_membership_events"], "readwrite"); - const store = tx.objectStore("oob_membership_events"); - membershipEvents.forEach((e) => { - store.put(e); - }); - // aside from all the events, we also write a marker object to the store - // to mark the fact that OOB members have been written for this room. - // It's possible that 0 members need to be written as all where previously know - // but we still need to know whether to return null or [] from getOutOfBandMembers - // where null means out of band members haven't been stored yet for this room - const markerObject = { - room_id: roomId, - oob_written: true, - state_key: 0, - }; - store.put(markerObject); - await txnAsPromise(tx); - logger.log(`LL: backend done storing for ${roomId}!`); - } - - public async clearOutOfBandMembers(roomId: string): Promise<void> { - // 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) => (<IDBValidKey[]>cursor?.primaryKey)[1], - ); - const maxStateKeyProm = reqAsCursorPromise(roomIndex.openKeyCursor(roomRange, "prev")).then( - (cursor) => (<IDBValidKey[]>cursor?.primaryKey)[1], - ); - const [minStateKey, maxStateKey] = await Promise.all([minStateKeyProm, maxStateKeyProm]); - - const writeTx = this.db!.transaction(["oob_membership_events"], "readwrite"); - const writeStore = writeTx.objectStore("oob_membership_events"); - const membersKeyRange = IDBKeyRange.bound([roomId, minStateKey], [roomId, maxStateKey]); - - logger.log( - `LL: Deleting all users + marker in storage for room ${roomId}, with key range:`, - [roomId, minStateKey], - [roomId, maxStateKey], - ); - await reqAsPromise(writeStore.delete(membersKeyRange)); - } - - /** - * Clear the entire database. This should be used when logging out of a client - * to prevent mixing data between accounts. - * @returns Resolved when the database is cleared. - */ - public clearDatabase(): Promise<void> { - return new Promise((resolve) => { - logger.log(`Removing indexeddb instance: ${this.dbName}`); - const req = this.indexedDB.deleteDatabase(this.dbName); - - req.onblocked = (): void => { - logger.log(`can't yet delete indexeddb ${this.dbName} because it is open elsewhere`); - }; - - req.onerror = (): void => { - // in firefox, with indexedDB disabled, this fails with a - // DOMError. We treat this as non-fatal, so that we can still - // use the app. - logger.warn(`unable to delete js-sdk store indexeddb: ${req.error}`); - resolve(); - }; - - req.onsuccess = (): void => { - 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. - */ - public getSavedSync(copy = true): Promise<ISavedSync | null> { - const data = this.syncAccumulator.getJSON(); - if (!data.nextBatch) return Promise.resolve(null); - if (copy) { - // We must deep copy the stored data so that the /sync processing code doesn't - // corrupt the internal state of the sync accumulator (it adds non-clonable keys) - return Promise.resolve(utils.deepCopy(data)); - } else { - return Promise.resolve(data); - } - } - - public getNextBatchToken(): Promise<string> { - return Promise.resolve(this.syncAccumulator.getNextBatchToken()); - } - - public setSyncData(syncData: ISyncResponse): Promise<void> { - 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. - */ - public async syncToDatabase(userTuples: UserTuple[]): Promise<void> { - if (this.syncToDatabasePromise) { - 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; - } - - private async doSyncToDatabase(userTuples: UserTuple[]): Promise<void> { - 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. - */ - private persistSyncData(nextBatch: string, roomsData: ISyncResponse["rooms"]): Promise<void> { - logger.log("Persisting sync data up to", nextBatch); - return utils.promiseTry<void>(() => { - 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.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. - */ - private persistAccountData(accountData: IMinimalEvent[]): Promise<void> { - return utils.promiseTry<void>(() => { - 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. - */ - private persistUserPresenceEvents(tuples: UserTuple[]): Promise<void> { - return utils.promiseTry<void>(() => { - 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. - */ - public getUserPresenceEvents(): Promise<UserTuple[]> { - return utils.promiseTry<UserTuple[]>(() => { - 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. - */ - private loadAccountData(): Promise<IMinimalEvent[]> { - logger.log(`LocalIndexedDBStoreBackend: loading account data...`); - return utils.promiseTry<IMinimalEvent[]>(() => { - const txn = this.db!.transaction(["accountData"], "readonly"); - const store = txn.objectStore("accountData"); - return selectQuery(store, undefined, (cursor) => { - return cursor.value; - }).then((result: IMinimalEvent[]) => { - logger.log(`LocalIndexedDBStoreBackend: loaded account data`); - return result; - }); - }); - } - - /** - * Load the sync data from the database. - * @returns An object with "roomsData" and "nextBatch" keys. - */ - private loadSyncData(): Promise<ISyncData> { - logger.log(`LocalIndexedDBStoreBackend: loading sync data...`); - return utils.promiseTry<ISyncData>(() => { - const txn = this.db!.transaction(["sync"], "readonly"); - const store = txn.objectStore("sync"); - return selectQuery(store, undefined, (cursor) => { - return cursor.value; - }).then((results: ISyncData[]) => { - logger.log(`LocalIndexedDBStoreBackend: loaded sync data`); - if (results.length > 1) { - logger.warn("loadSyncData: More than 1 sync row found."); - } - return results.length > 0 ? results[0] : ({} as ISyncData); - }); - }); - } - - public getClientOptions(): Promise<IStoredClientOpts | undefined> { - 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]); - }); - } - - public async storeClientOptions(options: IStoredClientOpts): Promise<void> { - 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); - } - - public async saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise<void> { - 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); - } - - public async getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch | null> { - 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 as ToDeviceBatchWithTxnId; - - return { - id: cursor.key as number, - txnId: resultBatch.txnId, - eventType: resultBatch.eventType, - batch: resultBatch.batch, - }; - } - - public async removeToDeviceBatch(id: number): Promise<void> { - const txn = this.db!.transaction(["to_device_queue"], "readwrite"); - const store = txn.objectStore("to_device_queue"); - store.delete(id); - await txnAsPromise(txn); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-remote-backend.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-remote-backend.ts deleted file mode 100644 index 7e2aa0c..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-remote-backend.ts +++ /dev/null @@ -1,203 +0,0 @@ -/* -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. -*/ - -import { logger } from "../logger"; -import { defer, IDeferred } from "../utils"; -import { ISavedSync } from "./index"; -import { IStoredClientOpts } from "../client"; -import { IStateEventWithRoomId, ISyncResponse } from "../matrix"; -import { IIndexedDBBackend, UserTuple } from "./indexeddb-backend"; -import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage"; - -export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend { - private worker?: Worker; - private nextSeq = 0; - // The currently in-flight requests to the actual backend - private inFlight: Record<number, IDeferred<any>> = {}; // seq: promise - // Once we start connecting, we keep the promise and re-use it - // if we try to connect again - private startPromise?: Promise<void>; - // Callback for when the IndexedDB gets closed unexpectedly - private onClose?(): void; - - /** - * 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. - */ - public constructor(private readonly workerFactory: () => Worker, private readonly dbName?: string) {} - - /** - * Attempt to connect to the database. This can fail if the user does not - * grant permission. - * @returns Promise which resolves if successfully connected. - */ - public connect(onClose?: () => void): Promise<void> { - 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. - */ - public clearDatabase(): Promise<void> { - return this.ensureStarted().then(() => this.doCmd("clearDatabase")); - } - - /** @returns whether or not the database was newly created in this session. */ - public isNewlyCreated(): Promise<boolean> { - 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. - */ - public getSavedSync(): Promise<ISavedSync> { - return this.doCmd("getSavedSync"); - } - - public getNextBatchToken(): Promise<string> { - return this.doCmd("getNextBatchToken"); - } - - public setSyncData(syncData: ISyncResponse): Promise<void> { - return this.doCmd("setSyncData", [syncData]); - } - - public syncToDatabase(userTuples: UserTuple[]): Promise<void> { - 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 - */ - public getOutOfBandMembers(roomId: string): Promise<IStateEventWithRoomId[] | null> { - 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 - */ - public setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void> { - return this.doCmd("setOutOfBandMembers", [roomId, membershipEvents]); - } - - public clearOutOfBandMembers(roomId: string): Promise<void> { - return this.doCmd("clearOutOfBandMembers", [roomId]); - } - - public getClientOptions(): Promise<IStoredClientOpts | undefined> { - return this.doCmd("getClientOptions"); - } - - public storeClientOptions(options: IStoredClientOpts): Promise<void> { - 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. - */ - public getUserPresenceEvents(): Promise<UserTuple[]> { - return this.doCmd("getUserPresenceEvents"); - } - - public async saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise<void> { - return this.doCmd("saveToDeviceBatches", [batches]); - } - - public async getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch | null> { - return this.doCmd("getOldestToDeviceBatch"); - } - - public async removeToDeviceBatch(id: number): Promise<void> { - return this.doCmd("removeToDeviceBatch", [id]); - } - - private ensureStarted(): Promise<void> { - 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.log("IndexedDB worker is ready"); - }); - } - return this.startPromise; - } - - private doCmd<T>(command: string, args?: any): Promise<T> { - // wrap in a q so if the postMessage throws, - // the promise automatically gets rejected - return Promise.resolve().then(() => { - const seq = this.nextSeq++; - const def = defer<T>(); - - this.inFlight[seq] = def; - - this.worker?.postMessage({ command, seq, args }); - - return def.promise; - }); - } - - private onWorkerMessage = (ev: MessageEvent): void => { - 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.error("Got reply from worker with no seq"); - return; - } - - const def = this.inFlight[msg.seq]; - if (def === undefined) { - logger.error("Got reply for unknown seq " + msg.seq); - return; - } - delete this.inFlight[msg.seq]; - - if (msg.command == "cmd_success") { - def.resolve(msg.result); - } else { - const error = new Error(msg.error.message); - error.name = msg.error.name; - def.reject(error); - } - } else { - logger.warn("Unrecognised message from worker: ", msg); - } - }; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-store-worker.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-store-worker.ts deleted file mode 100644 index 52a7fa6..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-store-worker.ts +++ /dev/null @@ -1,157 +0,0 @@ -/* -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. -*/ - -import { LocalIndexedDBStoreBackend } from "./indexeddb-local-backend"; -import { logger } from "../logger"; - -interface ICmd { - command: string; - seq: number; - args: any[]; -} - -/** - * 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. - * - */ -export class IndexedDBStoreWorker { - private backend?: LocalIndexedDBStoreBackend; - - /** - * @param postMessage - The web worker postMessage function that - * should be used to communicate back to the main script. - */ - public constructor(private readonly postMessage: InstanceType<typeof Worker>["postMessage"]) {} - - private onClose = (): void => { - 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 - */ - public onMessage = (ev: MessageEvent): void => { - const msg: ICmd = ev.data; - let prom: Promise<any> | undefined; - - 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 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.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, - }, - }); - }, - ); - }; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb.ts deleted file mode 100644 index cc77bf9..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb.ts +++ /dev/null @@ -1,383 +0,0 @@ -/* -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 */ - -import { MemoryStore, IOpts as IBaseOpts } from "./memory"; -import { LocalIndexedDBStoreBackend } from "./indexeddb-local-backend"; -import { RemoteIndexedDBStoreBackend } from "./indexeddb-remote-backend"; -import { User } from "../models/user"; -import { IEvent, MatrixEvent } from "../models/event"; -import { logger } from "../logger"; -import { ISavedSync } from "./index"; -import { IIndexedDBBackend } from "./indexeddb-backend"; -import { ISyncResponse } from "../sync-accumulator"; -import { TypedEventEmitter } from "../models/typed-event-emitter"; -import { IStateEventWithRoomId } from "../@types/search"; -import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage"; -import { IStoredClientOpts } from "../client"; - -/** - * 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 - -interface IOpts extends IBaseOpts { - /** The Indexed DB interface e.g. `window.indexedDB` */ - indexedDB: IDBFactory; - /** Optional database name. The same name must be used to open the same database. */ - dbName?: string; - /** Optional factory to spin up a Worker to execute the IDB transactions within. */ - workerFactory?: () => Worker; -} - -type EventHandlerMap = { - // Fired when an IDB command fails on a degradable path, and the store falls back to MemoryStore - // This signals the potential for data volatility. - degraded: (e: Error) => void; - // Fired when the IndexedDB gets closed unexpectedly, for example, if the underlying storage is removed or - // if the user clears the database in the browser's history preferences. - closed: () => void; -}; - -export class IndexedDBStore extends MemoryStore { - public static exists(indexedDB: IDBFactory, dbName: string): Promise<boolean> { - return LocalIndexedDBStoreBackend.exists(indexedDB, dbName); - } - - /** - * The backend instance. - * Call through to this API if you need to perform specific indexeddb actions like deleting the database. - */ - public readonly backend: IIndexedDBBackend; - - private startedUp = false; - private 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. - private userModifiedMap: Record<string, number> = {}; // user_id : timestamp - private emitter = new TypedEventEmitter<keyof EventHandlerMap, EventHandlerMap>(); - - /** - * 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. - */ - public constructor(opts: IOpts) { - super(opts); - - if (!opts.indexedDB) { - throw new Error("Missing required option: indexedDB"); - } - - if (opts.workerFactory) { - this.backend = new RemoteIndexedDBStoreBackend(opts.workerFactory, opts.dbName); - } else { - this.backend = new LocalIndexedDBStoreBackend(opts.indexedDB, opts.dbName); - } - } - - public on = this.emitter.on.bind(this.emitter); - - /** - * @returns Resolved when loaded from indexed db. - */ - public startup(): Promise<void> { - if (this.startedUp) { - logger.log(`IndexedDBStore.startup: already started`); - return Promise.resolve(); - } - - logger.log(`IndexedDBStore.startup: connecting to backend`); - return this.backend - .connect(this.onClose) - .then(() => { - logger.log(`IndexedDBStore.startup: loading presence events`); - return this.backend.getUserPresenceEvents(); - }) - .then((userPresenceEvents) => { - logger.log(`IndexedDBStore.startup: processing presence events`); - userPresenceEvents.forEach(([userId, rawEvent]) => { - const u = new User(userId); - if (rawEvent) { - u.setPresenceEvent(new MatrixEvent(rawEvent)); - } - this.userModifiedMap[u.userId] = u.getLastModifiedTime(); - this.storeUser(u); - }); - this.startedUp = true; - }); - } - - private onClose = (): void => { - 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. - */ - public getSavedSync = this.degradable((): Promise<ISavedSync | null> => { - return this.backend.getSavedSync(); - }, "getSavedSync"); - - /** @returns whether or not the database was newly created in this session. */ - public isNewlyCreated = this.degradable((): Promise<boolean> => { - return this.backend.isNewlyCreated(); - }, "isNewlyCreated"); - - /** - * @returns If there is a saved sync, the nextBatch token - * for this sync, otherwise null. - */ - public getSavedSyncToken = this.degradable((): Promise<string | null> => { - return this.backend.getNextBatchToken(); - }, "getSavedSyncToken"); - - /** - * Delete all data from this store. - * @returns Promise which resolves if the data was deleted from the database. - */ - public deleteAllData = this.degradable((): Promise<void> => { - super.deleteAllData(); - return this.backend.clearDatabase().then( - () => { - logger.log("Deleted indexeddb data."); - }, - (err) => { - logger.error(`Failed to delete indexeddb data: ${err}`); - throw err; - }, - ); - }); - - /** - * Whether this store would like to save its data - * Note that obviously whether the store wants to save or - * not could change between calling this function and calling - * save(). - * - * @returns True if calling save() will actually save - * (at the time this function is called). - */ - public wantsSave(): boolean { - 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) - */ - public save(force = false): Promise<void> { - if (force || this.wantsSave()) { - return this.reallySave(); - } - return Promise.resolve(); - } - - private reallySave = this.degradable((): Promise<void> => { - 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: [userId: string, presenceEvent: Partial<IEvent>][] = []; - 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); - }); - - public setSyncData = this.degradable((syncData: ISyncResponse): Promise<void> => { - 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 - */ - public getOutOfBandMembers = this.degradable((roomId: string): Promise<IStateEventWithRoomId[] | null> => { - 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 - */ - public setOutOfBandMembers = this.degradable( - (roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void> => { - super.setOutOfBandMembers(roomId, membershipEvents); - return this.backend.setOutOfBandMembers(roomId, membershipEvents); - }, - "setOutOfBandMembers", - ); - - public clearOutOfBandMembers = this.degradable((roomId: string) => { - super.clearOutOfBandMembers(roomId); - return this.backend.clearOutOfBandMembers(roomId); - }, "clearOutOfBandMembers"); - - public getClientOptions = this.degradable((): Promise<IStoredClientOpts | undefined> => { - return this.backend.getClientOptions(); - }, "getClientOptions"); - - public storeClientOptions = this.degradable((options: IStoredClientOpts): Promise<void> => { - super.storeClientOptions(options); - return this.backend.storeClientOptions(options); - }, "storeClientOptions"); - - /** - * All member functions of `IndexedDBStore` that access the backend use this wrapper to - * watch for failures after initial store startup, including `QuotaExceededError` as - * free disk space changes, etc. - * - * When IndexedDB fails via any of these paths, we degrade this back to a `MemoryStore` - * in place so that the current operation and all future ones are in-memory only. - * - * @param func - The degradable work to do. - * @param fallback - The method name for fallback. - * @returns A wrapped member function. - */ - private degradable<A extends Array<any>, R = void>( - func: DegradableFn<A, R>, - fallback?: keyof MemoryStore, - ): DegradableFn<A, R> { - const fallbackFn = fallback ? (super[fallback] as Function) : null; - - return async (...args) => { - try { - return await func.call(this, ...args); - } catch (e) { - logger.error("IndexedDBStore failure, degrading to MemoryStore", e); - this.emitter.emit("degraded", e as Error); - 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.log("IndexedDBStore trying to delete degraded data"); - await this.backend.clearDatabase(); - logger.log("IndexedDBStore delete after degrading succeeded"); - } catch (e) { - logger.warn("IndexedDBStore delete after degrading failed", e); - } - // Degrade the store from being an instance of `IndexedDBStore` to instead be - // an instance of `MemoryStore` so that future API calls use the memory path - // directly and skip IndexedDB entirely. This should be safe as - // `IndexedDBStore` already extends from `MemoryStore`, so we are making the - // store become its parent type in a way. The mutator methods of - // `IndexedDBStore` also maintain the state that `MemoryStore` uses (many are - // not overridden at all). - 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. - public async getPendingEvents(roomId: string): Promise<Partial<IEvent>[]> { - if (!this.localStorage) return super.getPendingEvents(roomId); - - const serialized = this.localStorage.getItem(pendingEventsKey(roomId)); - if (serialized) { - try { - return JSON.parse(serialized); - } catch (e) { - logger.error("Could not parse persisted pending events", e); - } - } - return []; - } - - public async setPendingEvents(roomId: string, events: Partial<IEvent>[]): Promise<void> { - 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)); - } - } - - public saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise<void> { - return this.backend.saveToDeviceBatches(batches); - } - - public getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch | null> { - return this.backend.getOldestToDeviceBatch(); - } - - public removeToDeviceBatch(id: number): Promise<void> { - return this.backend.removeToDeviceBatch(id); - } -} - -/** - * @param roomId - ID of the current room - * @returns Storage key to retrieve pending events - */ -function pendingEventsKey(roomId: string): string { - return `mx_pending_events_${roomId}`; -} - -type DegradableFn<A extends Array<any>, T> = (...args: A) => Promise<T>; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/local-storage-events-emitter.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/local-storage-events-emitter.ts deleted file mode 100644 index adb70cb..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/local-storage-events-emitter.ts +++ /dev/null @@ -1,46 +0,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. -*/ - -import { TypedEventEmitter } from "../models/typed-event-emitter"; - -export enum LocalStorageErrors { - Global = "Global", - SetItemError = "setItem", - GetItemError = "getItem", - RemoveItemError = "removeItem", - ClearError = "clear", - QuotaExceededError = "QuotaExceededError", -} - -type EventHandlerMap = { - [LocalStorageErrors.Global]: (error: Error) => void; - [LocalStorageErrors.SetItemError]: (error: Error) => void; - [LocalStorageErrors.GetItemError]: (error: Error) => void; - [LocalStorageErrors.RemoveItemError]: (error: Error) => void; - [LocalStorageErrors.ClearError]: (error: Error) => void; - [LocalStorageErrors.QuotaExceededError]: (error: Error) => void; -}; - -/** - * 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<LocalStorageErrors, EventHandlerMap> {} -export const localStorageErrorsEventsEmitter = new LocalStorageErrorsEventsEmitter(); diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/memory.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/memory.ts deleted file mode 100644 index d859ddd..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/memory.ts +++ /dev/null @@ -1,436 +0,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. -*/ - -/** - * This is an internal module. See {@link MemoryStore} for the public class. - */ - -import { EventType } from "../@types/event"; -import { Room } from "../models/room"; -import { User } from "../models/user"; -import { IEvent, MatrixEvent } from "../models/event"; -import { RoomState, RoomStateEvent } from "../models/room-state"; -import { RoomMember } from "../models/room-member"; -import { Filter } from "../filter"; -import { ISavedSync, IStore } from "./index"; -import { RoomSummary } from "../models/room-summary"; -import { ISyncResponse } from "../sync-accumulator"; -import { IStateEventWithRoomId } from "../@types/search"; -import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage"; -import { IStoredClientOpts } from "../client"; -import { MapWithDefault } from "../utils"; - -function isValidFilterId(filterId?: string | number | null): boolean { - 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"; -} - -export interface IOpts { - /** The local storage instance to persist some forms of data such as tokens. Rooms will NOT be stored. */ - localStorage?: Storage; -} - -export class MemoryStore implements IStore { - private rooms: Record<string, Room> = {}; // roomId: Room - private users: Record<string, User> = {}; // userId: User - private syncToken: string | null = null; - // userId: { - // filterId: Filter - // } - private filters: MapWithDefault<string, Map<string, Filter>> = new MapWithDefault(() => new Map()); - public accountData: Map<string, MatrixEvent> = new Map(); // type: content - protected readonly localStorage?: Storage; - private oobMembers: Map<string, IStateEventWithRoomId[]> = new Map(); // roomId: [member events] - private pendingEvents: { [roomId: string]: Partial<IEvent>[] } = {}; - private clientOptions?: IStoredClientOpts; - private pendingToDeviceBatches: IndexedToDeviceBatch[] = []; - private nextToDeviceBatchId = 0; - - /** - * Construct a new in-memory data store for the Matrix Client. - * @param opts - Config options - */ - public constructor(opts: IOpts = {}) { - this.localStorage = opts.localStorage; - } - - /** - * Retrieve the token to stream from. - * @returns The token or null. - */ - public getSyncToken(): string | null { - return this.syncToken; - } - - /** @returns whether or not the database was newly created in this session. */ - public isNewlyCreated(): Promise<boolean> { - return Promise.resolve(true); - } - - /** - * Set the token to stream from. - * @param token - The token to stream from. - */ - public setSyncToken(token: string): void { - this.syncToken = token; - } - - /** - * Store the given room. - * @param room - The room to be stored. All properties must be stored. - */ - public storeRoom(room: Room): void { - 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(RoomStateEvent.Members, this.onRoomMember); - // add existing members - room.currentState.getMembers().forEach((m) => { - this.onRoomMember(null, room.currentState, m); - }); - } - - /** - * Called when a room member in a room being tracked by this store has been - * updated. - */ - private onRoomMember = (event: MatrixEvent | null, state: RoomState, member: RoomMember): void => { - if (member.membership === "invite") { - // We do NOT add invited members because people love to typo user IDs - // which would then show up in these lists (!) - return; - } - - const user = this.users[member.userId] || new User(member.userId); - if (member.name) { - user.setDisplayName(member.name); - if (member.events.member) { - user.setRawDisplayName(member.events.member.getDirectionalContent().displayname); - } - } - if (member.events.member && member.events.member.getContent().avatar_url) { - user.setAvatarUrl(member.events.member.getContent().avatar_url); - } - this.users[user.userId] = user; - }; - - /** - * Retrieve a room by its' room ID. - * @param roomId - The room ID. - * @returns The room or null. - */ - public getRoom(roomId: string): Room | null { - return this.rooms[roomId] || null; - } - - /** - * Retrieve all known rooms. - * @returns A list of rooms, which may be empty. - */ - public getRooms(): Room[] { - return Object.values(this.rooms); - } - - /** - * Permanently delete a room. - */ - public removeRoom(roomId: string): void { - if (this.rooms[roomId]) { - this.rooms[roomId].currentState.removeListener(RoomStateEvent.Members, this.onRoomMember); - } - delete this.rooms[roomId]; - } - - /** - * Retrieve a summary of all the rooms. - * @returns A summary of each room. - */ - public getRoomSummaries(): RoomSummary[] { - return Object.values(this.rooms).map(function (room) { - return room.summary!; - }); - } - - /** - * Store a User. - * @param user - The user to store. - */ - public storeUser(user: User): void { - this.users[user.userId] = user; - } - - /** - * Retrieve a User by its' user ID. - * @param userId - The user ID. - * @returns The user or null. - */ - public getUser(userId: string): User | null { - return this.users[userId] || null; - } - - /** - * Retrieve all known users. - * @returns A list of users, which may be empty. - */ - public getUsers(): User[] { - 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. - */ - public scrollback(room: Room, limit: number): MatrixEvent[] { - 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. - */ - public storeEvents(room: Room, events: MatrixEvent[], token: string | null, toStart: boolean): void { - // no-op because they've already been added to the room instance. - } - - /** - * Store a filter. - */ - public storeFilter(filter: Filter): void { - if (!filter?.userId || !filter?.filterId) return; - this.filters.getOrCreate(filter.userId).set(filter.filterId, filter); - } - - /** - * Retrieve a filter. - * @returns A filter or null. - */ - public getFilter(userId: string, filterId: string): Filter | null { - 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. - */ - public getFilterIdByName(filterName: string): string | null { - 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. - */ - public setFilterIdByName(filterName: string, filterId?: string): void { - 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. - */ - public storeAccountDataEvents(events: MatrixEvent[]): void { - 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 - */ - public getAccountData(eventType: EventType | string): MatrixEvent | undefined { - 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. - */ - public setSyncData(syncData: ISyncResponse): Promise<void> { - return Promise.resolve(); - } - - /** - * We never want to save becase we have nothing to save to. - * - * @returns If the store wants to save - */ - public wantsSave(): boolean { - 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) - */ - public save(force: boolean): void {} - - /** - * Startup does nothing as this store doesn't require starting up. - * @returns An immediately resolved promise. - */ - public startup(): Promise<void> { - 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. - */ - public getSavedSync(): Promise<ISavedSync | null> { - return Promise.resolve(null); - } - - /** - * @returns If there is a saved sync, the nextBatch token - * for this sync, otherwise null. - */ - public getSavedSyncToken(): Promise<string | null> { - return Promise.resolve(null); - } - - /** - * Delete all data from this store. - * @returns An immediately resolved promise. - */ - public deleteAllData(): Promise<void> { - this.rooms = { - // roomId: Room - }; - this.users = { - // userId: User - }; - this.syncToken = null; - this.filters = new 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 - */ - public getOutOfBandMembers(roomId: string): Promise<IStateEventWithRoomId[] | null> { - 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 - */ - public setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void> { - this.oobMembers.set(roomId, membershipEvents); - return Promise.resolve(); - } - - public clearOutOfBandMembers(roomId: string): Promise<void> { - this.oobMembers.delete(roomId); - return Promise.resolve(); - } - - public getClientOptions(): Promise<IStoredClientOpts | undefined> { - return Promise.resolve(this.clientOptions); - } - - public storeClientOptions(options: IStoredClientOpts): Promise<void> { - this.clientOptions = Object.assign({}, options); - return Promise.resolve(); - } - - public async getPendingEvents(roomId: string): Promise<Partial<IEvent>[]> { - return this.pendingEvents[roomId] ?? []; - } - - public async setPendingEvents(roomId: string, events: Partial<IEvent>[]): Promise<void> { - this.pendingEvents[roomId] = events; - } - - public saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise<void> { - for (const batch of batches) { - this.pendingToDeviceBatches.push({ - id: this.nextToDeviceBatchId++, - eventType: batch.eventType, - txnId: batch.txnId, - batch: batch.batch, - }); - } - return Promise.resolve(); - } - - public async getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch | null> { - if (this.pendingToDeviceBatches.length === 0) return null; - return this.pendingToDeviceBatches[0]; - } - - public removeToDeviceBatch(id: number): Promise<void> { - this.pendingToDeviceBatches = this.pendingToDeviceBatches.filter((batch) => batch.id !== id); - return Promise.resolve(); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/stub.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/stub.ts deleted file mode 100644 index e4402ed..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/stub.ts +++ /dev/null @@ -1,267 +0,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. -*/ - -/** - * This is an internal module. - */ - -import { EventType } from "../@types/event"; -import { Room } from "../models/room"; -import { User } from "../models/user"; -import { IEvent, MatrixEvent } from "../models/event"; -import { Filter } from "../filter"; -import { ISavedSync, IStore } from "./index"; -import { RoomSummary } from "../models/room-summary"; -import { ISyncResponse } from "../sync-accumulator"; -import { IStateEventWithRoomId } from "../@types/search"; -import { IndexedToDeviceBatch, ToDeviceBatch } from "../models/ToDeviceMessage"; -import { IStoredClientOpts } from "../client"; - -/** - * Construct a stub store. This does no-ops on most store methods. - */ -export class StubStore implements IStore { - public readonly accountData = new Map(); // stub - private fromToken: string | null = null; - - /** @returns whether or not the database was newly created in this session. */ - public isNewlyCreated(): Promise<boolean> { - return Promise.resolve(true); - } - - /** - * Get the sync token. - */ - public getSyncToken(): string | null { - return this.fromToken; - } - - /** - * Set the sync token. - */ - public setSyncToken(token: string): void { - this.fromToken = token; - } - - /** - * No-op. - */ - public storeRoom(room: Room): void {} - - /** - * No-op. - */ - public getRoom(roomId: string): Room | null { - return null; - } - - /** - * No-op. - * @returns An empty array. - */ - public getRooms(): Room[] { - return []; - } - - /** - * Permanently delete a room. - */ - public removeRoom(roomId: string): void { - return; - } - - /** - * No-op. - * @returns An empty array. - */ - public getRoomSummaries(): RoomSummary[] { - return []; - } - - /** - * No-op. - */ - public storeUser(user: User): void {} - - /** - * No-op. - */ - public getUser(userId: string): User | null { - return null; - } - - /** - * No-op. - */ - public getUsers(): User[] { - return []; - } - - /** - * No-op. - */ - public scrollback(room: Room, limit: number): MatrixEvent[] { - 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. - */ - public storeEvents(room: Room, events: MatrixEvent[], token: string | null, toStart: boolean): void {} - - /** - * Store a filter. - */ - public storeFilter(filter: Filter): void {} - - /** - * Retrieve a filter. - * @returns A filter or null. - */ - public getFilter(userId: string, filterId: string): Filter | null { - return null; - } - - /** - * Retrieve a filter ID with the given name. - * @param filterName - The filter name. - * @returns The filter ID or null. - */ - public getFilterIdByName(filterName: string): string | null { - return null; - } - - /** - * Set a filter name to ID mapping. - */ - public setFilterIdByName(filterName: string, filterId?: string): void {} - - /** - * Store user-scoped account data events - * @param events - The events to store. - */ - public storeAccountDataEvents(events: MatrixEvent[]): void {} - - /** - * Get account data event by event type - * @param eventType - The event type being queried - */ - public getAccountData(eventType: EventType | string): MatrixEvent | undefined { - return undefined; - } - - /** - * setSyncData does nothing as there is no backing data store. - * - * @param syncData - The sync data - * @returns An immediately resolved promise. - */ - public setSyncData(syncData: ISyncResponse): Promise<void> { - return Promise.resolve(); - } - - /** - * We never want to save because we have nothing to save to. - * - * @returns If the store wants to save - */ - public wantsSave(): boolean { - return false; - } - - /** - * Save does nothing as there is no backing data store. - */ - public save(): void {} - - /** - * Startup does nothing. - * @returns An immediately resolved promise. - */ - public startup(): Promise<void> { - 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. - */ - public getSavedSync(): Promise<ISavedSync | null> { - return Promise.resolve(null); - } - - /** - * @returns If there is a saved sync, the nextBatch token - * for this sync, otherwise null. - */ - public getSavedSyncToken(): Promise<string | null> { - return Promise.resolve(null); - } - - /** - * Delete all data from this store. Does nothing since this store - * doesn't store anything. - * @returns An immediately resolved promise. - */ - public deleteAllData(): Promise<void> { - return Promise.resolve(); - } - - public getOutOfBandMembers(): Promise<IStateEventWithRoomId[] | null> { - return Promise.resolve(null); - } - - public setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void> { - return Promise.resolve(); - } - - public clearOutOfBandMembers(): Promise<void> { - return Promise.resolve(); - } - - public getClientOptions(): Promise<IStoredClientOpts | undefined> { - return Promise.resolve(undefined); - } - - public storeClientOptions(options: IStoredClientOpts): Promise<void> { - return Promise.resolve(); - } - - public async getPendingEvents(roomId: string): Promise<Partial<IEvent>[]> { - return []; - } - - public setPendingEvents(roomId: string, events: Partial<IEvent>[]): Promise<void> { - return Promise.resolve(); - } - - public async saveToDeviceBatches(batch: ToDeviceBatch[]): Promise<void> { - return Promise.resolve(); - } - - public getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch | null> { - return Promise.resolve(null); - } - - public async removeToDeviceBatch(id: number): Promise<void> { - return Promise.resolve(); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/sync-accumulator.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/sync-accumulator.ts deleted file mode 100644 index fef03d7..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/sync-accumulator.ts +++ /dev/null @@ -1,715 +0,0 @@ -/* -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 is an internal module. See {@link SyncAccumulator} for the public class. - */ - -import { logger } from "./logger"; -import { deepCopy, isSupportedReceiptType, MapWithDefault, recursiveMapToObject } from "./utils"; -import { IContent, IUnsigned } from "./models/event"; -import { IRoomSummary } from "./models/room-summary"; -import { EventType } from "./@types/event"; -import { MAIN_ROOM_TIMELINE, ReceiptContent, ReceiptType } from "./@types/read_receipts"; -import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync"; - -interface IOpts { - /** - * The ideal maximum number of timeline entries to keep in the sync response. - * This is best-effort, as clients do not always have a back-pagination token for each event, - * so it's possible there may be slightly *less* than this value. There will never be more. - * This cannot be 0 or else it makes it impossible to scroll back in a room. - * Default: 50. - */ - maxTimelineEntries?: number; -} - -export interface IMinimalEvent { - content: IContent; - type: EventType | string; - unsigned?: IUnsigned; -} - -export interface IEphemeral { - events: IMinimalEvent[]; -} - -/* eslint-disable camelcase */ -interface UnreadNotificationCounts { - highlight_count?: number; - notification_count?: number; -} - -export interface IRoomEvent extends IMinimalEvent { - event_id: string; - sender: string; - origin_server_ts: number; - /** @deprecated - legacy field */ - age?: number; -} - -export interface IStateEvent extends IRoomEvent { - prev_content?: IContent; - state_key: string; -} - -interface IState { - events: IStateEvent[]; -} - -export interface ITimeline { - events: Array<IRoomEvent | IStateEvent>; - limited?: boolean; - prev_batch: string | null; -} - -export interface IJoinedRoom { - "summary": IRoomSummary; - "state": IState; - "timeline": ITimeline; - "ephemeral": IEphemeral; - "account_data": IAccountData; - "unread_notifications": UnreadNotificationCounts; - "unread_thread_notifications"?: Record<string, UnreadNotificationCounts>; - "org.matrix.msc3773.unread_thread_notifications"?: Record<string, UnreadNotificationCounts>; -} - -export interface IStrippedState { - content: IContent; - state_key: string; - type: EventType | string; - sender: string; -} - -export interface IInviteState { - events: IStrippedState[]; -} - -export interface IInvitedRoom { - invite_state: IInviteState; -} - -export interface ILeftRoom { - state: IState; - timeline: ITimeline; - account_data: IAccountData; -} - -export interface IRooms { - [Category.Join]: Record<string, IJoinedRoom>; - [Category.Invite]: Record<string, IInvitedRoom>; - [Category.Leave]: Record<string, ILeftRoom>; -} - -interface IPresence { - events: IMinimalEvent[]; -} - -interface IAccountData { - events: IMinimalEvent[]; -} - -export interface IToDeviceEvent { - content: IContent; - sender: string; - type: string; -} - -interface IToDevice { - events: IToDeviceEvent[]; -} - -interface IDeviceLists { - changed?: string[]; - left?: string[]; -} - -export interface ISyncResponse { - "next_batch": string; - "rooms": IRooms; - "presence"?: IPresence; - "account_data": IAccountData; - "to_device"?: IToDevice; - "device_lists"?: IDeviceLists; - "device_one_time_keys_count"?: Record<string, number>; - - "device_unused_fallback_key_types"?: string[]; - "org.matrix.msc2732.device_unused_fallback_key_types"?: string[]; -} -/* eslint-enable camelcase */ - -export enum Category { - Invite = "invite", - Leave = "leave", - Join = "join", -} - -interface IRoom { - _currentState: { [eventType: string]: { [stateKey: string]: IStateEvent } }; - _timeline: { - event: IRoomEvent | IStateEvent; - token: string | null; - }[]; - _summary: Partial<IRoomSummary>; - _accountData: { [eventType: string]: IMinimalEvent }; - _unreadNotifications: Partial<UnreadNotificationCounts>; - _unreadThreadNotifications?: Record<string, Partial<UnreadNotificationCounts>>; - _readReceipts: { - [userId: string]: { - data: IMinimalEvent; - type: ReceiptType; - eventId: string; - }; - }; - _threadReadReceipts: { - [threadId: string]: { - [userId: string]: { - data: IMinimalEvent; - type: ReceiptType; - eventId: string; - }; - }; - }; -} - -export interface ISyncData { - nextBatch: string; - accountData: IMinimalEvent[]; - roomsData: IRooms; -} - -type TaggedEvent = IRoomEvent & { _localTs?: number }; - -function isTaggedEvent(event: IRoomEvent): event is TaggedEvent { - 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. - */ -export class SyncAccumulator { - private accountData: Record<string, IMinimalEvent> = {}; // $event_type: Object - private inviteRooms: Record<string, IInvitedRoom> = {}; // $roomId: { ... sync 'invite' json data ... } - private joinRooms: { [roomId: string]: IRoom } = {}; - // 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. - private nextBatch: string | null = null; - - public constructor(private readonly opts: IOpts = {}) { - this.opts.maxTimelineEntries = this.opts.maxTimelineEntries || 50; - } - - public accumulate(syncResponse: ISyncResponse, fromDatabase = false): void { - this.accumulateRooms(syncResponse, fromDatabase); - this.accumulateAccountData(syncResponse); - this.nextBatch = syncResponse.next_batch; - } - - private accumulateAccountData(syncResponse: ISyncResponse): void { - 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 - */ - private accumulateRooms(syncResponse: ISyncResponse, fromDatabase = false): void { - 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); - }); - } - } - - private accumulateRoom(roomId: string, category: Category.Invite, data: IInvitedRoom, fromDatabase: boolean): void; - private accumulateRoom(roomId: string, category: Category.Join, data: IJoinedRoom, fromDatabase: boolean): void; - private accumulateRoom(roomId: string, category: Category.Leave, data: ILeftRoom, fromDatabase: boolean): void; - private accumulateRoom(roomId: string, category: Category, data: any, fromDatabase = false): void { - // 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 as IInvitedRoom); - 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 as IJoinedRoom, fromDatabase); - break; - - case Category.Leave: - if (this.inviteRooms[roomId]) { - // (4) - delete this.inviteRooms[roomId]; - } else { - // (2) - delete this.joinRooms[roomId]; - } - break; - - default: - logger.error("Unknown cateogory: ", category); - } - } - - private accumulateInviteState(roomId: string, data: IInvitedRoom): void { - 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. - private accumulateJoinState(roomId: string, data: IJoinedRoom, fromDatabase = false): void { - // 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: {}, - _readReceipts: {}, - _threadReadReceipts: {}, - }; - } - 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[UNREAD_THREAD_NOTIFICATIONS.stable!] ?? data[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]; - } - - data.ephemeral?.events?.forEach((e) => { - // We purposefully do not persist m.typing events. - // Technically you could refresh a browser before the timer on a - // typing event is up, so it'll look like you aren't typing when - // you really still are. However, the alternative is worse. If - // we do persist typing events, it will look like people are - // typing forever until someone really does start typing (which - // will prompt Synapse to send down an actual m.typing event to - // clobber the one we persisted). - if (e.type !== 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<ReceiptContent>(e.content[eventId]).forEach(([key, value]) => { - if (!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 as ReceiptType, - eventId: eventId, - }; - - if (!data.thread_id || data.thread_id === MAIN_ROOM_TIMELINE) { - currentData._readReceipts[userId] = receipt; - } else { - currentData._threadReadReceipts = { - ...currentData._threadReadReceipts, - [data.thread_id]: { - ...(currentData._threadReadReceipts[data.thread_id] ?? {}), - [userId]: receipt, - }, - }; - } - } - }); - }); - }); - - // 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: TaggedEvent; - 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. - */ - public getJSON(forDatabase = false): ISyncData { - const data: IRooms = { - 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: IJoinedRoom = { - ephemeral: { events: [] }, - account_data: { events: [] }, - state: { events: [] }, - timeline: { - events: [], - prev_batch: null, - }, - unread_notifications: roomData._unreadNotifications, - unread_thread_notifications: roomData._unreadThreadNotifications, - summary: roomData._summary as IRoomSummary, - }; - // Add account data - Object.keys(roomData._accountData).forEach((evType) => { - roomJson.account_data.events.push(roomData._accountData[evType]); - }); - - // Add receipt data - const receiptEvent = { - type: EventType.Receipt, - room_id: roomId, - content: { - // $event_id: { "m.read": { $user_id: $json } } - } as IContent, - }; - - const receiptEventContent: MapWithDefault< - string, - MapWithDefault<ReceiptType, Map<string, object>> - > = new MapWithDefault(() => new MapWithDefault(() => new Map())); - - for (const [userId, receiptData] of Object.entries(roomData._readReceipts)) { - receiptEventContent - .getOrCreate(receiptData.eventId) - .getOrCreate(receiptData.type) - .set(userId, receiptData.data); - } - - for (const threadReceipts of Object.values(roomData._threadReadReceipts)) { - for (const [userId, receiptData] of Object.entries(threadReceipts)) { - receiptEventContent - .getOrCreate(receiptData.eventId) - .getOrCreate(receiptData.type) - .set(userId, receiptData.data); - } - } - - receiptEvent.content = recursiveMapToObject(receiptEventContent); - - // add only if we have some receipt data - if (receiptEventContent.size > 0) { - roomJson.ephemeral.events.push(receiptEvent as IMinimalEvent); - } - - // 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: (IRoomEvent | IStateEvent) & { _localTs?: number }; - 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 as IStateEvent).state_key === null || - (timelineEvent as IStateEvent).state_key === undefined - ) { - continue; // not a state event - } - // since we're going back in time, we need to use the previous - // state value else we'll break causality. We don't have the - // complete previous state event, so we need to create one. - const prevStateEvent = deepCopy(timelineEvent); - if (prevStateEvent.unsigned) { - if (prevStateEvent.unsigned.prev_content) { - prevStateEvent.content = prevStateEvent.unsigned.prev_content; - } - if (prevStateEvent.unsigned.prev_sender) { - prevStateEvent.sender = prevStateEvent.unsigned.prev_sender; - } - } - setState(rollBackState, prevStateEvent); - } - Object.keys(roomData._currentState).forEach((evType) => { - Object.keys(roomData._currentState[evType]).forEach((stateKey) => { - let ev = roomData._currentState[evType][stateKey]; - if (rollBackState[evType] && rollBackState[evType][stateKey]) { - // use the reverse clobbered event instead. - ev = rollBackState[evType][stateKey]; - } - roomJson.state.events.push(ev); - }); - }); - data.join[roomId] = roomJson; - }); - - // Add account data - const accData: IMinimalEvent[] = []; - Object.keys(this.accountData).forEach((evType) => { - accData.push(this.accountData[evType]); - }); - - return { - nextBatch: this.nextBatch!, - roomsData: data, - accountData: accData, - }; - } - - public getNextBatchToken(): string { - return this.nextBatch!; - } -} - -function setState(eventMap: Record<string, Record<string, IStateEvent>>, event: IRoomEvent | IStateEvent): void { - if ((event as IStateEvent).state_key === null || (event as IStateEvent).state_key === undefined || !event.type) { - return; - } - if (!eventMap[event.type]) { - eventMap[event.type] = Object.create(null); - } - eventMap[event.type][(event as IStateEvent).state_key] = event as IStateEvent; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/sync.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/sync.ts deleted file mode 100644 index dc5217c..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/sync.ts +++ /dev/null @@ -1,1898 +0,0 @@ -/* -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. - */ - -import { Optional } from "matrix-events-sdk"; - -import type { SyncCryptoCallbacks } from "./common-crypto/CryptoBackend"; -import { User, UserEvent } from "./models/user"; -import { NotificationCountType, Room, RoomEvent } from "./models/room"; -import * as utils from "./utils"; -import { IDeferred, noUnsafeEventProps, unsafeProp } from "./utils"; -import { Filter } from "./filter"; -import { EventTimeline } from "./models/event-timeline"; -import { logger } from "./logger"; -import { InvalidStoreError, InvalidStoreState } from "./errors"; -import { ClientEvent, IStoredClientOpts, MatrixClient, PendingEventOrdering, ResetTimelineCallback } from "./client"; -import { - IEphemeral, - IInvitedRoom, - IInviteState, - IJoinedRoom, - ILeftRoom, - IMinimalEvent, - IRoomEvent, - IStateEvent, - IStrippedState, - ISyncResponse, - ITimeline, - IToDeviceEvent, -} from "./sync-accumulator"; -import { MatrixEvent } from "./models/event"; -import { MatrixError, Method } from "./http-api"; -import { ISavedSync } from "./store"; -import { EventType } from "./@types/event"; -import { IPushRules } from "./@types/PushRules"; -import { RoomStateEvent, IMarkerFoundOptions } from "./models/room-state"; -import { RoomMemberEvent } from "./models/room-member"; -import { BeaconEvent } from "./models/beacon"; -import { IEventsResponse } from "./@types/requests"; -import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync"; -import { Feature, ServerSupport } from "./feature"; -import { Crypto } from "./crypto"; - -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; - -export enum SyncState { - /** Emitted after we try to sync more than `FAILED_SYNC_ERROR_THRESHOLD` - * times and are still failing. Or when we enounter a hard error like the - * token being invalid. */ - Error = "ERROR", - /** Emitted after the first sync events are ready (this could even be sync - * events from the cache) */ - Prepared = "PREPARED", - /** Emitted when the sync loop is no longer running */ - Stopped = "STOPPED", - /** Emitted after each sync request happens */ - Syncing = "SYNCING", - /** Emitted after a connectivity error and we're ready to start syncing again */ - Catchup = "CATCHUP", - /** Emitted for each time we try reconnecting. Will switch to `Error` after - * we reach the `FAILED_SYNC_ERROR_THRESHOLD` - */ - Reconnecting = "RECONNECTING", -} - -// 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. -const MSC2716_ROOM_VERSIONS = ["org.matrix.msc2716v3"]; - -function getFilterName(userId: string, suffix?: string): string { - // 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: any[]): void { - if (!DEBUG) return; - logger.log(...params); -} - -/** - * Options passed into the constructor of SyncApi by MatrixClient - */ -export interface SyncApiOptions { - /** - * Crypto manager - * - * @deprecated in favour of cryptoCallbacks - */ - crypto?: Crypto; - - /** - * If crypto is enabled on our client, callbacks into the crypto module - */ - cryptoCallbacks?: SyncCryptoCallbacks; - - /** - * A function which is called - * with a room ID and returns a boolean. It should return 'true' if the SDK can - * SAFELY remove events from this room. It may not be safe to remove events if - * there are other references to the timelines for this room. - */ - canResetEntireTimeline?: ResetTimelineCallback; -} - -interface ISyncOptions { - filter?: string; - hasSyncedBefore?: boolean; -} - -export interface ISyncStateData { - /** - * The matrix error if `state=ERROR`. - */ - error?: Error; - /** - * The 'since' token passed to /sync. - * `null` for the first successful sync since this client was - * started. Only present if `state=PREPARED` or - * `state=SYNCING`. - */ - oldSyncToken?: string; - /** - * The 'next_batch' result from /sync, which - * will become the 'since' token for the next call to /sync. Only present if - * `state=PREPARED</code> or <code>state=SYNCING`. - */ - nextSyncToken?: string; - /** - * True if we are working our way through a - * backlog of events after connecting. Only present if `state=SYNCING`. - */ - catchingUp?: boolean; - fromCache?: boolean; -} - -enum SetPresence { - Offline = "offline", - Online = "online", - Unavailable = "unavailable", -} - -interface ISyncParams { - filter?: string; - timeout: number; - since?: string; - // eslint-disable-next-line camelcase - full_state?: boolean; - // eslint-disable-next-line camelcase - set_presence?: SetPresence; - _cacheBuster?: string | number; // not part of the API itself -} - -type WrappedRoom<T> = T & { - room: Room; - isBrandNewRoom: boolean; -}; - -/** add default settings to an IStoredClientOpts */ -export function defaultClientOpts(opts?: IStoredClientOpts): IStoredClientOpts { - return { - initialSyncLimit: 8, - resolveInvitesToProfiles: false, - pollTimeout: 30 * 1000, - pendingEventOrdering: PendingEventOrdering.Chronological, - threadSupport: false, - ...opts, - }; -} - -export function defaultSyncApiOpts(syncOpts?: SyncApiOptions): SyncApiOptions { - return { - canResetEntireTimeline: (_roomId): boolean => false, - ...syncOpts, - }; -} - -export class SyncApi { - private readonly opts: IStoredClientOpts; - private readonly syncOpts: SyncApiOptions; - - private _peekRoom: Optional<Room> = null; - private currentSyncRequest?: Promise<ISyncResponse>; - private abortController?: AbortController; - private syncState: SyncState | null = null; - private syncStateData?: ISyncStateData; // additional data (eg. error object for failed sync) - private catchingUp = false; - private running = false; - private keepAliveTimer?: ReturnType<typeof setTimeout>; - private connectionReturnedDefer?: IDeferred<boolean>; - private notifEvents: MatrixEvent[] = []; // accumulator of sync events in the current sync response - private failedSyncCount = 0; // Number of consecutive failed /sync requests - private storeIsInvalid = false; // 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 - */ - public constructor(private readonly client: MatrixClient, opts?: IStoredClientOpts, syncOpts?: SyncApiOptions) { - this.opts = defaultClientOpts(opts); - this.syncOpts = defaultSyncApiOpts(syncOpts); - - if (client.getNotifTimelineSet()) { - client.reEmitter.reEmit(client.getNotifTimelineSet()!, [RoomEvent.Timeline, RoomEvent.TimelineReset]); - } - } - - public createRoom(roomId: string): Room { - const room = _createAndReEmitRoom(this.client, roomId, this.opts); - - room.on(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 - */ - private onMarkerStateEvent( - room: Room, - markerEvent: MatrixEvent, - { timelineWasEmpty }: IMarkerFoundOptions = {}, - ): void { - // 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.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.debug( - `MarkerState: Timeline needs to be refreshed because ` + - `a new markerEventId=${markerEvent.getId()} was sent in roomId=${room.roomId}`, - ); - room.setTimelineNeedsRefresh(true); - room.emit(RoomEvent.HistoryImportedWithinTimeline, markerEvent, room); - } else { - 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. - */ - public async syncLeftRooms(): Promise<Room[]> { - const client = this.client; - - // grab a filter with limit=1 and include_leave=true - const filter = new Filter(this.client.credentials.userId); - filter.setTimelineLimit(1); - filter.setIncludeLeaveRooms(true); - - const localTimeoutMs = this.opts.pollTimeout! + BUFFER_PERIOD_MS; - - const filterId = await client.getOrCreateFilter( - getFilterName(client.credentials.userId!, "LEFT_ROOMS"), - filter, - ); - - const qps: ISyncParams = { - timeout: 0, // don't want to block since this is a single isolated req - filter: filterId, - }; - - const data = await client.http.authedRequest<ISyncResponse>(Method.Get, "/sync", qps as any, undefined, { - localTimeoutMs, - }); - - let leaveRooms: WrappedRoom<ILeftRoom>[] = []; - 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.BACKWARDS); - - await this.injectRoomEvents(room, stateEvents, events); - - room.recalculate(); - client.store.storeRoom(room); - client.emit(ClientEvent.Room, room); - - this.processEventsForNotifs(room, events); - return room; - }), - ); - - return rooms.filter(Boolean) as Room[]; - } - - /** - * 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. - */ - public peek(roomId: string): Promise<Room> { - 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 = 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(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(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. - */ - public stopPeeking(): void { - this._peekRoom = null; - } - - /** - * Do a peek room poll. - * @param token - from= token - */ - private peekPoll(peekRoom: Room, token?: string): void { - if (this._peekRoom !== peekRoom) { - debuglog("Stopped peeking in room %s", peekRoom.roomId); - return; - } - - // FIXME: gut wrenching; hard-coded timeout values - this.client.http - .authedRequest<IEventsResponse>( - Method.Get, - "/events", - { - room_id: peekRoom.roomId, - timeout: String(30 * 1000), - from: token, - }, - undefined, - { - localTimeoutMs: 50 * 1000, - abortSignal: this.abortController?.signal, - }, - ) - .then( - (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(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()); - - peekRoom.addLiveEvents(events); - this.peekPoll(peekRoom, res.end); - }, - (err) => { - 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" - */ - public getSyncState(): SyncState | null { - 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. - */ - public getSyncStateData(): ISyncStateData | null { - return this.syncStateData ?? null; - } - - public async recoverFromSyncStartupError(savedSyncPromise: Promise<void> | undefined, error: Error): Promise<void> { - // 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 */ - private async wasLazyLoadingToggled(lazyLoadMembers = false): Promise<boolean> { - // 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; - } - - private shouldAbortSync(error: MatrixError): boolean { - if (error.errcode === "M_UNKNOWN_TOKEN") { - // The logout already happened, we just need to stop. - logger.warn("Token no longer valid - assuming logout"); - this.stop(); - this.updateSyncState(SyncState.Error, { error }); - return true; - } - return false; - } - - private getPushRules = async (): Promise<void> => { - try { - debuglog("Getting push rules..."); - const result = await this.client.getPushRules(); - debuglog("Got push rules"); - - this.client.pushRules = result; - } catch (err) { - logger.error("Getting push rules failed", err); - if (this.shouldAbortSync(<MatrixError>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, <Error>err); - return this.getPushRules(); // try again - } - }; - - private buildDefaultFilter = (): Filter => { - const filter = new Filter(this.client.credentials.userId); - if (this.client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported) { - filter.setUnreadThreadNotifications(true); - } - return filter; - }; - - private checkLazyLoadStatus = async (): Promise<void> => { - 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 InvalidStoreError(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.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.error("Storing client options failed", err); - throw err; - } - }; - - private getFilter = async (): Promise<{ - filterId?: string; - filter?: Filter; - }> => { - debuglog("Getting filter..."); - let filter: Filter; - if (this.opts.filter) { - filter = this.opts.filter; - } else { - filter = this.buildDefaultFilter(); - } - - let filterId: string; - try { - filterId = await this.client.getOrCreateFilter(getFilterName(this.client.credentials.userId!), filter); - } catch (err) { - logger.error("Getting filter failed", err); - if (this.shouldAbortSync(<MatrixError>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, <Error>err); - return this.getFilter(); // try again - } - return { filter, filterId }; - }; - - private savedSyncPromise?: Promise<void>; - - /** - * Main entry point - */ - public async sync(): Promise<void> { - 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.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. - */ - public stop(): void { - 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. - */ - public retryImmediately(): boolean { - 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(). - */ - private async syncFromCache(savedSync: ISavedSync): Promise<void> { - 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: ISyncStateData = { - nextSyncToken, - catchingUp: false, - fromCache: true, - }; - - const data: ISyncResponse = { - next_batch: nextSyncToken, - rooms: savedSync.roomsData, - account_data: { - events: savedSync.accountData, - }, - }; - - try { - await this.processSyncResponse(syncEventData, data); - } catch (e) { - 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 - */ - private async doSync(syncOptions: ISyncOptions): Promise<void> { - while (this.running) { - const syncToken = this.client.store.getSyncToken(); - - let data: ISyncResponse; - try { - if (!this.currentSyncRequest) { - this.currentSyncRequest = this.doSyncRequest(syncOptions, syncToken); - } - data = await this.currentSyncRequest; - } catch (e) { - const abort = await this.onSyncError(<MatrixError>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; - - await this.client.store.setSyncData(data); - - 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.error("Caught /sync error", e); - - // Emit the exception for client handling - this.client.emit(ClientEvent.SyncUnexpectedError, <Error>e); - } - - // 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. - 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); - } - } - - private doSyncRequest(syncOptions: ISyncOptions, syncToken: string | null): Promise<ISyncResponse> { - const qps = this.getSyncParams(syncOptions, syncToken); - return this.client.http.authedRequest<ISyncResponse>(Method.Get, "/sync", qps as any, undefined, { - localTimeoutMs: qps.timeout + BUFFER_PERIOD_MS, - abortSignal: this.abortController?.signal, - }); - } - - private getSyncParams(syncOptions: ISyncOptions, syncToken: string | null): ISyncParams { - 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: ISyncParams = { 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; - } - - private async onSyncError(err: MatrixError): Promise<boolean> { - 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.error("/sync error %s", err); - - if (this.shouldAbortSync(err)) { - return true; // abort - } - - this.failedSyncCount++; - logger.log("Number of consecutive failed sync requests:", this.failedSyncCount); - - debuglog("Starting keep-alive"); - // Note that we do *not* mark the sync connection as - // lost yet: we only do this if a keepalive poke - // fails, since long lived HTTP connections will - // go away sometimes and we shouldn't treat this as - // erroneous. We set the state to 'reconnecting' - // instead, so that clients can observe this state - // if they wish. - 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 - */ - private async processSyncResponse(syncEventData: ISyncStateData, data: ISyncResponse): Promise<void> { - 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(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(ClientEvent.Event, presenceEvent); - }); - } - - // handle non-room account_data - if (Array.isArray(data.account_data?.events)) { - const events = data.account_data.events.filter(noUnsafeEventProps).map(client.getEventMapper()); - const prevEventsMap = events.reduce<Record<string, MatrixEvent | undefined>>((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() === EventType.PushRules) { - const rules = accountDataEvent.getContent<IPushRules>(); - client.setPushRules(rules); - } - const prevEvent = prevEventsMap[accountDataEvent.getType()!]; - client.emit(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: IToDeviceEvent[] = data.to_device.events.filter(noUnsafeEventProps); - - if (this.syncOpts.cryptoCallbacks) { - toDeviceMessages = await this.syncOpts.cryptoCallbacks.preprocessToDeviceMessages(toDeviceMessages); - } - - const cancelledKeyVerificationTxns: string[] = []; - 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: string = toDeviceEvent.getContent()["transaction_id"]; - if (txnId) { - cancelledKeyVerificationTxns.push(txnId); - } - } - - // as mentioned above, .map is a cheap inline forEach, so return - // the unmodified event. - return toDeviceEvent; - }) - .forEach(function (toDeviceEvent) { - const content = toDeviceEvent.getContent(); - if (toDeviceEvent.getType() == "m.room.message" && content.msgtype == "m.bad.encrypted") { - // the mapper already logged a warning. - logger.log("Ignoring undecryptable to-device event from " + toDeviceEvent.getSender()); - return; - } - - if ( - toDeviceEvent.getType() === "m.key.verification.start" || - toDeviceEvent.getType() === "m.key.verification.request" - ) { - const txnId = content["transaction_id"]; - if (cancelledKeyVerificationTxns.includes(txnId)) { - toDeviceEvent.flagCancelled(); - } - } - - client.emit(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: WrappedRoom<IInvitedRoom>[] = []; - let joinRooms: WrappedRoom<IJoinedRoom>[] = []; - let leaveRooms: WrappedRoom<ILeftRoom>[] = []; - - 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 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(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(ClientEvent.Room, room); - } else { - // Update room state for invite->reject->invite cycles - room.recalculate(); - } - stateEvents.forEach(function (e) { - client.emit(ClientEvent.Event, e); - }); - }); - - // Handle joins - await 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( - NotificationCountType.Total, - joinObj.unread_notifications.notification_count ?? 0, - ); - } - - if (!encrypted || room.getUnreadNotificationCount(NotificationCountType.Highlight) <= 0) { - // If the locally stored highlight count is zero, use the server provided value. - room.setUnreadNotificationCount( - NotificationCountType.Highlight, - joinObj.unread_notifications.highlight_count ?? 0, - ); - } - } - - const unreadThreadNotifications = - joinObj[UNREAD_THREAD_NOTIFICATIONS.name] ?? joinObj[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, - NotificationCountType.Total, - unreadNotification.notification_count ?? 0, - ); - } - - const hasNoNotifications = - room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Highlight) <= 0; - if (!encrypted || (encrypted && hasNoNotifications)) { - room.setThreadUnreadNotificationCount( - threadId, - NotificationCountType.Highlight, - unreadNotification.highlight_count ?? 0, - ); - } - } - } else { - room.resetThreadUnreadNotificationCount(); - } - - joinObj.timeline = joinObj.timeline || ({} as ITimeline); - - 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.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() === EventType.RoomEncryption && e.getStateKey() === "") { - await this.syncOpts.cryptoCallbacks.onCryptoEvent(room, e); - } - } - } - - try { - await this.injectRoomEvents(room, stateEvents, events, syncEventData.fromCache); - } catch (e) { - 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(ClientEvent.Room, room); - } - - this.processEventsForNotifs(room, events); - - const emitEvent = (e: MatrixEvent): boolean => client.emit(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 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(ClientEvent.Room, room); - } - - this.processEventsForNotifs(room, events); - - stateEvents.forEach(function (e) { - client.emit(ClientEvent.Event, e); - }); - events.forEach(function (e) { - client.emit(ClientEvent.Event, e); - }); - accountDataEvents.forEach(function (e) { - client.emit(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.crypto) { - await this.syncOpts.crypto.handleDeviceListChanges(syncEventData, data.device_lists); - } else { - // FIXME if we *don't* have a crypto module, we still need to - // invalidate the device lists. But that would require a - // substantial bit of rework :/. - } - } - - // Handle one_time_keys_count - if (data.device_one_time_keys_count) { - const map = new Map<string, number>(Object.entries(data.device_one_time_keys_count)); - this.syncOpts.cryptoCallbacks?.preprocessOneTimeKeyCounts(map); - } - if (data.device_unused_fallback_key_types || data["org.matrix.msc2732.device_unused_fallback_key_types"]) { - // The presence of device_unused_fallback_key_types indicates that the - // server supports fallback keys. If there's no unused - // signed_curve25519 fallback key we need a new one. - const unusedFallbackKeys = - data.device_unused_fallback_key_types || data["org.matrix.msc2732.device_unused_fallback_key_types"]; - this.syncOpts.cryptoCallbacks?.preprocessUnusedFallbackKeys(new Set<string>(unusedFallbackKeys || null)); - } - } - - /** - * 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 - */ - private startKeepAlives(delay?: number): Promise<boolean> { - 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 = 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. - */ - private pokeKeepAlive(connDidFail = false): void { - const success = (): void => { - clearTimeout(this.keepAliveTimer); - if (this.connectionReturnedDefer) { - this.connectionReturnedDefer.resolve(connDidFail); - this.connectionReturnedDefer = undefined; - } - }; - - this.client.http - .request( - 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 }); - } - }, - ); - } - - private mapSyncResponseToRoomArray<T extends ILeftRoom | IJoinedRoom | IInvitedRoom>( - obj: Record<string, T>, - ): Array<WrappedRoom<T>> { - // Maps { roomid: {stuff}, roomid: {stuff} } - // to - // [{stuff+Room+isBrandNewRoom}, {stuff+Room+isBrandNewRoom}] - const client = this.client; - return Object.keys(obj) - .filter((k) => !unsafeProp(k)) - .map((roomId) => { - const arrObj = obj[roomId] as T & { room: Room; isBrandNewRoom: boolean }; - let room = client.store.getRoom(roomId); - let isBrandNewRoom = false; - if (!room) { - room = this.createRoom(roomId); - isBrandNewRoom = true; - } - arrObj.room = room; - arrObj.isBrandNewRoom = isBrandNewRoom; - return arrObj; - }); - } - - private mapSyncEventsFormat( - obj: IInviteState | ITimeline | IEphemeral, - room?: Room, - decrypt = true, - ): MatrixEvent[] { - if (!obj || !Array.isArray(obj.events)) { - return []; - } - const mapper = this.client.getEventMapper({ decrypt }); - type TaggedEvent = (IStrippedState | IRoomEvent | IStateEvent | IMinimalEvent) & { room_id?: string }; - return (obj.events as TaggedEvent[]).filter(noUnsafeEventProps).map(function (e) { - if (room) { - e.room_id = room.roomId; - } - return mapper(e); - }); - } - - /** - */ - private resolveInvites(room: Room): void { - 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 - */ - public async injectRoomEvents( - room: Room, - stateEventList: MatrixEvent[], - timelineEventList?: MatrixEvent[], - fromCache = false, - ): Promise<void> { - // 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. - 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. - */ - private processEventsForNotifs(room: Room, timelineEventList: MatrixEvent[]): void { - // 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); - } - } - } - } - - private getGuestFilter(): string { - // 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 - */ - private updateSyncState(newState: SyncState, data?: ISyncStateData): void { - const old = this.syncState; - this.syncState = newState; - this.syncStateData = data; - this.client.emit(ClientEvent.Sync, this.syncState, old, data); - } - - /** - * Event handler for the 'online' event - * This event is generally unreliable and precise behaviour - * varies between browsers, so we poll for connectivity too, - * but this might help us reconnect a little faster. - */ - private onOnline = (): void => { - debuglog("Browser thinks we are back online"); - this.startKeepAlives(0); - }; -} - -function createNewUser(client: MatrixClient, userId: string): User { - const user = new User(userId); - client.reEmitter.reEmit(user, [ - UserEvent.AvatarUrl, - UserEvent.DisplayName, - UserEvent.Presence, - UserEvent.CurrentlyActive, - 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. -export function _createAndReEmitRoom(client: MatrixClient, roomId: string, opts: Partial<IStoredClientOpts>): Room { - const { timelineSupport } = client; - - const room = new Room(roomId, client, client.getUserId()!, { - lazyLoadMembers: opts.lazyLoadMembers, - pendingEventOrdering: opts.pendingEventOrdering, - timelineSupport, - }); - - client.reEmitter.reEmit(room, [ - RoomEvent.Name, - RoomEvent.Redaction, - RoomEvent.RedactionCancelled, - RoomEvent.Receipt, - RoomEvent.Tags, - RoomEvent.LocalEchoUpdated, - RoomEvent.AccountData, - RoomEvent.MyMembership, - RoomEvent.Timeline, - RoomEvent.TimelineReset, - RoomStateEvent.Events, - RoomStateEvent.Members, - RoomStateEvent.NewMember, - RoomStateEvent.Update, - BeaconEvent.New, - BeaconEvent.Update, - BeaconEvent.Destroy, - BeaconEvent.LivenessChange, - ]); - - // We need to add a listener for RoomState.members in order to hook them - // correctly. - room.on(RoomStateEvent.NewMember, (event, state, member) => { - member.user = client.getUser(member.userId) ?? undefined; - client.reEmitter.reEmit(member, [ - RoomMemberEvent.Name, - RoomMemberEvent.Typing, - RoomMemberEvent.PowerLevel, - RoomMemberEvent.Membership, - ]); - }); - - return room; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/timeline-window.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/timeline-window.ts deleted file mode 100644 index be64c3b..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/timeline-window.ts +++ /dev/null @@ -1,507 +0,0 @@ -/* -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. -*/ - -import { Optional } from "matrix-events-sdk"; - -import { Direction, EventTimeline } from "./models/event-timeline"; -import { logger } from "./logger"; -import { MatrixClient } from "./client"; -import { EventTimelineSet } from "./models/event-timeline-set"; -import { MatrixEvent } from "./models/event"; - -/** - * @internal - */ -const DEBUG = false; - -/** - * @internal - */ -/* istanbul ignore next */ -const debuglog = DEBUG ? logger.log.bind(logger) : function (): void {}; - -/** - * the number of times we ask the server for more events before giving up - * - * @internal - */ -const DEFAULT_PAGINATE_LOOP_LIMIT = 5; - -interface IOpts { - /** - * Maximum number of events to keep in the window. If more events are retrieved via pagination requests, - * excess events will be dropped from the other end of the window. - */ - windowLimit?: number; -} - -export class TimelineWindow { - private readonly windowLimit: number; - // these will be TimelineIndex objects; they delineate the 'start' and - // 'end' of the window. - // - // start.index is inclusive; end.index is exclusive. - private start?: TimelineIndex; - private end?: TimelineIndex; - private eventCount = 0; - - /** - * 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 - */ - public constructor( - private readonly client: MatrixClient, - private readonly timelineSet: EventTimelineSet, - opts: IOpts = {}, - ) { - 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 - */ - public load(initialEventId?: string, initialWindowSize = 20): Promise<void> { - // 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: Optional<EventTimeline>): void => { - if (!timeline) { - throw new Error("No timeline given to initFields"); - } - - let eventIndex: number; - - 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. - */ - public getTimelineIndex(direction: Direction): TimelineIndex | null { - if (direction == EventTimeline.BACKWARDS) { - return this.start ?? null; - } else if (direction == 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. - */ - public extend(direction: Direction, size: number): boolean { - const tl = this.getTimelineIndex(direction); - - if (!tl) { - debuglog("TimelineWindow: no timeline yet"); - return false; - } - - const count = direction == EventTimeline.BACKWARDS ? tl.retreat(size) : tl.advance(size); - - if (count) { - this.eventCount += count; - debuglog("TimelineWindow: increased cap by " + count + " (now " + this.eventCount + ")"); - // remove some events from the other end, if necessary - const excess = this.eventCount - this.windowLimit; - if (excess > 0) { - this.unpaginate(excess, direction != EventTimeline.BACKWARDS); - } - return true; - } - - return false; - } - - /** - * Check if this window can be extended - * - * <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 - */ - public canPaginate(direction: Direction): boolean { - const tl = this.getTimelineIndex(direction); - - if (!tl) { - debuglog("TimelineWindow: no timeline yet"); - return false; - } - - if (direction == EventTimeline.BACKWARDS) { - if (tl.index > tl.minIndex()) { - return true; - } - } else { - if (tl.index < tl.maxIndex()) { - return true; - } - } - - 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. - */ - public async paginate( - direction: Direction, - size: number, - makeRequest = true, - requestLimit = DEFAULT_PAGINATE_LOOP_LIMIT, - ): Promise<boolean> { - // 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.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. - */ - public unpaginate(delta: number, startOfTimeline: boolean): void { - 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 - */ - public getEvents(): MatrixEvent[] { - if (!this.start) { - // not yet loaded - return []; - } - - const result: MatrixEvent[] = []; - - // iterate through each timeline between this.start and this.end - // (inclusive). - let timeline = this.start.timeline; - // eslint-disable-next-line no-constant-condition - while (true) { - const events = timeline.getEvents(); - - // For the first timeline in the chain, we want to start at - // this.start.index. For the last timeline in the chain, we want to - // stop before this.end.index. Otherwise, we want to copy all of the - // events in the timeline. - // - // (Note that both this.start.index and this.end.index are relative - // to their respective timelines' BaseIndex). - // - let startIndex = 0; - let endIndex = events.length; - if (timeline === this.start.timeline) { - startIndex = this.start.index + timeline.getBaseIndex(); - } - if (timeline === this.end?.timeline) { - endIndex = this.end.index + timeline.getBaseIndex(); - } - - for (let i = startIndex; i < endIndex; i++) { - result.push(events[i]); - } - - // if we're not done, iterate to the next timeline. - if (timeline === this.end?.timeline) { - break; - } else { - timeline = timeline.getNeighbouringTimeline(EventTimeline.FORWARDS)!; - } - } - - return result; - } -} - -/** - * A thing which contains a timeline reference, and an index into it. - * @internal - */ -export class TimelineIndex { - public pendingPaginate?: Promise<boolean>; - - // index: the indexes are relative to BaseIndex, so could well be negative. - public constructor(public timeline: EventTimeline, public index: number) {} - - /** - * @returns the minimum possible value for the index in the current - * timeline - */ - public minIndex(): number { - 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). - */ - public maxIndex(): number { - 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 - */ - public advance(delta: number): number { - if (!delta) { - return 0; - } - - // first try moving the index in the current timeline. See if there is room - // to do so. - let cappedDelta; - if (delta < 0) { - // we want to wind the index backwards. - // - // (this.minIndex() - this.index) is a negative number whose magnitude - // is the amount of room we have to wind back the index in the current - // timeline. We cap delta to this quantity. - cappedDelta = Math.max(delta, this.minIndex() - this.index); - if (cappedDelta < 0) { - this.index += cappedDelta; - return cappedDelta; - } - } else { - // we want to wind the index forwards. - // - // (this.maxIndex() - this.index) is a (positive) number whose magnitude - // is the amount of room we have to wind forward the index in the current - // timeline. We cap delta to this quantity. - cappedDelta = Math.min(delta, this.maxIndex() - this.index); - if (cappedDelta > 0) { - this.index += cappedDelta; - return cappedDelta; - } - } - - // the index is already at the start/end of the current timeline. - // - // next see if there is a neighbouring timeline to switch to. - const neighbour = this.timeline.getNeighbouringTimeline( - delta < 0 ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS, - ); - if (neighbour) { - this.timeline = neighbour; - if (delta < 0) { - this.index = this.maxIndex(); - } else { - this.index = this.minIndex(); - } - - debuglog("paginate: switched to new neighbour"); - - // recurse, using the next timeline - return this.advance(delta); - } - - return 0; - } - - /** - * Try move the index backwards, or into the neighbouring timeline - * - * @param delta - number of events to retreat by - * @returns number of events successfully retreated by - */ - public retreat(delta: number): number { - return this.advance(delta * -1) * -1; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/utils.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/utils.ts deleted file mode 100644 index 0c3aea7..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/utils.ts +++ /dev/null @@ -1,770 +0,0 @@ -/* -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. - */ - -import unhomoglyph from "unhomoglyph"; -import promiseRetry from "p-retry"; -import { Optional } from "matrix-events-sdk"; - -import { IEvent, MatrixEvent } from "./models/event"; -import { M_TIMESTAMP } from "./@types/location"; -import { ReceiptType } from "./@types/read_receipts"; - -const interns = new Map<string, string>(); - -/** - * 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. - */ -export function internaliseString(str: string): string { - // Unwrap strings before entering the map, if we somehow got a wrapped - // string as our input. This should only happen from tests. - if ((str as unknown) 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 - */ -export function encodeParams(params: QueryDict, urlSearchParams?: URLSearchParams): 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; -} - -export type QueryDict = Record<string, string[] | string | number | boolean | undefined>; - -/** - * Replace a stable parameter with the unstable naming for params - */ -export function replaceParam(stable: string, unstable: string, dict: QueryDict): QueryDict { - const result = { - ...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 - */ -export function decodeParams(query: string): Record<string, string | string[]> { - const o: Record<string, string | string[]> = {}; - 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'. - */ -export function encodeUri(pathTemplate: string, variables: Record<string, Optional<string>>): string { - 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. - */ -export function removeElement<T>(array: T[], fn: (t: T, i?: number, a?: T[]) => boolean, reverse?: boolean): boolean { - let i: number; - 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. - */ -export function isFunction(value: any): boolean { - 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 -export function checkObjectHasKeys(obj: object, keys: string[]): void { - 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. - */ -export function deepCopy<T>(obj: T): T { - 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 - */ -export function deepCompare(x: any, y: any): boolean { - // Inspired by - // http://stackoverflow.com/questions/1068834/object-comparison-in-javascript#1144249 - - // Compare primitives and functions. - // Also check if both arguments link to the same object. - if (x === y) { - return true; - } - - if (typeof x !== typeof y) { - return false; - } - - // special-case NaN (since NaN !== NaN) - if (typeof x === "number" && isNaN(x) && isNaN(y)) { - return true; - } - - // special-case null (since typeof null == 'object', but null.constructor - // throws) - if (x === null || y === null) { - return x === y; - } - - // everything else is either an unequal primitive, or an object - if (!(x instanceof Object)) { - return false; - } - - // check they are the same type of object - if (x.constructor !== y.constructor || x.prototype !== y.prototype) { - return false; - } - - // special-casing for some special types of object - if (x instanceof RegExp || x instanceof Date) { - return x.toString() === y.toString(); - } - - // the object algorithm works for Array, but it's sub-optimal. - if (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. - */ -export function deepSortedObjectEntries(obj: any): [string, any][] { - if (typeof obj !== "object") return obj; - - // Apparently these are object types... - if (obj === null || obj === undefined || Array.isArray(obj)) return obj; - - const pairs: [string, any][] = []; - 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 - */ -export function isNumber(value: any): value is number { - 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 - */ -export function removeHiddenChars(str: string): string { - if (typeof str === "string") { - return unhomoglyph(str.normalize("NFD").replace(removeHiddenCharsRegex, "")); - } - return ""; -} - -/** - * Removes the direction override characters from a string - * @returns string with chars removed - */ -export function removeDirectionOverrideChars(str: string): string { - if (typeof str === "string") { - return str.replace(/[\u202d-\u202e]/g, ""); - } - return ""; -} - -export function normalize(str: string): string { - // 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; - -export function escapeRegExp(string: string): string { - return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -export function globToRegexp(glob: string, extended = false): string { - // From - // https://github.com/matrix-org/synapse/blob/abbee6b29be80a77e05730707602f3bbfc3f38cb/synapse/push/__init__.py#L132 - // Because micromatch is about 130KB with dependencies, - // and minimatch is not much better. - const replacements: [RegExp, string | ((substring: string, ...args: any[]) => string)][] = [ - [/\\\*/g, ".*"], - [/\?/g, "."], - ]; - if (!extended) { - replacements.push([ - /\\\[(!|)(.*)\\]/g, - (_match: string, neg: string, pat: string): string => - ["[", neg ? "^" : "", pat.replace(/\\-/, "-"), "]"].join(""), - ]); - } - return replacements.reduce( - // https://github.com/microsoft/TypeScript/issues/30134 - (pat, args) => (args ? pat.replace(args[0], args[1] as any) : pat), - escapeRegExp(glob), - ); -} - -export function ensureNoTrailingSlash(url: string): string; -export function ensureNoTrailingSlash(url: undefined): undefined; -export function ensureNoTrailingSlash(url?: string): string | undefined; -export function ensureNoTrailingSlash(url?: string): string | undefined { - 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 - */ -export function sleep<T>(ms: number, value?: T): Promise<T> { - return new Promise((resolve) => { - setTimeout(resolve, ms, value); - }); -} - -/** - * Promise/async version of {@link setImmediate}. - */ -export function immediate(): Promise<void> { - return new Promise(setImmediate); -} - -export function isNullOrUndefined(val: any): boolean { - return val === null || val === undefined; -} - -export interface IDeferred<T> { - resolve: (value: T | Promise<T>) => void; - reject: (reason?: any) => void; - promise: Promise<T>; -} - -// Returns a Deferred -export function defer<T = void>(): IDeferred<T> { - let resolve!: IDeferred<T>["resolve"]; - let reject!: IDeferred<T>["reject"]; - - const promise = new Promise<T>((_resolve, _reject) => { - resolve = _resolve; - reject = _reject; - }); - - return { resolve, reject, promise }; -} - -export async function promiseMapSeries<T>( - promises: Array<T | Promise<T>>, - fn: (t: T) => Promise<unknown> | undefined, // if async we don't care about the type as we only await resolution -): Promise<void> { - for (const o of promises) { - await fn(await o); - } -} - -export function promiseTry<T>(fn: () => T | Promise<T>): Promise<T> { - return Promise.resolve(fn()); -} - -// Creates and awaits all promises, running no more than `chunkSize` at the same time -export async function chunkPromises<T>(fns: (() => Promise<T>)[], chunkSize: number): Promise<T[]> { - const results: T[] = []; - for (let i = 0; i < fns.length; i += chunkSize) { - results.push(...(await Promise.all(fns.slice(i, i + chunkSize).map((fn) => fn())))); - } - return results; -} - -/** - * 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. - */ -export function simpleRetryOperation<T>(promiseFn: (attempt: number) => Promise<T>): Promise<T> { - return promiseRetry( - (attempt: number) => { - 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). - */ -export const DEFAULT_ALPHABET = ((): string => { - 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. - */ -export function alphabetPad(s: string, n: number, alphabet = DEFAULT_ALPHABET): string { - 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. - */ -export function baseToString(n: bigint, alphabet = DEFAULT_ALPHABET): string { - // 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. - */ -export function stringToBase(s: string, alphabet = DEFAULT_ALPHABET): bigint { - 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. - */ -export function averageBetweenStrings(a: string, b: string, alphabet = DEFAULT_ALPHABET): string { - 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. - */ -export function nextString(s: string, alphabet = DEFAULT_ALPHABET): string { - 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. - */ -export function prevString(s: string, alphabet = DEFAULT_ALPHABET): string { - 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. - */ -export function lexicographicCompare(a: string, b: string): number { - // 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 - */ -export function compare(a: string, b: string): number { - 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 - */ -export function recursivelyAssign<T1 extends T2, T2 extends Record<string, any>>( - target: T1, - source: T2, - ignoreNullish = false, -): T1 & T2 { - 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) { - target[sourceKey as keyof T1] = sourceValue; - continue; - } - } - return target as T1 & T2; -} - -function getContentTimestampWithFallback(event: MatrixEvent): number { - return M_TIMESTAMP.findIn<number>(event.getContent()) ?? -1; -} - -/** - * Sort events by their content m.ts property - * Latest timestamp first - */ -export function sortEventsByLatestContentTimestamp(left: MatrixEvent, right: MatrixEvent): number { - return getContentTimestampWithFallback(right) - getContentTimestampWithFallback(left); -} - -export function isSupportedReceiptType(receiptType: string): boolean { - return [ReceiptType.Read, ReceiptType.ReadPrivate].includes(receiptType as ReceiptType); -} - -/** - * Determines whether two maps are equal. - * @param eq - The equivalence relation to compare values by. Defaults to strict equality. - */ -export function mapsEqual<K, V>(x: Map<K, V>, y: Map<K, V>, eq = (v1: V, v2: V): boolean => v1 === v2): boolean { - 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: any): any { - 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. - */ -export function recursiveMapToObject(map: Map<any, any>): any { - const targetMap = new Map(); - - for (const [key, value] of map) { - targetMap.set(key, processMapToObjectValue(value)); - } - - return Object.fromEntries(targetMap.entries()); -} - -export function unsafeProp<K extends keyof any | undefined>(prop: K): boolean { - return prop === "__proto__" || prop === "prototype" || prop === "constructor"; -} - -export function safeSet<K extends keyof any>(obj: Record<any, any>, prop: K, value: any): void { - if (unsafeProp(prop)) { - throw new Error("Trying to modify prototype or constructor"); - } - - obj[prop] = value; -} - -export function noUnsafeEventProps(event: Partial<IEvent>): boolean { - return !( - unsafeProp(event.room_id) || - unsafeProp(event.sender) || - unsafeProp(event.user_id) || - unsafeProp(event.event_id) - ); -} - -export class MapWithDefault<K, V> extends Map<K, V> { - public constructor(private createDefault: () => V) { - super(); - } - - /** - * 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. - */ - public getOrCreate(key: K): V { - if (!this.has(key)) { - this.set(key, this.createDefault()); - } - - return this.get(key)!; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/audioContext.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/audioContext.ts deleted file mode 100644 index 7cf3ed3..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/audioContext.ts +++ /dev/null @@ -1,44 +0,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: AudioContext | null = 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 - */ -export const acquireContext = (): AudioContext => { - 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. - */ -export const releaseContext = (): void => { - refCount--; - if (refCount === 0) { - audioContext?.close(); - audioContext = null; - } -}; diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/call.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/call.ts deleted file mode 100644 index cd75c10..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/call.ts +++ /dev/null @@ -1,2962 +0,0 @@ -/* -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. - */ - -import { v4 as uuidv4 } from "uuid"; -import { parse as parseSdp, write as writeSdp } from "sdp-transform"; - -import { logger } from "../logger"; -import * as utils from "../utils"; -import { IContent, MatrixEvent } from "../models/event"; -import { EventType, ToDeviceMessageId } from "../@types/event"; -import { RoomMember } from "../models/room-member"; -import { randomString } from "../randomstring"; -import { - MCallReplacesEvent, - MCallAnswer, - MCallInviteNegotiate, - CallCapabilities, - SDPStreamMetadataPurpose, - SDPStreamMetadata, - SDPStreamMetadataKey, - MCallSDPStreamMetadataChanged, - MCallSelectAnswer, - MCAllAssertedIdentity, - MCallCandidates, - MCallBase, - MCallHangupReject, -} from "./callEventTypes"; -import { CallFeed } from "./callFeed"; -import { MatrixClient } from "../client"; -import { EventEmitterEvents, TypedEventEmitter } from "../models/typed-event-emitter"; -import { DeviceInfo } from "../crypto/deviceinfo"; -import { GroupCallUnknownDeviceError } from "./groupCall"; -import { IScreensharingOpts } from "./mediaHandler"; -import { MatrixError } from "../http-api"; -import { GroupCallStats } from "./stats/groupCallStats"; - -interface CallOpts { - // The room ID for this call. - roomId: string; - invitee?: string; - // The Matrix Client instance to send events to. - client: MatrixClient; - /** - * Whether relay through TURN should be forced. - * @deprecated use opts.forceTURN when creating the matrix client - * since it's only possible to set this option on outbound calls. - */ - forceTURN?: boolean; - // A list of TURN servers. - turnServers?: Array<TurnServer>; - opponentDeviceId?: string; - opponentSessionId?: string; - groupCallId?: string; -} - -interface TurnServer { - urls: Array<string>; - username?: string; - password?: string; - ttl?: number; -} - -interface AssertedIdentity { - id: string; - displayName: string; -} - -enum MediaType { - AUDIO = "audio", - VIDEO = "video", -} - -enum CodecName { - OPUS = "opus", - // add more as needed -} - -// Used internally to specify modifications to codec parameters in SDP -interface CodecParamsMod { - mediaType: MediaType; - codec: CodecName; - enableDtx?: boolean; // true to enable discontinuous transmission, false to disable, undefined to leave as-is - maxAverageBitrate?: number; // sets the max average bitrate, or undefined to leave as-is -} - -export enum CallState { - Fledgling = "fledgling", - InviteSent = "invite_sent", - WaitLocalMedia = "wait_local_media", - CreateOffer = "create_offer", - CreateAnswer = "create_answer", - Connecting = "connecting", - Connected = "connected", - Ringing = "ringing", - Ended = "ended", -} - -export enum CallType { - Voice = "voice", - Video = "video", -} - -export enum CallDirection { - Inbound = "inbound", - Outbound = "outbound", -} - -export enum CallParty { - Local = "local", - Remote = "remote", -} - -export enum CallEvent { - Hangup = "hangup", - State = "state", - Error = "error", - Replaced = "replaced", - - // The value of isLocalOnHold() has changed - LocalHoldUnhold = "local_hold_unhold", - // The value of isRemoteOnHold() has changed - RemoteHoldUnhold = "remote_hold_unhold", - // backwards compat alias for LocalHoldUnhold: remove in a major version bump - HoldUnhold = "hold_unhold", - // Feeds have changed - FeedsChanged = "feeds_changed", - - AssertedIdentityChanged = "asserted_identity_changed", - - LengthChanged = "length_changed", - - DataChannel = "datachannel", - - SendVoipEvent = "send_voip_event", -} - -export enum CallErrorCode { - /** The user chose to end the call */ - UserHangup = "user_hangup", - - /** An error code when the local client failed to create an offer. */ - LocalOfferFailed = "local_offer_failed", - /** - * An error code when there is no local mic/camera to use. This may be because - * the hardware isn't plugged in, or the user has explicitly denied access. - */ - NoUserMedia = "no_user_media", - - /** - * Error code used when a call event failed to send - * because unknown devices were present in the room - */ - UnknownDevices = "unknown_devices", - - /** - * Error code used when we fail to send the invite - * for some reason other than there being unknown devices - */ - SendInvite = "send_invite", - - /** - * An answer could not be created - */ - CreateAnswer = "create_answer", - - /** - * An offer could not be created - */ - CreateOffer = "create_offer", - - /** - * Error code used when we fail to send the answer - * for some reason other than there being unknown devices - */ - SendAnswer = "send_answer", - - /** - * The session description from the other side could not be set - */ - SetRemoteDescription = "set_remote_description", - - /** - * The session description from this side could not be set - */ - SetLocalDescription = "set_local_description", - - /** - * A different device answered the call - */ - AnsweredElsewhere = "answered_elsewhere", - - /** - * No media connection could be established to the other party - */ - IceFailed = "ice_failed", - - /** - * The invite timed out whilst waiting for an answer - */ - InviteTimeout = "invite_timeout", - - /** - * The call was replaced by another call - */ - Replaced = "replaced", - - /** - * Signalling for the call could not be sent (other than the initial invite) - */ - SignallingFailed = "signalling_timeout", - - /** - * The remote party is busy - */ - UserBusy = "user_busy", - - /** - * We transferred the call off to somewhere else - */ - Transferred = "transferred", - - /** - * A call from the same user was found with a new session id - */ - NewSession = "new_session", -} - -/** - * The version field that we set in m.call.* events - */ -const VOIP_PROTO_VERSION = "1"; - -/** The fallback ICE server to use for STUN or TURN protocols. */ -const FALLBACK_ICE_SERVER = "stun:turn.matrix.org"; - -/** The length of time a call can be ringing for. */ -const CALL_TIMEOUT_MS = 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 - -export class CallError extends Error { - public readonly code: string; - - public constructor(code: CallErrorCode, msg: string, err: Error) { - // Still don't think there's any way to have proper nested errors - super(msg + ": " + err); - - this.code = code; - } -} - -export function genCallID(): string { - return Date.now().toString() + randomString(16); -} - -function getCodecParamMods(isPtt: boolean): CodecParamsMod[] { - const mods = [ - { - mediaType: "audio", - codec: "opus", - enableDtx: true, - maxAverageBitrate: isPtt ? 12000 : undefined, - }, - ] as CodecParamsMod[]; - - return mods; -} - -export interface VoipEvent { - type: "toDevice" | "sendEvent"; - eventType: string; - userId?: string; - opponentDeviceId?: string; - roomId?: string; - content: Record<string, unknown>; -} - -/** - * 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. - */ -export type CallEventHandlerMap = { - [CallEvent.DataChannel]: (channel: RTCDataChannel, call: MatrixCall) => void; - [CallEvent.FeedsChanged]: (feeds: CallFeed[], call: MatrixCall) => void; - [CallEvent.Replaced]: (newCall: MatrixCall, oldCall: MatrixCall) => void; - [CallEvent.Error]: (error: CallError, call: MatrixCall) => void; - [CallEvent.RemoteHoldUnhold]: (onHold: boolean, call: MatrixCall) => void; - [CallEvent.LocalHoldUnhold]: (onHold: boolean, call: MatrixCall) => void; - [CallEvent.LengthChanged]: (length: number, call: MatrixCall) => void; - [CallEvent.State]: (state: CallState, oldState: CallState, call: MatrixCall) => void; - [CallEvent.Hangup]: (call: MatrixCall) => void; - [CallEvent.AssertedIdentityChanged]: (call: MatrixCall) => void; - /* @deprecated */ - [CallEvent.HoldUnhold]: (onHold: boolean) => void; - [CallEvent.SendVoipEvent]: (event: VoipEvent, call: MatrixCall) => void; -}; - -// The key of the transceiver map (purpose + media type, separated by ':') -type TransceiverKey = string; - -// 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: SDPStreamMetadataPurpose, kind: TransceiverKey): string { - return purpose + ":" + kind; -} - -export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap> { - public roomId: string; - public callId: string; - public invitee?: string; - public hangupParty?: CallParty; - public hangupReason?: string; - public direction?: CallDirection; - public ourPartyId: string; - public peerConn?: RTCPeerConnection; - public toDeviceSeq = 0; - - // whether this call should have push-to-talk semantics - // This should be set by the consumer on incoming & outgoing calls. - public isPtt = false; - - private _state = CallState.Fledgling; - private readonly client: MatrixClient; - private readonly forceTURN?: boolean; - private readonly turnServers: Array<TurnServer>; - // A queue for candidates waiting to go out. - // We try to amalgamate candidates into a single candidate message where - // possible - private candidateSendQueue: Array<RTCIceCandidate> = []; - private candidateSendTries = 0; - private candidatesEnded = false; - private feeds: Array<CallFeed> = []; - - // our transceivers for each purpose and type of media - private transceivers = new Map<TransceiverKey, RTCRtpTransceiver>(); - - private inviteOrAnswerSent = false; - private waitForLocalAVStream = false; - private successor?: MatrixCall; - private opponentMember?: RoomMember; - private opponentVersion?: number | string; - // The party ID of the other side: undefined if we haven't chosen a partner - // yet, null if we have but they didn't send a party ID. - private opponentPartyId: string | null | undefined; - private opponentCaps?: CallCapabilities; - private iceDisconnectedTimeout?: ReturnType<typeof setTimeout>; - private inviteTimeout?: ReturnType<typeof setTimeout>; - private readonly removeTrackListeners = new Map<MediaStream, () => void>(); - - // The logic of when & if a call is on hold is nontrivial and explained in is*OnHold - // This flag represents whether we want the other party to be on hold - private remoteOnHold = 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' :( - private callStatsAtEnd?: any[]; - - // Perfect negotiation state: https://www.w3.org/TR/webrtc/#perfect-negotiation-example - private makingOffer = false; - private ignoreOffer = false; - - private responsePromiseChain?: Promise<void>; - - // If candidates arrive before we've picked an opponent (which, in particular, - // will happen if the opponent sends candidates eagerly before the user answers - // the call) we buffer them up here so we can then add the ones from the party we pick - private remoteCandidateBuffer = new Map<string, RTCIceCandidate[]>(); - - private remoteAssertedIdentity?: AssertedIdentity; - private remoteSDPStreamMetadata?: SDPStreamMetadata; - - private callLengthInterval?: ReturnType<typeof setInterval>; - private callStartTime?: number; - - private opponentDeviceId?: string; - private opponentDeviceInfo?: DeviceInfo; - private opponentSessionId?: string; - public groupCallId?: string; - - // Used to keep the timer for the delay before actually stopping our - // video track after muting (see setLocalVideoMuted) - private stopVideoTrackTimer?: ReturnType<typeof setTimeout>; - // 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 - private readonly isOnlyDataChannelAllowed: boolean; - private stats: GroupCallStats | undefined; - - /** - * Construct a new Matrix Call. - * @param opts - Config options. - */ - public constructor(opts: CallOpts) { - super(); - - 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) { - 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. - */ - public async placeVoiceCall(): Promise<void> { - await this.placeCall(true, false); - } - - /** - * Place a video call to this room. - * @throws If you have not specified a listener for 'error' events. - */ - public async placeVideoCall(): Promise<void> { - 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. - */ - public createDataChannel(label: string, options: RTCDataChannelInit | undefined): RTCDataChannel { - const dataChannel = this.peerConn!.createDataChannel(label, options); - this.emit(CallEvent.DataChannel, dataChannel, this); - return dataChannel; - } - - public getOpponentMember(): RoomMember | undefined { - return this.opponentMember; - } - - public getOpponentDeviceId(): string | undefined { - return this.opponentDeviceId; - } - - public getOpponentSessionId(): string | undefined { - return this.opponentSessionId; - } - - public opponentCanBeTransferred(): boolean { - return Boolean(this.opponentCaps && this.opponentCaps["m.call.transferee"]); - } - - public opponentSupportsDTMF(): boolean { - return Boolean(this.opponentCaps && this.opponentCaps["m.call.dtmf"]); - } - - public getRemoteAssertedIdentity(): AssertedIdentity | undefined { - return this.remoteAssertedIdentity; - } - - public get state(): CallState { - return this._state; - } - - private set state(state: CallState) { - const oldState = this._state; - this._state = state; - this.emit(CallEvent.State, state, oldState, this); - } - - public get type(): CallType { - // 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; - } - - public get hasLocalUserMediaVideoTrack(): boolean { - return !!this.localUsermediaStream?.getVideoTracks().length; - } - - public get hasRemoteUserMediaVideoTrack(): boolean { - return this.getRemoteFeeds().some((feed) => { - return feed.purpose === SDPStreamMetadataPurpose.Usermedia && feed.stream?.getVideoTracks().length; - }); - } - - public get hasLocalUserMediaAudioTrack(): boolean { - return !!this.localUsermediaStream?.getAudioTracks().length; - } - - public get hasRemoteUserMediaAudioTrack(): boolean { - return this.getRemoteFeeds().some((feed) => { - return feed.purpose === SDPStreamMetadataPurpose.Usermedia && !!feed.stream?.getAudioTracks().length; - }); - } - - private get hasUserMediaAudioSender(): boolean { - return Boolean(this.transceivers.get(getTransceiverKey(SDPStreamMetadataPurpose.Usermedia, "audio"))?.sender); - } - - private get hasUserMediaVideoSender(): boolean { - return Boolean(this.transceivers.get(getTransceiverKey(SDPStreamMetadataPurpose.Usermedia, "video"))?.sender); - } - - public get localUsermediaFeed(): CallFeed | undefined { - return this.getLocalFeeds().find((feed) => feed.purpose === SDPStreamMetadataPurpose.Usermedia); - } - - public get localScreensharingFeed(): CallFeed | undefined { - return this.getLocalFeeds().find((feed) => feed.purpose === SDPStreamMetadataPurpose.Screenshare); - } - - public get localUsermediaStream(): MediaStream | undefined { - return this.localUsermediaFeed?.stream; - } - - public get localScreensharingStream(): MediaStream | undefined { - return this.localScreensharingFeed?.stream; - } - - public get remoteUsermediaFeed(): CallFeed | undefined { - return this.getRemoteFeeds().find((feed) => feed.purpose === SDPStreamMetadataPurpose.Usermedia); - } - - public get remoteScreensharingFeed(): CallFeed | undefined { - return this.getRemoteFeeds().find((feed) => feed.purpose === SDPStreamMetadataPurpose.Screenshare); - } - - public get remoteUsermediaStream(): MediaStream | undefined { - return this.remoteUsermediaFeed?.stream; - } - - public get remoteScreensharingStream(): MediaStream | undefined { - return this.remoteScreensharingFeed?.stream; - } - - private getFeedByStreamId(streamId: string): CallFeed | undefined { - return this.getFeeds().find((feed) => feed.stream.id === streamId); - } - - /** - * Returns an array of all CallFeeds - * @returns CallFeeds - */ - public getFeeds(): Array<CallFeed> { - return this.feeds; - } - - /** - * Returns an array of all local CallFeeds - * @returns local CallFeeds - */ - public getLocalFeeds(): Array<CallFeed> { - return this.feeds.filter((feed) => feed.isLocal()); - } - - /** - * Returns an array of all remote CallFeeds - * @returns remote CallFeeds - */ - public getRemoteFeeds(): Array<CallFeed> { - return this.feeds.filter((feed) => !feed.isLocal()); - } - - private async initOpponentCrypto(): Promise<void> { - 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(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 GroupCallUnknownDeviceError(userId); - } - } - - /** - * Generates and returns localSDPStreamMetadata - * @returns localSDPStreamMetadata - */ - private getLocalSDPStreamMetadata(updateStreamIds = false): SDPStreamMetadata { - const metadata: SDPStreamMetadata = {}; - 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 - */ - public noIncomingFeeds(): boolean { - return !this.feeds.some((feed) => !feed.isLocal()); - } - - private pushRemoteFeed(stream: MediaStream): void { - // 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.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.warn( - `Call ${this.callId} pushRemoteFeed() ignoring stream because we already have a feed for it (streamId=${stream.id})`, - ); - return; - } - - this.feeds.push( - new 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.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 - */ - private pushRemoteFeedWithoutMetadata(stream: MediaStream): void { - const userId = this.getOpponentMember()!.userId; - // We can guess the purpose here since the other client can only send one stream - const purpose = 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.warn( - `Call ${this.callId} pushRemoteFeedWithoutMetadata() ignoring new stream because we already have stream (streamId=${stream.id})`, - ); - return; - } - - if (this.getFeedByStreamId(stream.id)) { - 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({ - 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.info( - `Call ${this.callId} pushRemoteFeedWithoutMetadata() pushed stream (streamId=${stream.id}, active=${stream.active})`, - ); - } - - private pushNewLocalFeed(stream: MediaStream, purpose: SDPStreamMetadataPurpose, addToPeerConnection = true): void { - 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.warn( - `Call ${this.callId} pushNewLocalFeed() ignoring stream because we already have a feed for it (streamId=${stream.id})`, - ); - return; - } - - this.pushLocalFeed( - new 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 - */ - public pushLocalFeed(callFeed: CallFeed, addToPeerConnection = true): void { - if (this.feeds.some((feed) => callFeed.stream.id === feed.stream.id)) { - 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.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.warn( - `Call ${this.callId} pushLocalFeed() didn't find a matching transceiver after adding track!`, - ); - } - } - } - } - - 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 - */ - public removeLocalFeed(callFeed: CallFeed): void { - 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 === SDPStreamMetadataPurpose.Screenshare) { - this.client.getMediaHandler().stopScreensharingStream(callFeed.stream); - } - - this.deleteFeed(callFeed); - } - - private deleteAllFeeds(): void { - for (const feed of this.feeds) { - if (!feed.isLocal() || !this.groupCallId) { - feed.dispose(); - } - } - - this.feeds = []; - this.emit(CallEvent.FeedsChanged, this.feeds, this); - } - - private deleteFeedByStream(stream: MediaStream): void { - const feed = this.getFeedByStreamId(stream.id); - if (!feed) { - logger.warn( - `Call ${this.callId} deleteFeedByStream() didn't find the feed to delete (streamId=${stream.id})`, - ); - return; - } - this.deleteFeed(feed); - } - - private deleteFeed(feed: CallFeed): void { - 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' :( - public async getCurrentCallStats(): Promise<any[] | undefined> { - if (this.callHasEnded()) { - return this.callStatsAtEnd; - } - - return this.collectCallStats(); - } - - private async collectCallStats(): Promise<any[] | undefined> { - // 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: any[] = []; - 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 - */ - public async initWithInvite(event: MatrixEvent): Promise<void> { - const invite = event.getContent<MCallInviteNegotiate>(); - this.direction = CallDirection.Inbound; - - // make sure we have valid turn creds. Unless something's gone wrong, it should - // poll and keep the credentials valid so this should be instant. - const haveTurnCreds = await this.client.checkTurnServers(); - if (!haveTurnCreds) { - logger.warn( - `Call ${this.callId} initWithInvite() failed to get TURN credentials! Proceeding with call anyway...`, - ); - } - - const sdpStreamMetadata = invite[SDPStreamMetadataKey]; - if (sdpStreamMetadata) { - this.updateRemoteSDPStreamMetadata(sdpStreamMetadata); - } else { - logger.debug( - `Call ${this.callId} initWithInvite() did not get any SDPStreamMetadata! Can not send/receive multiple streams`, - ); - } - - this.peerConn = this.createPeerConnection(); - // we must set the party ID before await-ing on anything: the call event - // handler will start giving us more call events (eg. candidates) so if - // we haven't set the party ID, we'll ignore them. - this.chooseOpponent(event); - await this.initOpponentCrypto(); - try { - await this.peerConn.setRemoteDescription(invite.offer); - await this.addBufferedIceCandidates(); - } catch (e) { - 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.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.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: CallState): void => { - 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 - */ - public initWithHangup(event: MatrixEvent): void { - // 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; - } - - private shouldAnswerWithMediaType( - wantedValue: boolean | undefined, - valueOfTheOtherSide: boolean, - type: "audio" | "video", - ): boolean { - if (wantedValue && !valueOfTheOtherSide) { - // TODO: Figure out how to do this - logger.warn( - `Call ${this.callId} shouldAnswerWithMediaType() unable to answer with ${type} because the other side isn't sending it either.`, - ); - return false; - } else if ( - !utils.isNullOrUndefined(wantedValue) && - wantedValue !== valueOfTheOtherSide && - !this.opponentSupportsSDPStreamMetadata() - ) { - 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. - */ - public async answer(audio?: boolean, video?: boolean): Promise<void> { - 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({ - client: this.client, - roomId: this.roomId, - userId: this.client.getUserId()!, - deviceId: this.client.getDeviceId() ?? undefined, - stream, - purpose: 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.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(<Error>e); - return; - } - } - } else if (this.waitForLocalAVStream) { - this.state = CallState.WaitLocalMedia; - } - } - - public answerWithCallFeeds(callFeeds: CallFeed[]): void { - 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. - */ - public replacedBy(newCall: MatrixCall): void { - logger.debug(`Call ${this.callId} replacedBy() running (newCallId=${newCall.callId})`); - if (this.state === CallState.WaitLocalMedia) { - 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.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. - */ - public hangup(reason: CallErrorCode, suppressEvent: boolean): void { - if (this.callHasEnded()) return; - - 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: IContent = {}; - // Don't send UserHangup reason to older clients - if ((this.opponentVersion && this.opponentVersion !== 0) || reason !== CallErrorCode.UserHangup) { - content["reason"] = reason; - } - this.sendVoipEvent(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. - */ - public reject(): void { - if (this.state !== CallState.Ringing) { - throw Error("Call must be in 'ringing' state to reject!"); - } - - if (this.opponentVersion === 0) { - 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.debug("Rejecting call: " + this.callId); - this.terminate(CallParty.Local, CallErrorCode.UserHangup, true); - this.sendVoipEvent(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 - */ - private async upgradeCall(audio: boolean, video: boolean): Promise<void> { - // We don't do call downgrades - if (!audio && !video) return; - if (!this.opponentSupportsSDPStreamMetadata()) return; - - try { - 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.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>error), - this, - ); - } - } - - /** - * Returns true if this.remoteSDPStreamMetadata is defined, otherwise returns false - * @returns can screenshare - */ - public opponentSupportsSDPStreamMetadata(): boolean { - return Boolean(this.remoteSDPStreamMetadata); - } - - /** - * If there is a screensharing stream returns true, otherwise returns false - * @returns is screensharing - */ - public isScreensharing(): boolean { - 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 - */ - public async setScreensharingEnabled(enabled: boolean, opts?: IScreensharingOpts): Promise<boolean> { - // Skip if there is nothing to do - if (enabled && this.isScreensharing()) { - 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.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.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, SDPStreamMetadataPurpose.Screenshare); - return true; - } catch (err) { - logger.error(`Call ${this.callId} setScreensharingEnabled() failed to get screen-sharing stream:`, err); - return false; - } - } else { - const audioTransceiver = this.transceivers.get( - getTransceiverKey(SDPStreamMetadataPurpose.Screenshare, "audio"), - ); - const videoTransceiver = this.transceivers.get( - getTransceiverKey(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 - */ - private async setScreensharingEnabledWithoutMetadataSupport( - enabled: boolean, - opts?: IScreensharingOpts, - ): Promise<boolean> { - 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(SDPStreamMetadataPurpose.Usermedia, "video"), - )?.sender; - - sender?.replaceTrack(track ?? null); - - this.pushNewLocalFeed(stream, SDPStreamMetadataPurpose.Screenshare, false); - - return true; - } catch (err) { - 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(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 - */ - public async updateLocalUsermediaStream( - stream: MediaStream, - forceAudio = false, - forceVideo = false, - ): Promise<void> { - const callFeed = this.localUsermediaFeed!; - const audioEnabled = forceAudio || (!callFeed.isAudioMuted() && !this.remoteOnHold); - const videoEnabled = forceVideo || (!callFeed.isVideoMuted() && !this.remoteOnHold); - 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(SDPStreamMetadataPurpose.Usermedia, track.kind); - - const transceiver = this.transceivers.get(tKey); - const oldSender = transceiver?.sender; - let added = false; - if (oldSender) { - try { - 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.warn( - `Call ${this.callId} updateLocalUsermediaStream() replaceTrack failed: adding new transceiver instead`, - error, - ); - } - } - - if (!added) { - 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.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 - */ - public async setLocalVideoMuted(muted: boolean): Promise<boolean> { - 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). - */ - public isLocalVideoMuted(): boolean { - 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 - */ - public async setMicrophoneMuted(muted: boolean): Promise<boolean> { - 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). - */ - public isMicrophoneMuted(): boolean { - 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) - */ - public isRemoteOnHold(): boolean { - return this.remoteOnHold; - } - - public setRemoteOnHold(onHold: boolean): void { - 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 - */ - public isLocalOnHold(): boolean { - 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) - */ - public sendDtmfDigit(digit: string): void { - for (const sender of this.peerConn!.getSenders()) { - if (sender.track?.kind === "audio" && sender.dtmf) { - sender.dtmf.insertDTMF(digit); - return; - } - } - - throw new Error("Unable to find a track to send DTMF on"); - } - - private updateMuteStatus(): void { - const micShouldBeMuted = this.isMicrophoneMuted() || this.remoteOnHold; - const vidShouldBeMuted = this.isLocalVideoMuted() || this.remoteOnHold; - - logger.log( - `Call ${this.callId} updateMuteStatus stream ${ - this.localUsermediaStream!.id - } micShouldBeMuted ${micShouldBeMuted} vidShouldBeMuted ${vidShouldBeMuted}`, - ); - - setTracksEnabled(this.localUsermediaStream!.getAudioTracks(), !micShouldBeMuted); - setTracksEnabled(this.localUsermediaStream!.getVideoTracks(), !vidShouldBeMuted); - } - - public async sendMetadataUpdate(): Promise<void> { - await this.sendVoipEvent(EventType.CallSDPStreamMetadataChangedPrefix, { - [SDPStreamMetadataKey]: this.getLocalSDPStreamMetadata(), - }); - } - - private gotCallFeedsForInvite(callFeeds: CallFeed[], requestScreenshareFeed = false): void { - 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.debug(`Call ${this.callId} gotUserMediaForInvite() run`); - // Now we wait for the negotiationneeded event - } - - private async sendAnswer(): Promise<void> { - 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, - }, - [SDPStreamMetadataKey]: this.getLocalSDPStreamMetadata(true), - } as MCallAnswer; - - 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.info( - `Call ${this.callId} sendAnswer() discarding ${discardCount} candidates that will be sent in answer`, - ); - - try { - await this.sendVoipEvent(EventType.CallAnswer, answerContent); - // If this isn't the first time we've tried to send the answer, - // we may have candidates queued up, so send them now. - this.inviteOrAnswerSent = true; - } catch (error) { - // We've failed to answer: back to the ringing state - this.state = CallState.Ringing; - if (error instanceof MatrixError && error.event) this.client.cancelPendingEvent(error.event); - - let code = CallErrorCode.SendAnswer; - let message = "Failed to send answer"; - if ((<Error>error).name == "UnknownDeviceError") { - code = CallErrorCode.UnknownDevices; - message = "Unknown devices present in the room"; - } - this.emit(CallEvent.Error, new CallError(code, message, <Error>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(); - } - - private queueGotCallFeedsForAnswer(callFeeds: CallFeed[]): void { - // 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 - private mungeSdp(description: RTCSessionDescriptionInit, mods: CodecParamsMod[]): void { - // The only way to enable DTX at this time is through SDP munging - const sdp = parseSdp(description.sdp!); - - sdp.media.forEach((media) => { - const payloadTypeToCodecMap = new Map<number, string>(); - const codecToPayloadTypeMap = new Map<string, number>(); - 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.info( - `Call ${this.callId} mungeSdp() ignoring SDP modifications for ${mod.codec} as it's not present.`, - ); - continue; - } - - const extraConfig: string[] = []; - 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 = writeSdp(sdp); - } - - private async createOffer(): Promise<RTCSessionDescriptionInit> { - const offer = await this.peerConn!.createOffer(); - this.mungeSdp(offer, getCodecParamMods(this.isPtt)); - return offer; - } - - private async createAnswer(): Promise<RTCSessionDescriptionInit> { - const answer = await this.peerConn!.createAnswer(); - this.mungeSdp(answer, getCodecParamMods(this.isPtt)); - return answer; - } - - private async gotCallFeedsForAnswer(callFeeds: CallFeed[]): Promise<void> { - if (this.callHasEnded()) return; - - this.waitForLocalAVStream = false; - - for (const feed of callFeeds) { - this.pushLocalFeed(feed); - } - - this.state = CallState.CreateAnswer; - - let answer: RTCSessionDescriptionInit; - try { - this.getRidOfRTXCodecs(); - answer = await this.createAnswer(); - } catch (err) { - 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.debug(`Call ${this.callId} gotCallFeedsForAnswer() error setting local description!`, err); - this.terminate(CallParty.Local, CallErrorCode.SetLocalDescription, true); - return; - } - } - - /** - * Internal - */ - private gotLocalIceCandidate = (event: RTCPeerConnectionIceEvent): void => { - if (event.candidate) { - if (this.candidatesEnded) { - logger.warn( - `Call ${this.callId} gotLocalIceCandidate() got candidate after candidates have ended - ignoring!`, - ); - return; - } - - 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); - } - } - }; - - private onIceGatheringStateChange = (event: Event): void => { - logger.debug( - `Call ${this.callId} onIceGatheringStateChange() ice gathering state changed to ${ - this.peerConn!.iceGatheringState - }`, - ); - if (this.peerConn?.iceGatheringState === "complete") { - this.queueCandidate(null); - } - }; - - public async onRemoteIceCandidatesReceived(ev: MatrixEvent): Promise<void> { - if (this.callHasEnded()) { - //debuglog("Ignoring remote ICE candidate because call has ended"); - return; - } - - const content = ev.getContent<MCallCandidates>(); - const candidates = content.candidates; - if (!candidates) { - 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.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.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. - */ - public async onAnswerReceived(event: MatrixEvent): Promise<void> { - const content = event.getContent<MCallAnswer>(); - logger.debug(`Call ${this.callId} onAnswerReceived() running (hangupParty=${content.party_id})`); - - if (this.callHasEnded()) { - logger.debug(`Call ${this.callId} onAnswerReceived() ignoring answer because call has ended`); - return; - } - - if (this.opponentPartyId !== undefined) { - 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[SDPStreamMetadataKey]; - if (sdpStreamMetadata) { - this.updateRemoteSDPStreamMetadata(sdpStreamMetadata); - } else { - logger.warn( - `Call ${this.callId} onAnswerReceived() did not get any SDPStreamMetadata! Can not send/receive multiple streams`, - ); - } - - try { - await this.peerConn!.setRemoteDescription(content.answer); - } catch (e) { - 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(EventType.CallSelectAnswer, { - selected_party_id: this.opponentPartyId, - }); - } catch (err) { - // This isn't fatal, and will just mean that if another party has raced to answer - // the call, they won't know they got rejected, so we carry on & don't retry. - logger.warn(`Call ${this.callId} onAnswerReceived() failed to send select_answer event`, err); - } - } - } - - public async onSelectAnswerReceived(event: MatrixEvent): Promise<void> { - if (this.direction !== CallDirection.Inbound) { - logger.warn( - `Call ${this.callId} onSelectAnswerReceived() got select_answer for an outbound call: ignoring`, - ); - return; - } - - const selectedPartyId = event.getContent<MCallSelectAnswer>().selected_party_id; - - if (selectedPartyId === undefined || selectedPartyId === null) { - logger.warn( - `Call ${this.callId} onSelectAnswerReceived() got nonsensical select_answer with null/undefined selected_party_id: ignoring`, - ); - return; - } - - if (selectedPartyId !== this.ourPartyId) { - 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); - } - } - - public async onNegotiateReceived(event: MatrixEvent): Promise<void> { - const content = event.getContent<MCallInviteNegotiate>(); - const description = content.description; - if (!description || !description.sdp || !description.type) { - 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://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Perfect_negotiation - const offerCollision = - description.type === "offer" && (this.makingOffer || this.peerConn!.signalingState !== "stable"); - - this.ignoreOffer = !polite && offerCollision; - if (this.ignoreOffer) { - logger.info( - `Call ${this.callId} onNegotiateReceived() ignoring colliding negotiate event because we're impolite`, - ); - return; - } - - const prevLocalOnHold = this.isLocalOnHold(); - - const sdpStreamMetadata = content[SDPStreamMetadataKey]; - if (sdpStreamMetadata) { - this.updateRemoteSDPStreamMetadata(sdpStreamMetadata); - } else { - logger.warn( - `Call ${this.callId} onNegotiateReceived() received negotiation event without SDPStreamMetadata!`, - ); - } - - try { - await this.peerConn!.setRemoteDescription(description); - - if (description.type === "offer") { - let answer: RTCSessionDescriptionInit; - try { - this.getRidOfRTXCodecs(); - answer = await this.createAnswer(); - } catch (err) { - logger.debug(`Call ${this.callId} onNegotiateReceived() failed to create answer: `, err); - this.terminate(CallParty.Local, CallErrorCode.CreateAnswer, true); - return; - } - - await this.peerConn!.setLocalDescription(answer); - - this.sendVoipEvent(EventType.CallNegotiate, { - description: this.peerConn!.localDescription?.toJSON(), - [SDPStreamMetadataKey]: this.getLocalSDPStreamMetadata(true), - }); - } - } catch (err) { - 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); - } - } - - private updateRemoteSDPStreamMetadata(metadata: SDPStreamMetadata): void { - this.remoteSDPStreamMetadata = 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; - } - } - - public onSDPStreamMetadataChangedReceived(event: MatrixEvent): void { - const content = event.getContent<MCallSDPStreamMetadataChanged>(); - const metadata = content[SDPStreamMetadataKey]; - this.updateRemoteSDPStreamMetadata(metadata); - } - - public async onAssertedIdentityReceived(event: MatrixEvent): Promise<void> { - const content = event.getContent<MCAllAssertedIdentity>(); - if (!content.asserted_identity) return; - - this.remoteAssertedIdentity = { - id: content.asserted_identity.id, - displayName: content.asserted_identity.display_name, - }; - this.emit(CallEvent.AssertedIdentityChanged, this); - } - - public callHasEnded(): boolean { - // This exists as workaround to typescript trying to be clever and erroring - // when putting if (this.state === CallState.Ended) return; twice in the same - // function, even though that function is async. - return this.state === CallState.Ended; - } - - private queueGotLocalOffer(): void { - // 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(); - } - } - - private async wrappedGotLocalOffer(): Promise<void> { - 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 as Error); - return; - } finally { - this.makingOffer = false; - } - } - - private async gotLocalOffer(): Promise<void> { - logger.debug(`Call ${this.callId} gotLocalOffer() running`); - - if (this.callHasEnded()) { - logger.debug( - `Call ${this.callId} gotLocalOffer() ignoring newly created offer because the call has ended"`, - ); - return; - } - - let offer: RTCSessionDescriptionInit; - try { - this.getRidOfRTXCodecs(); - offer = await this.createOffer(); - } catch (err) { - 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.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 ? EventType.CallInvite : EventType.CallNegotiate; - - const content = { - lifetime: CALL_TIMEOUT_MS, - } as MCallInviteNegotiate; - - if (eventType === 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[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.info( - `Call ${this.callId} gotLocalOffer() discarding ${discardCount} candidates that will be sent in offer`, - ); - - try { - await this.sendVoipEvent(eventType, content); - } catch (error) { - logger.error(`Call ${this.callId} gotLocalOffer() failed to send invite`, error); - if (error instanceof 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>error).name == "UnknownDeviceError") { - code = CallErrorCode.UnknownDevices; - message = "Unknown devices present in the room"; - } - - this.emit(CallEvent.Error, new CallError(code, message, <Error>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); - } - } - - private getLocalOfferFailed = (err: Error): void => { - 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); - }; - - private getUserMediaFailed = (err: Error): void => { - if (this.successor) { - this.successor.getUserMediaFailed(err); - return; - } - - 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); - }; - - private onIceConnectionStateChanged = (): void => { - if (this.callHasEnded()) { - return; // because ICE can still complete as we're ending the call - } - logger.debug( - `Call ${this.callId} onIceConnectionStateChanged() running (state=${this.peerConn?.iceConnectionState})`, - ); - - // ideally we'd consider the call to be connected when we get media but - // chrome doesn't implement any of the 'onstarted' events yet - if (["connected", "completed"].includes(this.peerConn?.iceConnectionState ?? "")) { - clearTimeout(this.iceDisconnectedTimeout); - this.iceDisconnectedTimeout = undefined; - 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") { - // 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 as (() => void) | null) { - this.candidatesEnded = false; - this.peerConn!.restartIce(); - } else { - 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.iceDisconnectedTimeout = setTimeout(() => { - 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); - } - } - }; - - private onSignallingStateChanged = (): void => { - logger.debug(`Call ${this.callId} onSignallingStateChanged() running (state=${this.peerConn?.signalingState})`); - }; - - private onTrack = (ev: RTCTrackEvent): void => { - if (ev.streams.length === 0) { - 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 = (): void => { - if (stream.getTracks().length === 0) { - 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); - } - }; - - private onDataChannel = (ev: RTCDataChannelEvent): void => { - this.emit(CallEvent.DataChannel, ev.channel, this); - }; - - /** - * 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) - */ - private getRidOfRTXCodecs(): void { - // RTCRtpReceiver.getCapabilities and RTCRtpSender.getCapabilities don't seem to be supported on FF - 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(SDPStreamMetadataPurpose.Screenshare, "video"), - ); - if (screenshareVideoTransceiver) screenshareVideoTransceiver.setCodecPreferences(codecs); - } - - private onNegotiationNeeded = async (): Promise<void> => { - logger.info(`Call ${this.callId} onNegotiationNeeded() negotiation is needed!`); - - if (this.state !== CallState.CreateOffer && this.opponentVersion === 0) { - logger.info( - `Call ${this.callId} onNegotiationNeeded() opponent does not support renegotiation: ignoring negotiationneeded event`, - ); - return; - } - - this.queueGotLocalOffer(); - }; - - public onHangupReceived = (msg: MCallHangupReject): void => { - 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.info( - `Call ${this.callId} onHangupReceived() ignoring message from party ID ${msg.party_id}: our partner is ${this.opponentPartyId}`, - ); - } - }; - - public onRejectReceived = (msg: MCallHangupReject): void => { - 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.debug(`Call ${this.callId} onRejectReceived() called in wrong state (state=${this.state})`); - } - }; - - public onAnsweredElsewhere = (msg: MCallAnswer): void => { - logger.debug(`Call ${this.callId} onAnsweredElsewhere() running`); - this.terminate(CallParty.Remote, CallErrorCode.AnsweredElsewhere, true); - }; - - /** - * @internal - */ - private async sendVoipEvent(eventType: string, content: object): Promise<void> { - 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 = { - ...realContent, - device_id: this.client.deviceId, - sender_session_id: this.client.getSessionId(), - dest_session_id: this.opponentSessionId, - seq: toDeviceSeq, - [ToDeviceMessageId]: uuidv4(), - }; - - 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.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<string, any>([[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 - */ - private queueCandidate(content: RTCIceCandidate | null): void { - // 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 - private discardDuplicateCandidates(): number { - let discardCount = 0; - const newQueue: RTCIceCandidate[] = []; - - 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 - */ - public async transfer(targetUserId: string): Promise<void> { - // 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, - } as MCallReplacesEvent; - - await this.sendVoipEvent(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). - */ - public async transferToCall(transferTargetCall: MatrixCall): Promise<void> { - 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, - } as MCallReplacesEvent; - - await transferTargetCall.sendVoipEvent(EventType.CallReplaces, bodyToTransferTarget); - - const bodyToTransferee = { - replacement_id: genCallID(), - target_user: { - id: targetUserId, - display_name: targetProfileInfo?.displayname, - avatar_url: targetProfileInfo?.avatar_url, - }, - create_call: newCallId, - } as MCallReplacesEvent; - - await this.sendVoipEvent(EventType.CallReplaces, bodyToTransferee); - - await this.terminate(CallParty.Local, CallErrorCode.Transferred, true); - await transferTargetCall.terminate(CallParty.Local, CallErrorCode.Transferred, true); - } - - private async terminate(hangupParty: CallParty, hangupReason: CallErrorCode, shouldEmit: boolean): Promise<void> { - 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); - } - - private stopAllMedia(): void { - 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 === SDPStreamMetadataPurpose.Usermedia) { - this.client.getMediaHandler().stopUserMediaStream(feed.stream); - } else if (feed.isLocal() && feed.purpose === SDPStreamMetadataPurpose.Screenshare) { - this.client.getMediaHandler().stopScreensharingStream(feed.stream); - } else if (!feed.isLocal()) { - logger.debug(`Call ${this.callId} stopAllMedia() stopping stream (streamId=${feed.stream.id})`); - for (const track of feed.stream.getTracks()) { - track.stop(); - } - } - } - } - - private checkForErrorListener(): void { - if (this.listeners(EventEmitterEvents.Error).length === 0) { - throw new Error("You MUST attach an error listener using call.on('error', function() {})"); - } - } - - private async sendCandidateQueue(): Promise<void> { - 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.debug(`Call ${this.callId} sendCandidateQueue() attempting to send ${candidates.length} candidates`); - try { - await this.sendVoipEvent(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 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.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>error), this); - this.hangup(code, false); - - return; - } - - const delayMs = 500 * Math.pow(2, this.candidateSendTries); - ++this.candidateSendTries; - 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. - */ - public async placeCall(audio: boolean, video: boolean): Promise<void> { - 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({ - client: this.client, - roomId: this.roomId, - userId: this.client.getUserId()!, - deviceId: this.client.getDeviceId() ?? undefined, - stream, - purpose: SDPStreamMetadataPurpose.Usermedia, - audioMuted: false, - videoMuted: false, - }); - await this.placeCallWithCallFeeds([callFeed]); - } catch (e) { - this.getUserMediaFailed(<Error>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. - */ - public async placeCallWithCallFeeds(callFeeds: CallFeed[], requestScreenshareFeed = false): Promise<void> { - 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.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.gotCallFeedsForInvite(callFeeds, requestScreenshareFeed); - } - - private createPeerConnection(): RTCPeerConnection { - 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); - - this.stats?.addStatsReportGatherer(this.callId, "unknown", pc); - return pc; - } - - private partyIdMatches(msg: MCallBase): boolean { - // They must either match or both be absent (in which case opponentPartyId will be null) - // Also we ignore party IDs on the invite/offer if the version is 0, so we must do the same - // here and use null if the version is 0 (woe betide any opponent sending messages in the - // same call with different versions) - const msgPartyId = msg.version === 0 ? null : msg.party_id || null; - return msgPartyId === this.opponentPartyId; - } - - // Commits to an opponent for the call - // ev: An invite or answer event - private chooseOpponent(ev: MatrixEvent): void { - // I choo-choo-choose you - const msg = ev.getContent<MCallInviteNegotiate | MCallAnswer>(); - - 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 || ({} as CallCapabilities); - this.opponentMember = this.client.getRoom(this.roomId)!.getMember(ev.getSender()!) ?? undefined; - } - - private async addBufferedIceCandidates(): Promise<void> { - const bufferedCandidates = this.remoteCandidateBuffer.get(this.opponentPartyId!); - if (bufferedCandidates) { - logger.info( - `Call ${this.callId} addBufferedIceCandidates() adding ${bufferedCandidates.length} buffered candidates for opponent ${this.opponentPartyId}`, - ); - await this.addIceCandidates(bufferedCandidates); - } - this.remoteCandidateBuffer.clear(); - } - - private async addIceCandidates(candidates: RTCIceCandidate[]): Promise<void> { - for (const candidate of candidates) { - if ( - (candidate.sdpMid === null || candidate.sdpMid === undefined) && - (candidate.sdpMLineIndex === null || candidate.sdpMLineIndex === undefined) - ) { - logger.debug(`Call ${this.callId} addIceCandidates() got remote ICE end-of-candidates`); - } else { - 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.info(`Call ${this.callId} addIceCandidates() failed to add remote ICE candidate`, err); - } - } - } - } - - public get hasPeerConnection(): boolean { - return Boolean(this.peerConn); - } - - public initStats(stats: GroupCallStats, peerId = "unknown"): void { - this.stats = stats; - this.stats.start(); - } -} - -export function setTracksEnabled(tracks: Array<MediaStreamTrack>, enabled: boolean): void { - for (const track of tracks) { - track.enabled = enabled; - } -} - -export function supportsMatrixCall(): boolean { - // 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.error("WebRTC is not supported in this browser / environment"); - } - return false; - } - } catch (e) { - 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. - */ -export function createNewMatrixCall( - client: MatrixClient, - roomId: string, - options?: Pick<CallOpts, "forceTURN" | "invitee" | "opponentDeviceId" | "opponentSessionId" | "groupCallId">, -): MatrixCall | null { - if (!supportsMatrixCall()) return null; - - const optionsForceTURN = options ? options.forceTURN : false; - - const opts: CallOpts = { - 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; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callEventHandler.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callEventHandler.ts deleted file mode 100644 index 4ee183a..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callEventHandler.ts +++ /dev/null @@ -1,425 +0,0 @@ -/* -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { MatrixEvent } from "../models/event"; -import { logger } from "../logger"; -import { CallDirection, CallError, CallErrorCode, CallState, createNewMatrixCall, MatrixCall } from "./call"; -import { EventType } from "../@types/event"; -import { ClientEvent, MatrixClient } from "../client"; -import { MCallAnswer, MCallHangupReject } from "./callEventTypes"; -import { GroupCall, GroupCallErrorCode, GroupCallEvent, GroupCallUnknownDeviceError } from "./groupCall"; -import { RoomEvent } from "../models/room"; - -// Don't ring unless we'd be ringing for at least 3 seconds: the user needs some -// time to press the 'accept' button -const RING_GRACE_PERIOD = 3000; - -export enum CallEventHandlerEvent { - Incoming = "Call.incoming", -} - -export type CallEventHandlerEventHandlerMap = { - /** - * Fires whenever an incoming call arrives. - * @param call - The incoming call. - * @example - * ``` - * matrixClient.on("Call.incoming", function(call){ - * call.answer(); // auto-answer - * }); - * ``` - */ - [CallEventHandlerEvent.Incoming]: (call: MatrixCall) => void; -}; - -export class CallEventHandler { - // XXX: Most of these are only public because of the tests - public calls: Map<string, MatrixCall>; - public callEventBuffer: MatrixEvent[]; - public nextSeqByCall: Map<string, number> = new Map(); - public toDeviceEventBuffers: Map<string, Array<MatrixEvent>> = new Map(); - - private client: MatrixClient; - private candidateEventsByCall: Map<string, Array<MatrixEvent>>; - private eventBufferPromiseChain?: Promise<void>; - - public constructor(client: MatrixClient) { - this.client = client; - this.calls = new Map<string, MatrixCall>(); - // 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<string, Array<MatrixEvent>>(); - } - - public start(): void { - this.client.on(ClientEvent.Sync, this.onSync); - this.client.on(RoomEvent.Timeline, this.onRoomTimeline); - this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); - } - - public stop(): void { - this.client.removeListener(ClientEvent.Sync, this.onSync); - this.client.removeListener(RoomEvent.Timeline, this.onRoomTimeline); - this.client.removeListener(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); - } - - private onSync = (): void => { - // 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); - } - }; - - private async evaluateEventBuffer(eventBuffer: MatrixEvent[]): Promise<void> { - 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<string>(); - - // 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 === EventType.CallAnswer || eventType === 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 === 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.error("CallEventHandler evaluateEventBuffer() caught exception handling call event", e); - } - } - } - - private onRoomTimeline = (event: MatrixEvent): void => { - this.callEventBuffer.push(event); - }; - - private onToDeviceEvent = (event: MatrixEvent): void => { - 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(); - } - } - }; - - private async handleCallEvent(event: MatrixEvent): Promise<void> { - this.client.emit(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() as EventType; - const senderId = event.getSender()!; - let call = content.call_id ? this.calls.get(content.call_id) : undefined; - - let opponentDeviceId: string | undefined; - - let groupCall: GroupCall | undefined; - if (groupCallId) { - groupCall = this.client.groupCallEventHandler!.getGroupCallById(groupCallId); - - if (!groupCall) { - logger.warn( - `CallEventHandler handleCallEvent() could not find a group call - ignoring event (groupCallId=${groupCallId}, type=${type})`, - ); - return; - } - - opponentDeviceId = content.device_id; - - if (!opponentDeviceId) { - logger.warn( - `CallEventHandler handleCallEvent() could not find a device id - ignoring event (senderId=${senderId})`, - ); - groupCall.emit(GroupCallEvent.Error, new GroupCallUnknownDeviceError(senderId)); - return; - } - - if (content.dest_session_id !== this.client.getSessionId()) { - 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 === 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 === CallState.Ended) return; - - if (call) { - 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.info( - "CallEventHandler handleCallEvent() current turn creds expire in " + timeUntilTurnCresExpire + " ms", - ); - call = - createNewMatrixCall(this.client, callRoomId, { - forceTURN: this.client.forceTURN, - opponentDeviceId, - groupCallId, - opponentSessionId: content.sender_session_id, - }) ?? undefined; - if (!call) { - 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 CallError) { - if (e.code === GroupCallErrorCode.UnknownDevice) { - groupCall?.emit(GroupCallEvent.Error, e); - } else { - 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: MatrixCall | undefined; - for (const thisCall of this.calls.values()) { - const isCalling = [CallState.WaitLocalMedia, CallState.CreateOffer, CallState.InviteSent].includes( - thisCall.state, - ); - - if ( - call.roomId === thisCall.roomId && - thisCall.direction === CallDirection.Outbound && - call.getOpponentMember()?.userId === thisCall.invitee && - isCalling - ) { - existingCall = thisCall; - break; - } - } - - if (existingCall) { - if (existingCall.callId > call.callId) { - logger.log( - `CallEventHandler handleCallEvent() detected glare - answering incoming call and canceling outgoing call (incomingId=${call.callId}, outgoingId=${existingCall.callId})`, - ); - existingCall.replacedBy(call); - } else { - logger.log( - `CallEventHandler handleCallEvent() detected glare - hanging up incoming call (incomingId=${call.callId}, outgoingId=${existingCall.callId})`, - ); - call.hangup(CallErrorCode.Replaced, true); - } - } else { - this.client.emit(CallEventHandlerEvent.Incoming, call); - } - return; - } else if (type === 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 ([EventType.CallHangup, 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 = - 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 !== CallState.Ended) { - if (type === EventType.CallHangup) { - call.onHangupReceived(content as MCallHangupReject); - } else { - call.onRejectReceived(content as MCallHangupReject); - } - - // @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 === CallState.Ended) this.calls.delete(content.call_id); - } - } - return; - } - - // The following events need a call and a peer connection - if (!call || !call.hasPeerConnection) { - 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 EventType.CallAnswer: - if (weSentTheEvent) { - if (call.state === CallState.Ringing) { - call.onAnsweredElsewhere(content as MCallAnswer); - } - } else { - call.onAnswerReceived(event); - } - break; - case EventType.CallSelectAnswer: - call.onSelectAnswerReceived(event); - break; - - case EventType.CallNegotiate: - call.onNegotiateReceived(event); - break; - - case EventType.CallAssertedIdentity: - case EventType.CallAssertedIdentityPrefix: - call.onAssertedIdentityReceived(event); - break; - - case EventType.CallSDPStreamMetadataChanged: - case EventType.CallSDPStreamMetadataChangedPrefix: - call.onSDPStreamMetadataChangedReceived(event); - break; - } - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callEventTypes.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callEventTypes.ts deleted file mode 100644 index f06ed5b..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callEventTypes.ts +++ /dev/null @@ -1,92 +0,0 @@ -// allow non-camelcase as these are events type that go onto the wire -/* eslint-disable camelcase */ - -import { CallErrorCode } from "./call"; - -// TODO: Change to "sdp_stream_metadata" when MSC3077 is merged -export const SDPStreamMetadataKey = "org.matrix.msc3077.sdp_stream_metadata"; - -export enum SDPStreamMetadataPurpose { - Usermedia = "m.usermedia", - Screenshare = "m.screenshare", -} - -export interface SDPStreamMetadataObject { - purpose: SDPStreamMetadataPurpose; - audio_muted: boolean; - video_muted: boolean; -} - -export interface SDPStreamMetadata { - [key: string]: SDPStreamMetadataObject; -} - -export interface CallCapabilities { - "m.call.transferee": boolean; - "m.call.dtmf": boolean; -} - -export interface CallReplacesTarget { - id: string; - display_name: string; - avatar_url: string; -} - -export interface MCallBase { - call_id: string; - version: string | number; - party_id?: string; - sender_session_id?: string; - dest_session_id?: string; -} - -export interface MCallAnswer extends MCallBase { - answer: RTCSessionDescription; - capabilities?: CallCapabilities; - [SDPStreamMetadataKey]: SDPStreamMetadata; -} - -export interface MCallSelectAnswer extends MCallBase { - selected_party_id: string; -} - -export interface MCallInviteNegotiate extends MCallBase { - offer: RTCSessionDescription; - description: RTCSessionDescription; - lifetime: number; - capabilities?: CallCapabilities; - invitee?: string; - sender_session_id?: string; - dest_session_id?: string; - [SDPStreamMetadataKey]: SDPStreamMetadata; -} - -export interface MCallSDPStreamMetadataChanged extends MCallBase { - [SDPStreamMetadataKey]: SDPStreamMetadata; -} - -export interface MCallReplacesEvent extends MCallBase { - replacement_id: string; - target_user: CallReplacesTarget; - create_call: string; - await_call: string; - target_room: string; -} - -export interface MCAllAssertedIdentity extends MCallBase { - asserted_identity: { - id: string; - display_name: string; - avatar_url: string; - }; -} - -export interface MCallCandidates extends MCallBase { - candidates: RTCIceCandidate[]; -} - -export interface MCallHangupReject extends MCallBase { - reason?: CallErrorCode; -} - -/* eslint-enable camelcase */ diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callFeed.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callFeed.ts deleted file mode 100644 index 505cf56..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callFeed.ts +++ /dev/null @@ -1,361 +0,0 @@ -/* -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. -*/ - -import { SDPStreamMetadataPurpose } from "./callEventTypes"; -import { acquireContext, releaseContext } from "./audioContext"; -import { MatrixClient } from "../client"; -import { RoomMember } from "../models/room-member"; -import { logger } from "../logger"; -import { TypedEventEmitter } from "../models/typed-event-emitter"; -import { CallEvent, CallState, MatrixCall } from "./call"; - -const POLLING_INTERVAL = 200; // ms -export const SPEAKING_THRESHOLD = -60; // dB -const SPEAKING_SAMPLE_COUNT = 8; // samples - -export interface ICallFeedOpts { - client: MatrixClient; - roomId?: string; - userId: string; - deviceId: string | undefined; - stream: MediaStream; - purpose: SDPStreamMetadataPurpose; - /** - * Whether or not the remote SDPStreamMetadata says audio is muted - */ - audioMuted: boolean; - /** - * Whether or not the remote SDPStreamMetadata says video is muted - */ - videoMuted: boolean; - /** - * The MatrixCall which is the source of this CallFeed - */ - call?: MatrixCall; -} - -export enum CallFeedEvent { - NewStream = "new_stream", - MuteStateChanged = "mute_state_changed", - LocalVolumeChanged = "local_volume_changed", - VolumeChanged = "volume_changed", - ConnectedChanged = "connected_changed", - Speaking = "speaking", - Disposed = "disposed", -} - -type EventHandlerMap = { - [CallFeedEvent.NewStream]: (stream: MediaStream) => void; - [CallFeedEvent.MuteStateChanged]: (audioMuted: boolean, videoMuted: boolean) => void; - [CallFeedEvent.LocalVolumeChanged]: (localVolume: number) => void; - [CallFeedEvent.VolumeChanged]: (volume: number) => void; - [CallFeedEvent.ConnectedChanged]: (connected: boolean) => void; - [CallFeedEvent.Speaking]: (speaking: boolean) => void; - [CallFeedEvent.Disposed]: () => void; -}; - -export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap> { - public stream: MediaStream; - public sdpMetadataStreamId: string; - public userId: string; - public readonly deviceId: string | undefined; - public purpose: SDPStreamMetadataPurpose; - public speakingVolumeSamples: number[]; - - private client: MatrixClient; - private call?: MatrixCall; - private roomId?: string; - private audioMuted: boolean; - private videoMuted: boolean; - private localVolume = 1; - private measuringVolumeActivity = false; - private audioContext?: AudioContext; - private analyser?: AnalyserNode; - private frequencyBinCount?: Float32Array; - private speakingThreshold = SPEAKING_THRESHOLD; - private speaking = false; - private volumeLooperTimeout?: ReturnType<typeof setTimeout>; - private _disposed = false; - private _connected = false; - - public constructor(opts: ICallFeedOpts) { - super(); - - 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(CallEvent.State, this.onCallState); - this.onCallState(opts.call.state); - } - } - - public get connected(): boolean { - // Local feeds are always considered connected - return this.isLocal() || this._connected; - } - - private set connected(connected: boolean) { - this._connected = connected; - this.emit(CallFeedEvent.ConnectedChanged, this.connected); - } - - private get hasAudioTrack(): boolean { - return this.stream.getAudioTracks().length > 0; - } - - private updateStream(oldStream: MediaStream | null, newStream: MediaStream): void { - if (newStream === oldStream) return; - - if (oldStream) { - oldStream.removeEventListener("addtrack", this.onAddTrack); - this.measureVolumeActivity(false); - } - - this.stream = newStream; - newStream.addEventListener("addtrack", this.onAddTrack); - - if (this.hasAudioTrack) { - this.initVolumeMeasuring(); - } else { - this.measureVolumeActivity(false); - } - - this.emit(CallFeedEvent.NewStream, this.stream); - } - - private initVolumeMeasuring(): void { - if (!this.hasAudioTrack) return; - if (!this.audioContext) this.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); - } - - private onAddTrack = (): void => { - this.emit(CallFeedEvent.NewStream, this.stream); - }; - - private onCallState = (state: CallState): void => { - if (state === CallState.Connected) { - this.connected = true; - } else if (state === CallState.Connecting) { - this.connected = false; - } - }; - - /** - * Returns callRoom member - * @returns member of the callRoom - */ - public getMember(): RoomMember | null { - 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? - */ - public isLocal(): boolean { - 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? - */ - public isAudioMuted(): boolean { - 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? - */ - public isVideoMuted(): boolean { - // We assume only one video track - return this.stream.getVideoTracks().length === 0 || this.videoMuted; - } - - public isSpeaking(): boolean { - 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 - */ - public setNewStream(newStream: MediaStream): void { - 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? - */ - public setAudioVideoMuted(audioMuted: boolean | null, videoMuted: boolean | null): void { - 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 - */ - public measureVolumeActivity(enabled: boolean): void { - 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); - } - } - - public setSpeakingThreshold(threshold: number): void { - this.speakingThreshold = threshold; - } - - private volumeLooper = (): void => { - 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); - }; - - public clone(): CallFeed { - const mediaHandler = this.client.getMediaHandler(); - const stream = this.stream.clone(); - logger.log(`CallFeed clone() cloning stream (originalStreamId=${this.stream.id}, newStreamId${stream.id})`); - - if (this.purpose === 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, - }); - } - - public dispose(): void { - clearTimeout(this.volumeLooperTimeout); - this.stream?.removeEventListener("addtrack", this.onAddTrack); - this.call?.removeListener(CallEvent.State, this.onCallState); - if (this.audioContext) { - this.audioContext = undefined; - this.analyser = undefined; - releaseContext(); - } - this._disposed = true; - this.emit(CallFeedEvent.Disposed); - } - - public get disposed(): boolean { - return this._disposed; - } - - private set disposed(value: boolean) { - this._disposed = value; - } - - public getLocalVolume(): number { - return this.localVolume; - } - - public setLocalVolume(localVolume: number): void { - this.localVolume = localVolume; - this.emit(CallFeedEvent.LocalVolumeChanged, localVolume); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCall.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCall.ts deleted file mode 100644 index c0896c4..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCall.ts +++ /dev/null @@ -1,1598 +0,0 @@ -import { TypedEventEmitter } from "../models/typed-event-emitter"; -import { CallFeed, SPEAKING_THRESHOLD } from "./callFeed"; -import { MatrixClient, IMyDevice } from "../client"; -import { - CallErrorCode, - CallEvent, - CallEventHandlerMap, - CallState, - genCallID, - MatrixCall, - setTracksEnabled, - createNewMatrixCall, - CallError, -} from "./call"; -import { RoomMember } from "../models/room-member"; -import { Room } from "../models/room"; -import { RoomStateEvent } from "../models/room-state"; -import { logger } from "../logger"; -import { ReEmitter } from "../ReEmitter"; -import { SDPStreamMetadataPurpose } from "./callEventTypes"; -import { MatrixEvent } from "../models/event"; -import { EventType } from "../@types/event"; -import { CallEventHandlerEvent } from "./callEventHandler"; -import { GroupCallEventHandlerEvent } from "./groupCallEventHandler"; -import { IScreensharingOpts } from "./mediaHandler"; -import { mapsEqual } from "../utils"; -import { GroupCallStats } from "./stats/groupCallStats"; -import { ByteSentStatsReport, ConnectionStatsReport, StatsReport } from "./stats/statsReport"; - -export enum GroupCallIntent { - Ring = "m.ring", - Prompt = "m.prompt", - Room = "m.room", -} - -export enum GroupCallType { - Video = "m.video", - Voice = "m.voice", -} - -export enum GroupCallTerminationReason { - CallEnded = "call_ended", -} - -export type CallsByUserAndDevice = Map<string, Map<string, MatrixCall>>; - -/** - * 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. - */ -export enum GroupCallEvent { - GroupCallStateChanged = "group_call_state_changed", - ActiveSpeakerChanged = "active_speaker_changed", - CallsChanged = "calls_changed", - UserMediaFeedsChanged = "user_media_feeds_changed", - ScreenshareFeedsChanged = "screenshare_feeds_changed", - LocalScreenshareStateChanged = "local_screenshare_state_changed", - LocalMuteStateChanged = "local_mute_state_changed", - ParticipantsChanged = "participants_changed", - Error = "group_call_error", -} - -export type GroupCallEventHandlerMap = { - [GroupCallEvent.GroupCallStateChanged]: (newState: GroupCallState, oldState: GroupCallState) => void; - [GroupCallEvent.ActiveSpeakerChanged]: (activeSpeaker: CallFeed | undefined) => void; - [GroupCallEvent.CallsChanged]: (calls: CallsByUserAndDevice) => void; - [GroupCallEvent.UserMediaFeedsChanged]: (feeds: CallFeed[]) => void; - [GroupCallEvent.ScreenshareFeedsChanged]: (feeds: CallFeed[]) => void; - [GroupCallEvent.LocalScreenshareStateChanged]: ( - isScreensharing: boolean, - feed?: CallFeed, - sourceId?: string, - ) => void; - [GroupCallEvent.LocalMuteStateChanged]: (audioMuted: boolean, videoMuted: boolean) => void; - [GroupCallEvent.ParticipantsChanged]: (participants: Map<RoomMember, Map<string, ParticipantState>>) => void; - /** - * Fires whenever an error occurs when call.js encounters an issue with setting up the call. - * <p> - * The error given will have a code equal to either `MatrixCall.ERR_LOCAL_OFFER_FAILED` or - * `MatrixCall.ERR_NO_USER_MEDIA`. `ERR_LOCAL_OFFER_FAILED` is emitted when the local client - * fails to create an offer. `ERR_NO_USER_MEDIA` is emitted when the user has denied access - * to their audio/video hardware. - * @param err - The error raised by MatrixCall. - * @example - * ``` - * matrixCall.on("error", function(err){ - * console.error(err.code, err); - * }); - * ``` - */ - [GroupCallEvent.Error]: (error: GroupCallError) => void; -}; - -export enum GroupCallStatsReportEvent { - ConnectionStats = "GroupCall.connection_stats", - ByteSentStats = "GroupCall.byte_sent_stats", -} - -export type GroupCallStatsReportEventHandlerMap = { - [GroupCallStatsReportEvent.ConnectionStats]: (report: GroupCallStatsReport<ConnectionStatsReport>) => void; - [GroupCallStatsReportEvent.ByteSentStats]: (report: GroupCallStatsReport<ByteSentStatsReport>) => void; -}; - -export enum GroupCallErrorCode { - NoUserMedia = "no_user_media", - UnknownDevice = "unknown_device", - PlaceCallFailed = "place_call_failed", -} - -export interface GroupCallStatsReport<T extends ConnectionStatsReport | ByteSentStatsReport> { - report: T; -} - -export class GroupCallError extends Error { - public code: string; - - public constructor(code: GroupCallErrorCode, msg: string, err?: Error) { - // Still don't think there's any way to have proper nested errors - if (err) { - super(msg + ": " + err); - } else { - super(msg); - } - - this.code = code; - } -} - -export class GroupCallUnknownDeviceError extends GroupCallError { - public constructor(public userId: string) { - super(GroupCallErrorCode.UnknownDevice, "No device found for " + userId); - } -} - -export class OtherUserSpeakingError extends Error { - public constructor() { - super("Cannot unmute: another user is speaking"); - } -} - -export interface IGroupCallDataChannelOptions { - ordered: boolean; - maxPacketLifeTime: number; - maxRetransmits: number; - protocol: string; -} - -export interface IGroupCallRoomState { - "m.intent": GroupCallIntent; - "m.type": GroupCallType; - "io.element.ptt"?: boolean; - // TODO: Specify data-channels - "dataChannelsEnabled"?: boolean; - "dataChannelOptions"?: IGroupCallDataChannelOptions; -} - -export interface IGroupCallRoomMemberFeed { - purpose: SDPStreamMetadataPurpose; -} - -export interface IGroupCallRoomMemberDevice { - device_id: string; - session_id: string; - expires_ts: number; - feeds: IGroupCallRoomMemberFeed[]; -} - -export interface IGroupCallRoomMemberCallState { - "m.call_id": string; - "m.foci"?: string[]; - "m.devices": IGroupCallRoomMemberDevice[]; -} - -export interface IGroupCallRoomMemberState { - "m.calls": IGroupCallRoomMemberCallState[]; -} - -export enum GroupCallState { - LocalCallFeedUninitialized = "local_call_feed_uninitialized", - InitializingLocalCallFeed = "initializing_local_call_feed", - LocalCallFeedInitialized = "local_call_feed_initialized", - Entered = "entered", - Ended = "ended", -} - -export interface ParticipantState { - sessionId: string; - screensharing: boolean; -} - -interface ICallHandlers { - onCallFeedsChanged: (feeds: CallFeed[]) => void; - onCallStateChanged: (state: CallState, oldState: CallState | undefined) => void; - onCallHangup: (call: MatrixCall) => void; - onCallReplaced: (newCall: MatrixCall) => void; -} - -const DEVICE_TIMEOUT = 1000 * 60 * 60; // 1 hour - -function getCallUserId(call: MatrixCall): string | null { - return call.getOpponentMember()?.userId || call.invitee || null; -} - -export class GroupCall extends TypedEventEmitter< - GroupCallEvent | CallEvent | GroupCallStatsReportEvent, - GroupCallEventHandlerMap & CallEventHandlerMap & GroupCallStatsReportEventHandlerMap -> { - // Config - public activeSpeakerInterval = 1000; - public retryCallInterval = 5000; - public participantTimeout = 1000 * 15; - public pttMaxTransmitTime = 1000 * 20; - - public activeSpeaker?: CallFeed; - public localCallFeed?: CallFeed; - public localScreenshareFeed?: CallFeed; - public localDesktopCapturerSourceId?: string; - public readonly userMediaFeeds: CallFeed[] = []; - public readonly screenshareFeeds: CallFeed[] = []; - public groupCallId: string; - public readonly allowCallWithoutVideoAndAudio: boolean; - - private readonly calls = new Map<string, Map<string, MatrixCall>>(); // user_id -> device_id -> MatrixCall - private callHandlers = new Map<string, Map<string, ICallHandlers>>(); // user_id -> device_id -> ICallHandlers - private activeSpeakerLoopInterval?: ReturnType<typeof setTimeout>; - private retryCallLoopInterval?: ReturnType<typeof setTimeout>; - private retryCallCounts: Map<string, Map<string, number>> = new Map(); // user_id -> device_id -> count - private reEmitter: ReEmitter; - private transmitTimer: ReturnType<typeof setTimeout> | null = null; - private participantsExpirationTimer: ReturnType<typeof setTimeout> | null = null; - private resendMemberStateTimer: ReturnType<typeof setInterval> | null = null; - private initWithAudioMuted = false; - private initWithVideoMuted = false; - private initCallFeedPromise?: Promise<void>; - - private readonly stats: GroupCallStats; - - public constructor( - private client: MatrixClient, - public room: Room, - public type: GroupCallType, - public isPtt: boolean, - public intent: GroupCallIntent, - groupCallId?: string, - private dataChannelsEnabled?: boolean, - private dataChannelOptions?: IGroupCallDataChannelOptions, - isCallWithoutVideoAndAudio?: boolean, - ) { - super(); - this.reEmitter = new ReEmitter(this); - this.groupCallId = groupCallId ?? genCallID(); - this.creationTs = - room.currentState.getStateEvents(EventType.GroupCallPrefix, this.groupCallId)?.getTs() ?? null; - this.updateParticipants(); - - room.on(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; - - const userID = this.client.getUserId() || "unknown"; - this.stats = new GroupCallStats(this.groupCallId, userID); - this.stats.reports.on(StatsReport.CONNECTION_STATS, this.onConnectionStats); - this.stats.reports.on(StatsReport.BYTE_SENT_STATS, this.onByteSentStats); - } - - private onConnectionStats = (report: ConnectionStatsReport): void => { - // @TODO: Implement data argumentation - this.emit(GroupCallStatsReportEvent.ConnectionStats, { report }); - }; - - private onByteSentStats = (report: ByteSentStatsReport): void => { - // @TODO: Implement data argumentation - this.emit(GroupCallStatsReportEvent.ByteSentStats, { report }); - }; - - public async create(): Promise<GroupCall> { - this.creationTs = Date.now(); - this.client.groupCallEventHandler!.groupCalls.set(this.room.roomId, this); - this.client.emit(GroupCallEventHandlerEvent.Outgoing, this); - - const groupCallState: IGroupCallRoomState = { - "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, EventType.GroupCallPrefix, groupCallState, this.groupCallId); - - return this; - } - - private _state = GroupCallState.LocalCallFeedUninitialized; - - /** - * The group call's state. - */ - public get state(): GroupCallState { - return this._state; - } - - private set state(value: GroupCallState) { - const prevValue = this._state; - if (value !== prevValue) { - this._state = value; - this.emit(GroupCallEvent.GroupCallStateChanged, value, prevValue); - } - } - - private _participants = new Map<RoomMember, Map<string, ParticipantState>>(); - - /** - * The current participants in the call, as a map from members to device IDs - * to participant info. - */ - public get participants(): Map<RoomMember, Map<string, ParticipantState>> { - return this._participants; - } - - private set participants(value: Map<RoomMember, Map<string, ParticipantState>>) { - const prevValue = this._participants; - const participantStateEqual = (x: ParticipantState, y: ParticipantState): boolean => - x.sessionId === y.sessionId && x.screensharing === y.screensharing; - const deviceMapsEqual = (x: Map<string, ParticipantState>, y: Map<string, ParticipantState>): boolean => - mapsEqual(x, y, participantStateEqual); - - // Only update if the map actually changed - if (!mapsEqual(value, prevValue, deviceMapsEqual)) { - this._participants = value; - this.emit(GroupCallEvent.ParticipantsChanged, value); - } - } - - private _creationTs: number | null = null; - - /** - * The timestamp at which the call was created, or null if it has not yet - * been created. - */ - public get creationTs(): number | null { - return this._creationTs; - } - - private set creationTs(value: number | null) { - this._creationTs = value; - } - - private _enteredViaAnotherSession = false; - - /** - * Whether the local device has entered this call via another session, such - * as a widget. - */ - public get enteredViaAnotherSession(): boolean { - return this._enteredViaAnotherSession; - } - - public set enteredViaAnotherSession(value: boolean) { - this._enteredViaAnotherSession = value; - this.updateParticipants(); - } - - /** - * Executes the given callback on all calls in this group call. - * @param f - The callback. - */ - public forEachCall(f: (call: MatrixCall) => void): void { - for (const deviceMap of this.calls.values()) { - for (const call of deviceMap.values()) f(call); - } - } - - public getLocalFeeds(): CallFeed[] { - const feeds: CallFeed[] = []; - - if (this.localCallFeed) feeds.push(this.localCallFeed); - if (this.localScreenshareFeed) feeds.push(this.localScreenshareFeed); - - return feeds; - } - - public hasLocalParticipant(): boolean { - 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. - */ - private callExpected(call: MatrixCall): boolean { - 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; - } - - public async initLocalCallFeed(): Promise<void> { - 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; - } - } - - private async initLocalCallFeedInternal(): Promise<void> { - logger.log(`GroupCall ${this.groupCallId} initLocalCallFeedInternal() running`); - - let stream: MediaStream; - - 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({ - client: this.client, - roomId: this.room.roomId, - userId: this.client.getUserId()!, - deviceId: this.client.getDeviceId()!, - stream, - purpose: SDPStreamMetadataPurpose.Usermedia, - audioMuted: this.initWithAudioMuted || stream.getAudioTracks().length === 0 || this.isPtt, - videoMuted: this.initWithVideoMuted || stream.getVideoTracks().length === 0, - }); - - setTracksEnabled(stream.getAudioTracks(), !callFeed.isAudioMuted()); - setTracksEnabled(stream.getVideoTracks(), !callFeed.isVideoMuted()); - - this.localCallFeed = callFeed; - this.addUserMediaFeed(callFeed); - - this.state = GroupCallState.LocalCallFeedInitialized; - } - - public async updateLocalUsermediaStream(stream: MediaStream): Promise<void> { - if (this.localCallFeed) { - const oldStream = this.localCallFeed.stream; - this.localCallFeed.setNewStream(stream); - const micShouldBeMuted = this.localCallFeed.isAudioMuted(); - const vidShouldBeMuted = this.localCallFeed.isVideoMuted(); - logger.log( - `GroupCall ${this.groupCallId} updateLocalUsermediaStream() (oldStreamId=${oldStream.id}, newStreamId=${stream.id}, micShouldBeMuted=${micShouldBeMuted}, vidShouldBeMuted=${vidShouldBeMuted})`, - ); - setTracksEnabled(stream.getAudioTracks(), !micShouldBeMuted); - setTracksEnabled(stream.getVideoTracks(), !vidShouldBeMuted); - this.client.getMediaHandler().stopUserMediaStream(oldStream); - } - } - - public async enter(): Promise<void> { - 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.log(`GroupCall ${this.groupCallId} enter() running`); - this.state = GroupCallState.Entered; - - this.client.on(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); - } - - private dispose(): void { - 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(CallErrorCode.UserHangup, false)); - - this.activeSpeaker = undefined; - clearInterval(this.activeSpeakerLoopInterval); - - this.retryCallCounts.clear(); - clearInterval(this.retryCallLoopInterval); - - this.client.removeListener(CallEventHandlerEvent.Incoming, this.onIncomingCall); - this.stats.stop(); - } - - public leave(): void { - this.dispose(); - this.state = GroupCallState.LocalCallFeedUninitialized; - } - - public async terminate(emitStateEvent = true): Promise<void> { - this.dispose(); - - this.room.off(RoomStateEvent.Update, this.onRoomState); - this.client.groupCallEventHandler!.groupCalls.delete(this.room.roomId); - this.client.emit(GroupCallEventHandlerEvent.Ended, this); - this.state = GroupCallState.Ended; - - if (emitStateEvent) { - const existingStateEvent = this.room.currentState.getStateEvents( - EventType.GroupCallPrefix, - this.groupCallId, - )!; - - await this.client.sendStateEvent( - this.room.roomId, - EventType.GroupCallPrefix, - { - ...existingStateEvent.getContent(), - "m.terminated": GroupCallTerminationReason.CallEnded, - }, - this.groupCallId, - ); - } - } - - /* - * Local Usermedia - */ - - public isLocalVideoMuted(): boolean { - if (this.localCallFeed) { - return this.localCallFeed.isVideoMuted(); - } - - return true; - } - - public isMicrophoneMuted(): boolean { - 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 - */ - public async setMicrophoneMuted(muted: boolean): Promise<boolean> { - // 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 (): Promise<void> => { - const updates: Promise<void>[] = []; - this.forEachCall((call) => updates.push(call.sendMetadataUpdate())); - - await Promise.all(updates).catch((e) => - logger.info( - `GroupCall ${this.groupCallId} setMicrophoneMuted() failed to send some metadata updates`, - e, - ), - ); - }; - - if (sendUpdatesBefore) await sendUpdates(); - - if (this.localCallFeed) { - logger.log( - `GroupCall ${this.groupCallId} setMicrophoneMuted() (streamId=${this.localCallFeed.stream.id}, muted=${muted})`, - ); - - // We needed this here to avoid an error in case user join a call without a device. - // I can not use .then .catch functions because linter :-( - try { - if (!muted) { - const stream = await this.client - .getMediaHandler() - .getUserMediaStream(true, !this.localCallFeed.isVideoMuted()); - if (stream === null) { - // if case permission denied to get a stream stop this here - /* istanbul ignore next */ - logger.log( - `GroupCall ${this.groupCallId} setMicrophoneMuted() no device to receive local stream, muted=${muted}`, - ); - return false; - } - } - } catch (e) { - /* istanbul ignore next */ - logger.log( - `GroupCall ${this.groupCallId} setMicrophoneMuted() no device or permission to receive local stream, muted=${muted}`, - ); - 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. - setTracksEnabled(this.localCallFeed.stream.getAudioTracks(), !muted); - } else { - logger.log(`GroupCall ${this.groupCallId} setMicrophoneMuted() no stream muted (muted=${muted})`); - this.initWithAudioMuted = muted; - } - - this.forEachCall((call) => - setTracksEnabled(call.localUsermediaFeed!.stream.getAudioTracks(), !muted && this.callExpected(call)), - ); - this.emit(GroupCallEvent.LocalMuteStateChanged, muted, this.isLocalVideoMuted()); - - if (!sendUpdatesBefore) await sendUpdates(); - - 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 - */ - public async setLocalVideoMuted(muted: boolean): Promise<boolean> { - // 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.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); - setTracksEnabled(this.localCallFeed.stream.getVideoTracks(), !muted); - } catch (_) { - // No permission to video device - /* istanbul ignore next */ - logger.log( - `GroupCall ${this.groupCallId} setLocalVideoMuted() no device or permission to receive local stream, muted=${muted}`, - ); - return false; - } - } else { - logger.log(`GroupCall ${this.groupCallId} setLocalVideoMuted() no stream muted (muted=${muted})`); - this.initWithVideoMuted = muted; - } - - const updates: Promise<unknown>[] = []; - 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) => - setTracksEnabled(call.localUsermediaFeed!.stream.getVideoTracks(), !muted && this.callExpected(call)), - ); - - this.emit(GroupCallEvent.LocalMuteStateChanged, this.isMicrophoneMuted(), muted); - - return true; - } - - public async setScreensharingEnabled(enabled: boolean, opts: IScreensharingOpts = {}): Promise<boolean> { - if (enabled === this.isScreensharing()) { - return enabled; - } - - if (enabled) { - try { - 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 = (): void => { - this.setScreensharingEnabled(false); - track.removeEventListener("ended", onTrackEnded); - }; - - track.addEventListener("ended", onTrackEnded); - } - - logger.log( - `GroupCall ${this.groupCallId} setScreensharingEnabled() granted screensharing permissions. Setting screensharing enabled on all calls`, - ); - - this.localDesktopCapturerSourceId = opts.desktopCapturerSourceId; - this.localScreenshareFeed = new CallFeed({ - client: this.client, - roomId: this.room.roomId, - userId: this.client.getUserId()!, - deviceId: this.client.getDeviceId()!, - stream, - purpose: 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.error( - `GroupCall ${this.groupCallId} setScreensharingEnabled() enabling screensharing error`, - error, - ); - this.emit( - GroupCallEvent.Error, - new GroupCallError( - GroupCallErrorCode.NoUserMedia, - "Failed to get screen-sharing stream: ", - error as 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; - } - } - - public isScreensharing(): boolean { - return !!this.localScreenshareFeed; - } - - /* - * 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. - */ - - private onIncomingCall = (newCall: MatrixCall): void => { - // The incoming calls may be for another room, which we will ignore. - if (newCall.roomId !== this.room.roomId) { - return; - } - - if (newCall.state !== CallState.Ringing) { - logger.warn( - `GroupCall ${this.groupCallId} onIncomingCall() incoming call no longer in ringing state - ignoring`, - ); - return; - } - - if (!newCall.groupCallId || newCall.groupCallId !== this.groupCallId) { - 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.warn(`GroupCall ${this.groupCallId} onIncomingCall() incoming call with no member - ignoring`); - return; - } - - const deviceMap = this.calls.get(opponentUserId) ?? new Map<string, MatrixCall>(); - const prevCall = deviceMap.get(newCall.getOpponentDeviceId()!); - - if (prevCall?.callId === newCall.callId) return; - - logger.log( - `GroupCall ${this.groupCallId} onIncomingCall() incoming call (userId=${opponentUserId}, callId=${newCall.callId})`, - ); - - if (prevCall) prevCall.hangup(CallErrorCode.Replaced, false); - - 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) { - setTracksEnabled(feed.stream.getAudioTracks(), false); - setTracksEnabled(feed.stream.getVideoTracks(), false); - } - } - newCall.answerWithCallFeeds(feeds); - - deviceMap.set(newCall.getOpponentDeviceId()!, newCall); - this.calls.set(opponentUserId, deviceMap); - this.emit(GroupCallEvent.CallsChanged, this.calls); - }; - - /** - * 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. - */ - private wantsOutgoingCall(userId: string, deviceId: string): boolean { - 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. - */ - private placeOutgoingCalls(): void { - let callsChanged = false; - - for (const [{ userId }, participantMap] of this.participants) { - const callMap = this.calls.get(userId) ?? new Map<string, MatrixCall>(); - - 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.debug( - `GroupCall ${this.groupCallId} placeOutgoingCalls() replacing call (userId=${userId}, deviceId=${deviceId}, callId=${prevCall.callId})`, - ); - prevCall.hangup(CallErrorCode.NewSession, false); - } - - const newCall = createNewMatrixCall(this.client, this.room.roomId, { - invitee: userId, - opponentDeviceId: deviceId, - opponentSessionId: participant.sessionId, - groupCallId: this.groupCallId, - }); - - if (newCall === null) { - 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.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.warn( - `GroupCall ${this.groupCallId} placeOutgoingCalls() failed to place call (userId=${userId})`, - e, - ); - - if (e instanceof 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(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 - */ - - private getMemberStateEvents(): MatrixEvent[]; - private getMemberStateEvents(userId: string): MatrixEvent | null; - private getMemberStateEvents(userId?: string): MatrixEvent[] | MatrixEvent | null { - return userId === undefined - ? this.room.currentState.getStateEvents(EventType.GroupCallMemberPrefix) - : this.room.currentState.getStateEvents(EventType.GroupCallMemberPrefix, userId); - } - - private onRetryCallLoop = (): void => { - 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(); - }; - - private initCall(call: MatrixCall): void { - const opponentMemberId = getCallUserId(call); - - if (!opponentMemberId) { - throw new Error("Cannot init call without user id"); - } - - const onCallFeedsChanged = (): void => this.onCallFeedsChanged(call); - const onCallStateChanged = (state: CallState, oldState?: CallState): void => - this.onCallStateChanged(call, state, oldState); - const onCallHangup = this.onCallHangup; - const onCallReplaced = (newCall: MatrixCall): void => 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(CallEvent.FeedsChanged, onCallFeedsChanged); - call.on(CallEvent.State, onCallStateChanged); - call.on(CallEvent.Hangup, onCallHangup); - call.on(CallEvent.Replaced, onCallReplaced); - - call.isPtt = this.isPtt; - - this.reEmitter.reEmit(call, Object.values(CallEvent)); - - call.initStats(this.stats); - - onCallFeedsChanged(); - } - - private disposeCall(call: MatrixCall, hangupReason: CallErrorCode): void { - 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(CallEvent.FeedsChanged, onCallFeedsChanged); - call.removeListener(CallEvent.State, onCallStateChanged); - call.removeListener(CallEvent.Hangup, onCallHangup); - call.removeListener(CallEvent.Replaced, onCallReplaced); - - deviceMap.delete(opponentMemberId); - if (deviceMap.size === 0) this.callHandlers.delete(opponentMemberId); - - if (call.hangupReason === 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); - } - } - - private onCallFeedsChanged = (call: MatrixCall): void => { - 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; - - 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); - } - } - }; - - private onCallStateChanged = (call: MatrixCall, state: CallState, _oldState: CallState | undefined): void => { - if (state === 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 === CallState.Connected && opponentUserId) { - const retriesMap = this.retryCallCounts.get(opponentUserId); - retriesMap?.delete(call.getOpponentDeviceId()!); - if (retriesMap?.size === 0) this.retryCallCounts.delete(opponentUserId); - } - }; - - private onCallHangup = (call: MatrixCall): void => { - if (call.hangupReason === 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 as CallErrorCode); - deviceMap.delete(call.getOpponentDeviceId()!); - if (deviceMap.size === 0) this.calls.delete(opponentUserId); - this.emit(GroupCallEvent.CallsChanged, this.calls); - } - }; - - private onCallReplaced = (prevCall: MatrixCall, newCall: MatrixCall): void => { - const opponentUserId = prevCall.getOpponentMember()!.userId; - - let deviceMap = this.calls.get(opponentUserId); - if (deviceMap === undefined) { - deviceMap = new Map(); - this.calls.set(opponentUserId, deviceMap); - } - - prevCall.hangup(CallErrorCode.Replaced, false); - this.initCall(newCall); - deviceMap.set(prevCall.getOpponentDeviceId()!, newCall); - this.emit(GroupCallEvent.CallsChanged, this.calls); - }; - - /* - * UserMedia CallFeed Event Handlers - */ - - public getUserMediaFeed(userId: string, deviceId: string): CallFeed | undefined { - return this.userMediaFeeds.find((f) => f.userId === userId && f.deviceId! === deviceId); - } - - private addUserMediaFeed(callFeed: CallFeed): void { - this.userMediaFeeds.push(callFeed); - callFeed.measureVolumeActivity(true); - this.emit(GroupCallEvent.UserMediaFeedsChanged, this.userMediaFeeds); - } - - private replaceUserMediaFeed(existingFeed: CallFeed, replacementFeed: CallFeed): void { - 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); - } - - private removeUserMediaFeed(callFeed: CallFeed): void { - 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); - } - } - - private onActiveSpeakerLoop = (): void => { - let topAvg: number | undefined = undefined; - let nextActiveSpeaker: CallFeed | undefined = 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, SPEAKING_THRESHOLD), - ); - const avg = total / callFeed.speakingVolumeSamples.length; - - if (!topAvg || avg > topAvg) { - topAvg = avg; - nextActiveSpeaker = callFeed; - } - } - - if (nextActiveSpeaker && this.activeSpeaker !== nextActiveSpeaker && topAvg && topAvg > SPEAKING_THRESHOLD) { - this.activeSpeaker = nextActiveSpeaker; - this.emit(GroupCallEvent.ActiveSpeakerChanged, this.activeSpeaker); - } - }; - - /* - * Screenshare Call Feed Event Handlers - */ - - public getScreenshareFeed(userId: string, deviceId: string): CallFeed | undefined { - return this.screenshareFeeds.find((f) => f.userId === userId && f.deviceId! === deviceId); - } - - private addScreenshareFeed(callFeed: CallFeed): void { - this.screenshareFeeds.push(callFeed); - this.emit(GroupCallEvent.ScreenshareFeedsChanged, this.screenshareFeeds); - } - - private replaceScreenshareFeed(existingFeed: CallFeed, replacementFeed: CallFeed): void { - 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); - } - - private removeScreenshareFeed(callFeed: CallFeed): void { - 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. - */ - private updateParticipants(): void { - 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.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<RoomMember, Map<string, ParticipantState>>(); - 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<Record<any, unknown>>(); - const calls: Record<any, unknown>[] = Array.isArray(content["m.calls"]) ? content["m.calls"] : []; - const call = calls.find((call) => call["m.call_id"] === this.groupCallId); - const devices: Record<any, unknown>[] = 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), - ) as unknown as IGroupCallRoomMemberDevice[]; - - // 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<string, ParticipantState>(); - 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 === 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 === 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. - */ - private async updateDevices( - fn: (devices: IGroupCallRoomMemberDevice[]) => IGroupCallRoomMemberDevice[] | null, - keepAlive = false, - ): Promise<void> { - const now = Date.now(); - const localUserId = this.client.getUserId()!; - - const event = this.getMemberStateEvents(localUserId); - const content = event?.getContent<Record<any, unknown>>() ?? {}; - const calls: Record<any, unknown>[] = Array.isArray(content["m.calls"]) ? content["m.calls"] : []; - - let call: Record<any, unknown> | null = null; - const otherCalls: Record<any, unknown>[] = []; - for (const c of calls) { - if (c["m.call_id"] === this.groupCallId) { - call = c; - } else { - otherCalls.push(c); - } - } - if (call === null) call = {}; - - const devices: Record<any, unknown>[] = 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), - ) as unknown as IGroupCallRoomMemberDevice[]; - - const newDevices = fn(validDevices); - if (newDevices === null) return; - - const newCalls = [...(otherCalls as unknown as IGroupCallRoomMemberCallState[])]; - if (newDevices.length > 0) { - newCalls.push({ - ...call, - "m.call_id": this.groupCallId, - "m.devices": newDevices, - }); - } - - const newContent: IGroupCallRoomMemberState = { "m.calls": newCalls }; - - await this.client.sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, newContent, localUserId, { - keepAlive, - }); - } - - private async addDeviceToMemberState(): Promise<void> { - 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 - }, - ]); - } - - private async updateMemberState(): Promise<void> { - // 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.log(`GroupCall ${this.groupCallId} updateMemberState() resending call member state"`); - try { - await this.addDeviceToMemberState(); - } catch (e) { - 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). - */ - public async cleanMemberState(): Promise<void> { - const { devices: myDevices } = await this.client.getDevices(); - const deviceMap = new Map<string, IMyDevice>(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; - }); - } - - private onRoomState = (): void => this.updateParticipants(); - - private onParticipantsChanged = (): void => { - // 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()) { - setTracksEnabled(feed.stream.getAudioTracks(), !feed.isAudioMuted() && expected); - setTracksEnabled(feed.stream.getVideoTracks(), !feed.isVideoMuted() && expected); - } - }); - - if (this.state === GroupCallState.Entered) this.placeOutgoingCalls(); - }; - - private onStateChanged = (newState: GroupCallState, oldState: GroupCallState): void => { - 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.error( - `GroupCall ${this.groupCallId} onStateChanged() failed to update member state devices"`, - e, - ), - ); - } - }; - - private onLocalFeedsChanged = (): void => { - if (this.state === GroupCallState.Entered) { - this.updateMemberState().catch((e) => - logger.error( - `GroupCall ${this.groupCallId} onLocalFeedsChanged() failed to update member state feeds`, - e, - ), - ); - } - }; - - public getGroupCallStats(): GroupCallStats { - return this.stats; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCallEventHandler.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCallEventHandler.ts deleted file mode 100644 index 08487bd..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCallEventHandler.ts +++ /dev/null @@ -1,232 +0,0 @@ -/* -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. -*/ - -import { MatrixEvent } from "../models/event"; -import { MatrixClient, ClientEvent } from "../client"; -import { GroupCall, GroupCallIntent, GroupCallType, IGroupCallDataChannelOptions } from "./groupCall"; -import { Room } from "../models/room"; -import { RoomState, RoomStateEvent } from "../models/room-state"; -import { RoomMember } from "../models/room-member"; -import { logger } from "../logger"; -import { EventType } from "../@types/event"; -import { SyncState } from "../sync"; - -export enum GroupCallEventHandlerEvent { - Incoming = "GroupCall.incoming", - Outgoing = "GroupCall.outgoing", - Ended = "GroupCall.ended", - Participants = "GroupCall.participants", -} - -export type GroupCallEventHandlerEventHandlerMap = { - [GroupCallEventHandlerEvent.Incoming]: (call: GroupCall) => void; - [GroupCallEventHandlerEvent.Outgoing]: (call: GroupCall) => void; - [GroupCallEventHandlerEvent.Ended]: (call: GroupCall) => void; - [GroupCallEventHandlerEvent.Participants]: (participants: RoomMember[], call: GroupCall) => void; -}; - -interface RoomDeferred { - prom: Promise<void>; - resolve?: () => void; -} - -export class GroupCallEventHandler { - public groupCalls = new Map<string, GroupCall>(); // 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 - private roomDeferreds = new Map<string, RoomDeferred>(); - - public constructor(private client: MatrixClient) {} - - public async start(): Promise<void> { - // 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() !== SyncState.Syncing) { - logger.debug("GroupCallEventHandler start() waiting for client to start syncing"); - await new Promise<void>((resolve) => { - const onSync = (): void => { - if (this.client.getSyncState() === SyncState.Syncing) { - this.client.off(ClientEvent.Sync, onSync); - return resolve(); - } - }; - this.client.on(ClientEvent.Sync, onSync); - }); - } - - const rooms = this.client.getRooms(); - - for (const room of rooms) { - this.createGroupCallForRoom(room); - } - - this.client.on(ClientEvent.Room, this.onRoomsChanged); - this.client.on(RoomStateEvent.Events, this.onRoomStateChanged); - } - - public stop(): void { - this.client.removeListener(RoomStateEvent.Events, this.onRoomStateChanged); - } - - private getRoomDeferred(roomId: string): RoomDeferred { - let deferred = this.roomDeferreds.get(roomId); - if (deferred === undefined) { - let resolveFunc: () => void; - deferred = { - prom: new Promise<void>((resolve) => { - resolveFunc = resolve; - }), - }; - deferred.resolve = resolveFunc!; - this.roomDeferreds.set(roomId, deferred); - } - - return deferred; - } - - public waitUntilRoomReadyForGroupCalls(roomId: string): Promise<void> { - return this.getRoomDeferred(roomId).prom; - } - - public getGroupCallById(groupCallId: string): GroupCall | undefined { - return [...this.groupCalls.values()].find((groupCall) => groupCall.groupCallId === groupCallId); - } - - private createGroupCallForRoom(room: Room): void { - const callEvents = room.currentState.getStateEvents(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.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.info(`GroupCallEventHandler createGroupCallForRoom() processed room (roomId=${room.roomId})`); - this.getRoomDeferred(room.roomId).resolve!(); - } - - private createGroupCallFromRoomStateEvent(event: MatrixEvent): GroupCall | undefined { - const roomId = event.getRoomId(); - const content = event.getContent(); - - const room = this.client.getRoom(roomId); - - if (!room) { - 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(GroupCallType).includes(callType)) { - logger.warn( - `GroupCallEventHandler createGroupCallFromRoomStateEvent() received invalid call type (type=${callType}, roomId=${roomId})`, - ); - return; - } - - const callIntent = content["m.intent"]; - - if (!Object.values(GroupCallIntent).includes(callIntent)) { - logger.warn(`Received invalid group call intent (type=${callType}, roomId=${roomId})`); - return; - } - - const isPtt = Boolean(content["io.element.ptt"]); - - let dataChannelOptions: IGroupCallDataChannelOptions | undefined; - - 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( - 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; - } - - private onRoomsChanged = (room: Room): void => { - this.createGroupCallForRoom(room); - }; - - private onRoomStateChanged = (event: MatrixEvent, state: RoomState): void => { - const eventType = event.getType(); - - if (eventType === 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.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.warn( - `GroupCallEventHandler onRoomStateChanged() currently does not support multiple calls (roomId=${state.roomId})`, - ); - } - } - }; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/mediaHandler.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/mediaHandler.ts deleted file mode 100644 index 7f65835..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/mediaHandler.ts +++ /dev/null @@ -1,469 +0,0 @@ -/* -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. -*/ - -import { TypedEventEmitter } from "../models/typed-event-emitter"; -import { GroupCallType, GroupCallState } from "../webrtc/groupCall"; -import { logger } from "../logger"; -import { MatrixClient } from "../client"; - -export enum MediaHandlerEvent { - LocalStreamsChanged = "local_streams_changed", -} - -export type MediaHandlerEventHandlerMap = { - [MediaHandlerEvent.LocalStreamsChanged]: () => void; -}; - -export interface IScreensharingOpts { - desktopCapturerSourceId?: string; - audio?: boolean; - // For electron screen capture, there are very few options for detecting electron - // apart from inspecting the user agent or just trying getDisplayMedia() and - // catching the failure, so we do the latter - this flag tells the function to just - // throw an error so we can catch it in this case, rather than logging and emitting. - throwOnFail?: boolean; -} - -export interface AudioSettings { - autoGainControl: boolean; - echoCancellation: boolean; - noiseSuppression: boolean; -} - -export class MediaHandler extends TypedEventEmitter< - MediaHandlerEvent.LocalStreamsChanged, - MediaHandlerEventHandlerMap -> { - private audioInput?: string; - private audioSettings?: AudioSettings; - private videoInput?: string; - private localUserMediaStream?: MediaStream; - public userMediaStreams: MediaStream[] = []; - public screensharingStreams: MediaStream[] = []; - - // Promise chain to serialise calls to getMediaStream - private getMediaStreamPromise?: Promise<MediaStream>; - - public constructor(private client: MatrixClient) { - super(); - } - - public restoreMediaSettings(audioInput: string, videoInput: string): void { - 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 - */ - public async setAudioInput(deviceId: string): Promise<void> { - 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 - */ - public async setAudioSettings(opts: AudioSettings): Promise<void> { - logger.info(`MediaHandler setAudioSettings() running (opts=${JSON.stringify(opts)})`); - - this.audioSettings = Object.assign({}, opts) as AudioSettings; - await this.updateLocalUsermediaStreams(); - } - - /** - * Set a video input device to use for MatrixCalls - * @param deviceId - the identifier for the device - * undefined treated as unset - */ - public async setVideoInput(deviceId: string): Promise<void> { - 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 - */ - public async setMediaInputs(audioInput: string, videoInput: string): Promise<void> { - 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 - */ - public async updateLocalUsermediaStreams(): Promise<void> { - if (this.userMediaStreams.length === 0) return; - - const callMediaStreamParams: Map<string, { audio: boolean; video: boolean }> = 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.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.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.log( - `MediaHandler updateLocalUsermediaStreams() calling getUserMediaStream() (groupCallId=${groupCall.groupCallId})`, - ); - const stream = await this.getUserMediaStream(true, groupCall.type === GroupCallType.Video); - - if (groupCall.state === GroupCallState.Ended) { - continue; - } - - await groupCall.updateLocalUsermediaStream(stream); - } - - this.emit(MediaHandlerEvent.LocalStreamsChanged); - } - - public async hasAudioDevice(): Promise<boolean> { - try { - const devices = await navigator.mediaDevices.enumerateDevices(); - return devices.filter((device) => device.kind === "audioinput").length > 0; - } catch (err) { - logger.log(`MediaHandler hasAudioDevice() calling navigator.mediaDevices.enumerateDevices with error`, err); - return false; - } - } - - public async hasVideoDevice(): Promise<boolean> { - try { - const devices = await navigator.mediaDevices.enumerateDevices(); - return devices.filter((device) => device.kind === "videoinput").length > 0; - } catch (err) { - 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 - */ - public async getUserMediaStream(audio: boolean, video: boolean, reusable = true): Promise<MediaStream> { - // 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; - } - - private async getUserMediaStreamInternal(audio: boolean, video: boolean, reusable: boolean): Promise<MediaStream> { - const shouldRequestAudio = audio && (await this.hasAudioDevice()); - const shouldRequestVideo = video && (await this.hasVideoDevice()); - - let stream: MediaStream; - - 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.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.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 - */ - public stopUserMediaStream(mediaStream: MediaStream): void { - 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.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 - */ - public async getScreensharingStream(opts: IScreensharingOpts = {}, reusable = true): Promise<MediaStream> { - let stream: MediaStream; - - if (this.screensharingStreams.length === 0) { - const screenshareConstraints = this.getScreenshareContraints(opts); - - if (opts.desktopCapturerSourceId) { - // We are using Electron - logger.debug( - `MediaHandler getScreensharingStream() calling getUserMedia() (opts=${JSON.stringify(opts)})`, - ); - stream = await navigator.mediaDevices.getUserMedia(screenshareConstraints); - } else { - // We are not using Electron - 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.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 - */ - public stopScreensharingStream(mediaStream: MediaStream): void { - 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.debug(`MediaHandler stopScreensharingStream() splicing stream out (streamId=${mediaStream.id})`); - this.screensharingStreams.splice(index, 1); - } - - this.emit(MediaHandlerEvent.LocalStreamsChanged); - } - - /** - * Stops all local media tracks - */ - public stopAllStreams(): void { - for (const stream of this.userMediaStreams) { - 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); - } - - private getUserMediaContraints(audio: boolean, video: boolean): MediaStreamConstraints { - 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, - }; - } - - private getScreenshareContraints(opts: IScreensharingOpts): DesktopCapturerConstraints { - const { desktopCapturerSourceId, audio } = opts; - if (desktopCapturerSourceId) { - return { - audio: audio ?? false, - video: { - mandatory: { - chromeMediaSource: "desktop", - chromeMediaSourceId: desktopCapturerSourceId, - }, - }, - }; - } else { - return { - audio: audio ?? false, - video: true, - }; - } - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/connectionStats.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/connectionStats.ts deleted file mode 100644 index dbde6e5..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/connectionStats.ts +++ /dev/null @@ -1,47 +0,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. -*/ - -import { TransportStats } from "./transportStats"; -import { Bitrate } from "./media/mediaTrackStats"; - -export interface ConnectionStatsBandwidth { - /** - * bytes per second - */ - download: number; - /** - * bytes per second - */ - upload: number; -} - -export interface ConnectionStatsBitrate extends Bitrate { - audio?: Bitrate; - video?: Bitrate; -} - -export interface PacketLoos { - total: number; - download: number; - upload: number; -} - -export class ConnectionStats { - public bandwidth: ConnectionStatsBitrate = {} as ConnectionStatsBitrate; - public bitrate: ConnectionStatsBitrate = {} as ConnectionStatsBitrate; - public packetLoss: PacketLoos = {} as PacketLoos; - public transport: TransportStats[] = []; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/connectionStatsReporter.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/connectionStatsReporter.ts deleted file mode 100644 index c43b9b4..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/connectionStatsReporter.ts +++ /dev/null @@ -1,28 +0,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. -*/ -import { Bitrate } from "./media/mediaTrackStats"; - -export class ConnectionStatsReporter { - public static buildBandwidthReport(now: RTCIceCandidatePairStats): Bitrate { - const availableIncomingBitrate = now.availableIncomingBitrate; - const availableOutgoingBitrate = now.availableOutgoingBitrate; - - return { - download: availableIncomingBitrate ? Math.round(availableIncomingBitrate / 1000) : 0, - upload: availableOutgoingBitrate ? Math.round(availableOutgoingBitrate / 1000) : 0, - }; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/groupCallStats.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/groupCallStats.ts deleted file mode 100644 index 6d8c566..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/groupCallStats.ts +++ /dev/null @@ -1,64 +0,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. -*/ -import { StatsReportGatherer } from "./statsReportGatherer"; -import { StatsReportEmitter } from "./statsReportEmitter"; - -export class GroupCallStats { - private timer: undefined | ReturnType<typeof setTimeout>; - private readonly gatherers: Map<string, StatsReportGatherer> = new Map<string, StatsReportGatherer>(); - public readonly reports = new StatsReportEmitter(); - - public constructor(private groupCallId: string, private userId: string, private interval: number = 10000) {} - - public start(): void { - if (this.timer === undefined) { - this.timer = setInterval(() => { - this.processStats(); - }, this.interval); - } - } - - public stop(): void { - if (this.timer !== undefined) { - clearInterval(this.timer); - this.gatherers.forEach((c) => c.stopProcessingStats()); - } - } - - public hasStatsReportGatherer(callId: string): boolean { - return this.gatherers.has(callId); - } - - public addStatsReportGatherer(callId: string, userId: string, peerConnection: RTCPeerConnection): boolean { - if (this.hasStatsReportGatherer(callId)) { - return false; - } - this.gatherers.set(callId, new StatsReportGatherer(callId, userId, peerConnection, this.reports)); - return true; - } - - public removeStatsReportGatherer(callId: string): boolean { - return this.gatherers.delete(callId); - } - - public getStatsReportGatherer(callId: string): StatsReportGatherer | undefined { - return this.hasStatsReportGatherer(callId) ? this.gatherers.get(callId) : undefined; - } - - private processStats(): void { - this.gatherers.forEach((c) => c.processStats(this.groupCallId, this.userId)); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaSsrcHandler.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaSsrcHandler.ts deleted file mode 100644 index e606051..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaSsrcHandler.ts +++ /dev/null @@ -1,57 +0,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. -*/ - -import { parse as parseSdp } from "sdp-transform"; - -export type Mid = string; -export type Ssrc = string; -export type MapType = "local" | "remote"; - -export class MediaSsrcHandler { - private readonly ssrcToMid = { local: new Map<Mid, Ssrc[]>(), remote: new Map<Mid, Ssrc[]>() }; - - public findMidBySsrc(ssrc: Ssrc, type: "local" | "remote"): Mid | undefined { - let mid: Mid | undefined; - this.ssrcToMid[type].forEach((ssrcs, m) => { - if (ssrcs.find((s) => s == ssrc)) { - mid = m; - return; - } - }); - return mid; - } - - public parse(description: string, type: MapType): void { - const sdp = parseSdp(description); - const ssrcToMid = new Map<Mid, Ssrc[]>(); - sdp.media.forEach((m) => { - if ((!!m.mid && m.type === "video") || m.type === "audio") { - const ssrcs: Ssrc[] = []; - m.ssrcs?.forEach((ssrc) => { - if (ssrc.attribute === "cname") { - ssrcs.push(`${ssrc.id}`); - } - }); - ssrcToMid.set(`${m.mid}`, ssrcs); - } - }); - this.ssrcToMid[type] = ssrcToMid; - } - - public getSsrcToMidMap(type: MapType): Map<Mid, Ssrc[]> { - return this.ssrcToMid[type]; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackHandler.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackHandler.ts deleted file mode 100644 index 32580b1..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackHandler.ts +++ /dev/null @@ -1,71 +0,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. -*/ - -export type TrackId = string; - -export class MediaTrackHandler { - public constructor(private readonly pc: RTCPeerConnection) {} - - public getLocalTracks(kind: "audio" | "video"): MediaStreamTrack[] { - const isNotNullAndKind = (track: MediaStreamTrack | null): boolean => { - 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); - } - - public getTackById(trackId: string): MediaStreamTrack | undefined { - 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); - } - - public getLocalTrackIdByMid(mid: string): string | undefined { - 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; - } - - public getRemoteTrackIdByMid(mid: string): string | undefined { - 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; - } - - public getActiveSimulcastStreams(): number { - //@TODO implement this right.. Check how many layer configured - return 3; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackStats.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackStats.ts deleted file mode 100644 index 69ee9bd..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackStats.ts +++ /dev/null @@ -1,104 +0,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. -*/ - -import { TrackId } from "./mediaTrackHandler"; - -export interface PacketLoss { - packetsTotal: number; - packetsLost: number; - isDownloadStream: boolean; -} - -export interface Bitrate { - /** - * bytes per second - */ - download: number; - /** - * bytes per second - */ - upload: number; -} - -export interface Resolution { - width: number; - height: number; -} - -export type TrackStatsType = "local" | "remote"; - -export class MediaTrackStats { - private loss: PacketLoss = { packetsTotal: 0, packetsLost: 0, isDownloadStream: false }; - private bitrate: Bitrate = { download: 0, upload: 0 }; - private resolution: Resolution = { width: -1, height: -1 }; - private framerate = 0; - private codec = ""; - - public constructor( - public readonly trackId: TrackId, - public readonly type: TrackStatsType, - public readonly kind: "audio" | "video", - ) {} - - public getType(): TrackStatsType { - return this.type; - } - - public setLoss(loos: PacketLoss): void { - this.loss = loos; - } - - public getLoss(): PacketLoss { - return this.loss; - } - - public setResolution(resolution: Resolution): void { - this.resolution = resolution; - } - - public getResolution(): Resolution { - return this.resolution; - } - - public setFramerate(framerate: number): void { - this.framerate = framerate; - } - - public getFramerate(): number { - return this.framerate; - } - - public setBitrate(bitrate: Bitrate): void { - this.bitrate = bitrate; - } - - public getBitrate(): Bitrate { - return this.bitrate; - } - - public setCodec(codecShortType: string): boolean { - this.codec = codecShortType; - return true; - } - - public getCodec(): string { - return this.codec; - } - - public resetBitrate(): void { - this.bitrate = { download: 0, upload: 0 }; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackStatsHandler.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackStatsHandler.ts deleted file mode 100644 index 6fb119c..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackStatsHandler.ts +++ /dev/null @@ -1,86 +0,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. -*/ -import { TrackID } from "../statsReport"; -import { MediaTrackStats } from "./mediaTrackStats"; -import { MediaTrackHandler } from "./mediaTrackHandler"; -import { MediaSsrcHandler } from "./mediaSsrcHandler"; - -export class MediaTrackStatsHandler { - private readonly track2stats = new Map<TrackID, MediaTrackStats>(); - - public constructor( - public readonly mediaSsrcHandler: MediaSsrcHandler, - public readonly mediaTrackHandler: MediaTrackHandler, - ) {} - - /** - * 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 - */ - public findTrack2Stats(report: any, type: "remote" | "local"): MediaTrackStats | undefined { - 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: "audio" | "video" = track.kind === "audio" ? track.kind : "video"; - trackStats = new MediaTrackStats(trackID, type, kind); - this.track2stats.set(trackID, trackStats); - } else { - return undefined; - } - } - return trackStats; - } - - public findLocalVideoTrackStats(report: any): MediaTrackStats | undefined { - const localVideoTracks = this.mediaTrackHandler.getLocalTracks("video"); - if (localVideoTracks.length === 0) { - return undefined; - } - return this.findTrack2Stats(report, "local"); - } - - public getTrack2stats(): Map<TrackID, MediaTrackStats> { - return this.track2stats; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReport.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReport.ts deleted file mode 100644 index 56d6c4b..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReport.ts +++ /dev/null @@ -1,56 +0,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. -*/ - -import { ConnectionStatsBandwidth, ConnectionStatsBitrate, PacketLoos } from "./connectionStats"; -import { TransportStats } from "./transportStats"; -import { Resolution } from "./media/mediaTrackStats"; - -export enum StatsReport { - CONNECTION_STATS = "StatsReport.connection_stats", - BYTE_SENT_STATS = "StatsReport.byte_sent_stats", -} - -export type TrackID = string; -export type ByteSend = number; - -export interface ByteSentStatsReport extends Map<TrackID, ByteSend> { - // is a map: `local trackID` => byte send -} - -export interface ConnectionStatsReport { - bandwidth: ConnectionStatsBandwidth; - bitrate: ConnectionStatsBitrate; - packetLoss: PacketLoos; - resolution: ResolutionMap; - framerate: FramerateMap; - codec: CodecMap; - transport: TransportStats[]; -} - -export interface ResolutionMap { - local: Map<TrackID, Resolution>; - remote: Map<TrackID, Resolution>; -} - -export interface FramerateMap { - local: Map<TrackID, number>; - remote: Map<TrackID, number>; -} - -export interface CodecMap { - local: Map<TrackID, string>; - remote: Map<TrackID, string>; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportBuilder.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportBuilder.ts deleted file mode 100644 index c1af471..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportBuilder.ts +++ /dev/null @@ -1,110 +0,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. -*/ -import { CodecMap, ConnectionStatsReport, FramerateMap, ResolutionMap, TrackID } from "./statsReport"; -import { MediaTrackStats, Resolution } from "./media/mediaTrackStats"; - -export class StatsReportBuilder { - public static build(stats: Map<TrackID, MediaTrackStats>): ConnectionStatsReport { - const report = {} as ConnectionStatsReport; - - // process stats - const totalPackets = { - download: 0, - upload: 0, - }; - const lostPackets = { - download: 0, - upload: 0, - }; - let bitrateDownload = 0; - let bitrateUpload = 0; - const resolutions: ResolutionMap = { - local: new Map<TrackID, Resolution>(), - remote: new Map<TrackID, Resolution>(), - }; - const framerates: FramerateMap = { local: new Map<TrackID, number>(), remote: new Map<TrackID, number>() }; - const codecs: CodecMap = { local: new Map<TrackID, string>(), remote: new Map<TrackID, string>() }; - - let audioBitrateDownload = 0; - let audioBitrateUpload = 0; - let videoBitrateDownload = 0; - let videoBitrateUpload = 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") { - 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()); - - 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: StatsReportBuilder.calculatePacketLoss( - lostPackets.download + lostPackets.upload, - totalPackets.download + totalPackets.upload, - ), - download: StatsReportBuilder.calculatePacketLoss(lostPackets.download, totalPackets.download), - upload: StatsReportBuilder.calculatePacketLoss(lostPackets.upload, totalPackets.upload), - }; - report.framerate = framerates; - report.resolution = resolutions; - report.codec = codecs; - return report; - } - - private static calculatePacketLoss(lostPackets: number, totalPackets: number): number { - if (!totalPackets || totalPackets <= 0 || !lostPackets || lostPackets <= 0) { - return 0; - } - - return Math.round((lostPackets / totalPackets) * 100); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportEmitter.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportEmitter.ts deleted file mode 100644 index cf01470..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportEmitter.ts +++ /dev/null @@ -1,33 +0,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. -*/ - -import { TypedEventEmitter } from "../../models/typed-event-emitter"; -import { ByteSentStatsReport, ConnectionStatsReport, StatsReport } from "./statsReport"; - -export type StatsReportHandlerMap = { - [StatsReport.BYTE_SENT_STATS]: (report: ByteSentStatsReport) => void; - [StatsReport.CONNECTION_STATS]: (report: ConnectionStatsReport) => void; -}; - -export class StatsReportEmitter extends TypedEventEmitter<StatsReport, StatsReportHandlerMap> { - public emitByteSendReport(byteSentStats: ByteSentStatsReport): void { - this.emit(StatsReport.BYTE_SENT_STATS, byteSentStats); - } - - public emitConnectionStatsReport(report: ConnectionStatsReport): void { - this.emit(StatsReport.CONNECTION_STATS, report); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportGatherer.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportGatherer.ts deleted file mode 100644 index 769ba6e..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportGatherer.ts +++ /dev/null @@ -1,183 +0,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. -*/ - -import { ConnectionStats } from "./connectionStats"; -import { StatsReportEmitter } from "./statsReportEmitter"; -import { ByteSend, ByteSentStatsReport, TrackID } from "./statsReport"; -import { ConnectionStatsReporter } from "./connectionStatsReporter"; -import { TransportStatsReporter } from "./transportStatsReporter"; -import { MediaSsrcHandler } from "./media/mediaSsrcHandler"; -import { MediaTrackHandler } from "./media/mediaTrackHandler"; -import { MediaTrackStatsHandler } from "./media/mediaTrackStatsHandler"; -import { TrackStatsReporter } from "./trackStatsReporter"; -import { StatsReportBuilder } from "./statsReportBuilder"; -import { StatsValueFormatter } from "./statsValueFormatter"; - -export class StatsReportGatherer { - private isActive = true; - private previousStatsReport: RTCStatsReport | undefined; - private currentStatsReport: RTCStatsReport | undefined; - private readonly connectionStats = new ConnectionStats(); - - private readonly trackStats: MediaTrackStatsHandler; - - // private readonly ssrcToMid = { local: new Map<Mid, Ssrc[]>(), remote: new Map<Mid, Ssrc[]>() }; - - public constructor( - public readonly callId: string, - public readonly remoteUserId: string, - private readonly pc: RTCPeerConnection, - private readonly emitter: StatsReportEmitter, - private readonly isFocus = true, - ) { - pc.addEventListener("signalingstatechange", this.onSignalStateChange.bind(this)); - this.trackStats = new MediaTrackStatsHandler(new MediaSsrcHandler(), new MediaTrackHandler(pc)); - } - - public async processStats(groupCallId: string, localUserId: string): Promise<boolean> { - 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 false; - } - - this.previousStatsReport = this.currentStatsReport; - return true; - }) - .catch((error) => { - this.handleError(error); - return false; - }); - } - this.isActive = false; - } - return Promise.resolve(false); - } - - private processStatsReport(groupCallId: string, localUserId: string): void { - const byteSentStats: ByteSentStatsReport = new Map<TrackID, ByteSend>(); - - 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 = ConnectionStatsReporter.buildBandwidthReport(now); - this.connectionStats.transport = TransportStatsReporter.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) { - TrackStatsReporter.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") { - TrackStatsReporter.buildFramerateResolution(trackStats, now); - if (before) { - TrackStatsReporter.buildBitrateReceived(trackStats, now, before); - } - } else if (before) { - byteSentStats.set(trackStats.trackId, StatsValueFormatter.getNonNegativeValue(now.bytesSent)); - TrackStatsReporter.buildBitrateSend(trackStats, now, before); - } - TrackStatsReporter.buildCodec(this.currentStatsReport, trackStats, now); - } else if (now.type === "track" && now.kind === "video" && !now.remoteSource) { - const trackStats = this.trackStats.findLocalVideoTrackStats(now); - if (!trackStats) { - return; - } - TrackStatsReporter.buildFramerateResolution(trackStats, now); - TrackStatsReporter.calculateSimulcastFramerate( - trackStats, - now, - before, - this.trackStats.mediaTrackHandler.getActiveSimulcastStreams(), - ); - } - }); - - this.emitter.emitByteSendReport(byteSentStats); - this.processAndEmitReport(); - } - - public setActive(isActive: boolean): void { - this.isActive = isActive; - } - - public getActive(): boolean { - return this.isActive; - } - - private handleError(_: any): void { - this.isActive = false; - } - - private processAndEmitReport(): void { - const report = StatsReportBuilder.build(this.trackStats.getTrack2stats()); - - this.connectionStats.bandwidth = report.bandwidth; - this.connectionStats.bitrate = report.bitrate; - this.connectionStats.packetLoss = report.packetLoss; - - this.emitter.emitConnectionStatsReport({ - ...report, - transport: this.connectionStats.transport, - }); - - this.connectionStats.transport = []; - } - - public stopProcessingStats(): void {} - - private onSignalStateChange(): void { - 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"); - } - } - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsValueFormatter.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsValueFormatter.ts deleted file mode 100644 index c658fa6..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsValueFormatter.ts +++ /dev/null @@ -1,27 +0,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. -*/ -export class StatsValueFormatter { - public static getNonNegativeValue(imput: any): number { - let value = imput; - - if (typeof value !== "number") { - value = Number(value); - } - - if (isNaN(value)) { - return 0; - } - - return Math.max(0, value); - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/trackStatsReporter.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/trackStatsReporter.ts deleted file mode 100644 index 1f6fcd6..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/trackStatsReporter.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { MediaTrackStats } from "./media/mediaTrackStats"; -import { StatsValueFormatter } from "./statsValueFormatter"; - -export class TrackStatsReporter { - public static buildFramerateResolution(trackStats: MediaTrackStats, now: any): void { - 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)); - } - - public static calculateSimulcastFramerate(trackStats: MediaTrackStats, now: any, before: any, layer: number): void { - 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); - } - - public static buildCodec(report: RTCStatsReport | undefined, trackStats: MediaTrackStats, now: any): void { - 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); - } - } - - public static buildBitrateReceived(trackStats: MediaTrackStats, now: any, before: any): void { - trackStats.setBitrate({ - download: TrackStatsReporter.calculateBitrate( - now.bytesReceived, - before.bytesReceived, - now.timestamp, - before.timestamp, - ), - upload: 0, - }); - } - - public static buildBitrateSend(trackStats: MediaTrackStats, now: any, before: any): void { - trackStats.setBitrate({ - download: 0, - upload: this.calculateBitrate(now.bytesSent, before.bytesSent, now.timestamp, before.timestamp), - }); - } - - public static buildPacketsLost(trackStats: MediaTrackStats, now: any, before: any): void { - const key = now.type === "outbound-rtp" ? "packetsSent" : "packetsReceived"; - - let packetsNow = now[key]; - if (!packetsNow || packetsNow < 0) { - packetsNow = 0; - } - - const packetsBefore = StatsValueFormatter.getNonNegativeValue(before[key]); - const packetsDiff = Math.max(0, packetsNow - packetsBefore); - - const packetsLostNow = StatsValueFormatter.getNonNegativeValue(now.packetsLost); - const packetsLostBefore = StatsValueFormatter.getNonNegativeValue(before.packetsLost); - const packetsLostDiff = Math.max(0, packetsLostNow - packetsLostBefore); - - trackStats.setLoss({ - packetsTotal: packetsDiff + packetsLostDiff, - packetsLost: packetsLostDiff, - isDownloadStream: now.type !== "outbound-rtp", - }); - } - - private static calculateBitrate( - bytesNowAny: any, - bytesBeforeAny: any, - nowTimestamp: number, - beforeTimestamp: number, - ): number { - const bytesNow = StatsValueFormatter.getNonNegativeValue(bytesNowAny); - const bytesBefore = StatsValueFormatter.getNonNegativeValue(bytesBeforeAny); - const bytesProcessed = Math.max(0, bytesNow - bytesBefore); - - const timeMs = nowTimestamp - beforeTimestamp; - let bitrateKbps = 0; - - if (timeMs > 0) { - // TODO is there any reason to round here? - bitrateKbps = Math.round((bytesProcessed * 8) / timeMs); - } - - return bitrateKbps; - } -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/transportStats.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/transportStats.ts deleted file mode 100644 index 2b6e975..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/transportStats.ts +++ /dev/null @@ -1,26 +0,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. -*/ - -export interface TransportStats { - ip: string; - type: string; - localIp: string; - isFocus: boolean; - localCandidateType: string; - remoteCandidateType: string; - networkType: string; - rtt: number; -} diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/transportStatsReporter.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/transportStatsReporter.ts deleted file mode 100644 index d419a73..0000000 --- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/transportStatsReporter.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { TransportStats } from "./transportStats"; - -export class TransportStatsReporter { - public static buildReport( - report: RTCStatsReport | undefined, - now: RTCIceCandidatePairStats, - conferenceStatsTransport: TransportStats[], - isFocus: boolean, - ): TransportStats[] { - 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: TransportStats) => 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, - } as TransportStats); - } - } - return conferenceStatsTransport; - } -} |