From 633c92eae865e957121e08de634aeee11a8b3992 Mon Sep 17 00:00:00 2001 From: RaindropsSys Date: Mon, 24 Apr 2023 14:03:36 +0200 Subject: Updated 18 files, added 1692 files and deleted includes/system/compare.inc (automated) --- .../src/@types/IIdentityServerProvider.ts | 24 + .../matrix-js-sdk/src/@types/PushRules.ts | 209 + .../matrix-js-sdk/src/@types/another-json.d.ts | 19 + .../node_modules/matrix-js-sdk/src/@types/auth.ts | 117 + .../matrix-js-sdk/src/@types/beacon.ts | 140 + .../matrix-js-sdk/src/@types/crypto.ts | 99 + .../node_modules/matrix-js-sdk/src/@types/event.ts | 251 + .../matrix-js-sdk/src/@types/extensible_events.ts | 151 + .../matrix-js-sdk/src/@types/global.d.ts | 99 + .../src/@types/local_notifications.ts | 19 + .../matrix-js-sdk/src/@types/location.ts | 92 + .../matrix-js-sdk/src/@types/partials.ts | 89 + .../node_modules/matrix-js-sdk/src/@types/polls.ts | 119 + .../matrix-js-sdk/src/@types/read_receipts.ts | 61 + .../matrix-js-sdk/src/@types/requests.ts | 243 + .../matrix-js-sdk/src/@types/search.ts | 119 + .../matrix-js-sdk/src/@types/signed.ts | 25 + .../matrix-js-sdk/src/@types/spaces.ts | 37 + .../matrix-js-sdk/src/@types/synapse.ts | 40 + .../node_modules/matrix-js-sdk/src/@types/sync.ts | 27 + .../matrix-js-sdk/src/@types/threepids.ts | 29 + .../node_modules/matrix-js-sdk/src/@types/topic.ts | 63 + .../node_modules/matrix-js-sdk/src/@types/uia.ts | 29 + .../matrix-js-sdk/src/NamespacedValue.ts | 120 + .../node_modules/matrix-js-sdk/src/ReEmitter.ts | 91 + .../matrix-js-sdk/src/ToDeviceMessageQueue.ts | 148 + .../matrix-js-sdk/src/autodiscovery.ts | 472 + .../matrix-js-sdk/src/browser-index.ts | 47 + .../node_modules/matrix-js-sdk/src/client.ts | 9680 ++++++++++++++++++++ .../src/common-crypto/CryptoBackend.ts | 170 + .../matrix-js-sdk/src/common-crypto/README.md | 4 + .../matrix-js-sdk/src/content-helpers.ts | 288 + .../node_modules/matrix-js-sdk/src/content-repo.ts | 79 + .../node_modules/matrix-js-sdk/src/crypto-api.ts | 75 + .../matrix-js-sdk/src/crypto/CrossSigning.ts | 803 ++ .../matrix-js-sdk/src/crypto/DeviceList.ts | 989 ++ .../matrix-js-sdk/src/crypto/EncryptionSetup.ts | 356 + .../matrix-js-sdk/src/crypto/OlmDevice.ts | 1496 +++ .../src/crypto/OutgoingRoomKeyRequestManager.ts | 485 + .../matrix-js-sdk/src/crypto/RoomList.ts | 63 + .../matrix-js-sdk/src/crypto/SecretStorage.ts | 583 ++ .../node_modules/matrix-js-sdk/src/crypto/aes.ts | 157 + .../matrix-js-sdk/src/crypto/algorithms/base.ts | 268 + .../matrix-js-sdk/src/crypto/algorithms/index.ts | 20 + .../matrix-js-sdk/src/crypto/algorithms/megolm.ts | 2208 +++++ .../matrix-js-sdk/src/crypto/algorithms/olm.ts | 329 + .../node_modules/matrix-js-sdk/src/crypto/api.ts | 127 + .../matrix-js-sdk/src/crypto/backup.ts | 813 ++ .../matrix-js-sdk/src/crypto/crypto.ts | 50 + .../matrix-js-sdk/src/crypto/dehydration.ts | 271 + .../matrix-js-sdk/src/crypto/deviceinfo.ts | 161 + .../node_modules/matrix-js-sdk/src/crypto/index.ts | 3936 ++++++++ .../matrix-js-sdk/src/crypto/key_passphrase.ts | 93 + .../matrix-js-sdk/src/crypto/keybackup.ts | 77 + .../matrix-js-sdk/src/crypto/olmlib.ts | 566 ++ .../matrix-js-sdk/src/crypto/recoverykey.ts | 62 + .../matrix-js-sdk/src/crypto/store/base.ts | 226 + .../crypto/store/indexeddb-crypto-store-backend.ts | 1062 +++ .../src/crypto/store/indexeddb-crypto-store.ts | 708 ++ .../src/crypto/store/localStorage-crypto-store.ts | 403 + .../src/crypto/store/memory-crypto-store.ts | 533 ++ .../matrix-js-sdk/src/crypto/verification/Base.ts | 369 + .../matrix-js-sdk/src/crypto/verification/Error.ts | 76 + .../src/crypto/verification/IllegalMethod.ts | 50 + .../src/crypto/verification/QRCode.ts | 311 + .../matrix-js-sdk/src/crypto/verification/SAS.ts | 492 + .../src/crypto/verification/SASDecimal.ts | 37 + .../src/crypto/verification/request/Channel.ts | 34 + .../crypto/verification/request/InRoomChannel.ts | 356 + .../crypto/verification/request/ToDeviceChannel.ts | 354 + .../verification/request/VerificationRequest.ts | 926 ++ .../node_modules/matrix-js-sdk/src/embedded.ts | 347 + .../node_modules/matrix-js-sdk/src/errors.ts | 53 + .../node_modules/matrix-js-sdk/src/event-mapper.ts | 97 + .../src/extensible_events_v1/ExtensibleEvent.ts | 58 + .../src/extensible_events_v1/InvalidEventError.ts | 24 + .../src/extensible_events_v1/MessageEvent.ts | 145 + .../src/extensible_events_v1/PollEndEvent.ts | 97 + .../src/extensible_events_v1/PollResponseEvent.ts | 143 + .../src/extensible_events_v1/PollStartEvent.ts | 207 + .../src/extensible_events_v1/utilities.ts | 35 + .../node_modules/matrix-js-sdk/src/feature.ts | 75 + .../matrix-js-sdk/src/filter-component.ts | 204 + .../node_modules/matrix-js-sdk/src/filter.ts | 242 + .../matrix-js-sdk/src/http-api/errors.ts | 84 + .../matrix-js-sdk/src/http-api/fetch.ts | 311 + .../matrix-js-sdk/src/http-api/index.ts | 191 + .../matrix-js-sdk/src/http-api/interface.ts | 147 + .../matrix-js-sdk/src/http-api/method.ts | 22 + .../matrix-js-sdk/src/http-api/prefix.ts | 48 + .../matrix-js-sdk/src/http-api/utils.ts | 153 + .../matrix/node_modules/matrix-js-sdk/src/index.ts | 25 + .../matrix-js-sdk/src/indexeddb-helpers.ts | 50 + .../matrix-js-sdk/src/indexeddb-worker.ts | 24 + .../matrix-js-sdk/src/interactive-auth.ts | 617 ++ .../node_modules/matrix-js-sdk/src/logger.ts | 82 + .../node_modules/matrix-js-sdk/src/matrix.ts | 110 + .../matrix-js-sdk/src/models/MSC3089Branch.ts | 258 + .../matrix-js-sdk/src/models/MSC3089TreeSpace.ts | 566 ++ .../matrix-js-sdk/src/models/ToDeviceMessage.ts | 38 + .../matrix-js-sdk/src/models/beacon.ts | 209 + .../matrix-js-sdk/src/models/event-context.ts | 110 + .../matrix-js-sdk/src/models/event-status.ts | 39 + .../matrix-js-sdk/src/models/event-timeline-set.ts | 906 ++ .../matrix-js-sdk/src/models/event-timeline.ts | 458 + .../node_modules/matrix-js-sdk/src/models/event.ts | 1631 ++++ .../matrix-js-sdk/src/models/invites-ignorer.ts | 368 + .../node_modules/matrix-js-sdk/src/models/poll.ts | 268 + .../matrix-js-sdk/src/models/read-receipt.ts | 312 + .../matrix-js-sdk/src/models/related-relations.ts | 39 + .../src/models/relations-container.ts | 146 + .../matrix-js-sdk/src/models/relations.ts | 368 + .../matrix-js-sdk/src/models/room-member.ts | 453 + .../matrix-js-sdk/src/models/room-state.ts | 1081 +++ .../matrix-js-sdk/src/models/room-summary.ts | 44 + .../node_modules/matrix-js-sdk/src/models/room.ts | 3487 +++++++ .../matrix-js-sdk/src/models/search-result.ts | 54 + .../matrix-js-sdk/src/models/thread.ts | 669 ++ .../src/models/typed-event-emitter.ts | 114 + .../node_modules/matrix-js-sdk/src/models/user.ts | 281 + .../matrix-js-sdk/src/pushprocessor.ts | 770 ++ .../node_modules/matrix-js-sdk/src/randomstring.ts | 42 + .../matrix-js-sdk/src/realtime-callbacks.ts | 191 + .../src/rendezvous/MSC3906Rendezvous.ts | 264 + .../src/rendezvous/RendezvousChannel.ts | 48 + .../matrix-js-sdk/src/rendezvous/RendezvousCode.ts | 25 + .../src/rendezvous/RendezvousError.ts | 23 + .../src/rendezvous/RendezvousFailureReason.ts | 31 + .../src/rendezvous/RendezvousIntent.ts | 20 + .../src/rendezvous/RendezvousTransport.ts | 58 + .../channels/MSC3903ECDHv2RendezvousChannel.ts | 259 + .../matrix-js-sdk/src/rendezvous/channels/index.ts | 17 + .../matrix-js-sdk/src/rendezvous/index.ts | 23 + .../MSC3886SimpleHttpRendezvousTransport.ts | 193 + .../src/rendezvous/transports/index.ts | 17 + .../matrix-js-sdk/src/room-hierarchy.ts | 152 + .../src/rust-crypto/KeyClaimManager.ts | 77 + .../src/rust-crypto/OutgoingRequestProcessor.ts | 116 + .../matrix-js-sdk/src/rust-crypto/RoomEncryptor.ts | 153 + .../src/rust-crypto/browserify-index.ts | 31 + .../matrix-js-sdk/src/rust-crypto/constants.ts | 18 + .../matrix-js-sdk/src/rust-crypto/index.ts | 45 + .../matrix-js-sdk/src/rust-crypto/rust-crypto.ts | 334 + .../node_modules/matrix-js-sdk/src/scheduler.ts | 335 + .../matrix-js-sdk/src/secret-storage.ts | 88 + .../matrix-js-sdk/src/service-types.ts | 20 + .../matrix-js-sdk/src/sliding-sync-sdk.ts | 1027 +++ .../node_modules/matrix-js-sdk/src/sliding-sync.ts | 961 ++ .../node_modules/matrix-js-sdk/src/store/index.ts | 248 + .../matrix-js-sdk/src/store/indexeddb-backend.ts | 40 + .../src/store/indexeddb-local-backend.ts | 597 ++ .../src/store/indexeddb-remote-backend.ts | 203 + .../src/store/indexeddb-store-worker.ts | 157 + .../matrix-js-sdk/src/store/indexeddb.ts | 383 + .../src/store/local-storage-events-emitter.ts | 46 + .../node_modules/matrix-js-sdk/src/store/memory.ts | 436 + .../node_modules/matrix-js-sdk/src/store/stub.ts | 267 + .../matrix-js-sdk/src/sync-accumulator.ts | 715 ++ .../matrix/node_modules/matrix-js-sdk/src/sync.ts | 1898 ++++ .../matrix-js-sdk/src/timeline-window.ts | 507 + .../matrix/node_modules/matrix-js-sdk/src/utils.ts | 770 ++ .../matrix-js-sdk/src/webrtc/audioContext.ts | 44 + .../node_modules/matrix-js-sdk/src/webrtc/call.ts | 2962 ++++++ .../matrix-js-sdk/src/webrtc/callEventHandler.ts | 425 + .../matrix-js-sdk/src/webrtc/callEventTypes.ts | 92 + .../matrix-js-sdk/src/webrtc/callFeed.ts | 361 + .../matrix-js-sdk/src/webrtc/groupCall.ts | 1598 ++++ .../src/webrtc/groupCallEventHandler.ts | 232 + .../matrix-js-sdk/src/webrtc/mediaHandler.ts | 469 + .../src/webrtc/stats/connectionStats.ts | 47 + .../src/webrtc/stats/connectionStatsReporter.ts | 28 + .../src/webrtc/stats/groupCallStats.ts | 64 + .../src/webrtc/stats/media/mediaSsrcHandler.ts | 57 + .../src/webrtc/stats/media/mediaTrackHandler.ts | 71 + .../src/webrtc/stats/media/mediaTrackStats.ts | 104 + .../webrtc/stats/media/mediaTrackStatsHandler.ts | 86 + .../matrix-js-sdk/src/webrtc/stats/statsReport.ts | 56 + .../src/webrtc/stats/statsReportBuilder.ts | 110 + .../src/webrtc/stats/statsReportEmitter.ts | 33 + .../src/webrtc/stats/statsReportGatherer.ts | 183 + .../src/webrtc/stats/statsValueFormatter.ts | 27 + .../src/webrtc/stats/trackStatsReporter.ts | 117 + .../src/webrtc/stats/transportStats.ts | 26 + .../src/webrtc/stats/transportStatsReporter.ts | 48 + 184 files changed, 67460 insertions(+) create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/@types/IIdentityServerProvider.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/@types/PushRules.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/@types/another-json.d.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/@types/auth.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/@types/beacon.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/@types/crypto.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/@types/event.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/@types/extensible_events.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/@types/global.d.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/@types/local_notifications.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/@types/location.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/@types/partials.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/@types/polls.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/@types/read_receipts.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/@types/requests.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/@types/search.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/@types/signed.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/@types/spaces.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/@types/synapse.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/@types/sync.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/@types/threepids.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/@types/topic.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/@types/uia.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/NamespacedValue.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/ReEmitter.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/ToDeviceMessageQueue.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/autodiscovery.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/browser-index.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/client.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/common-crypto/CryptoBackend.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/common-crypto/README.md create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/content-helpers.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/content-repo.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto-api.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/CrossSigning.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/DeviceList.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/EncryptionSetup.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OlmDevice.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OutgoingRoomKeyRequestManager.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/RoomList.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/SecretStorage.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/aes.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/base.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/index.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/megolm.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/olm.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/api.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/backup.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/crypto.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/dehydration.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/deviceinfo.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/index.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/key_passphrase.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/keybackup.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/olmlib.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/recoverykey.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/base.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store-backend.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/localStorage-crypto-store.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/memory-crypto-store.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Base.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Error.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/IllegalMethod.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/QRCode.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SAS.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SASDecimal.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/Channel.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/InRoomChannel.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/ToDeviceChannel.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/VerificationRequest.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/embedded.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/errors.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/event-mapper.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/ExtensibleEvent.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/InvalidEventError.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/MessageEvent.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollEndEvent.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollResponseEvent.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollStartEvent.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/utilities.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/feature.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/filter-component.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/filter.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/errors.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/fetch.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/index.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/interface.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/method.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/prefix.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/utils.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/index.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/indexeddb-helpers.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/indexeddb-worker.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/interactive-auth.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/logger.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/matrix.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/models/MSC3089Branch.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/models/MSC3089TreeSpace.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/models/ToDeviceMessage.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/models/beacon.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-context.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-status.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-timeline-set.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-timeline.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/models/event.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/models/invites-ignorer.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/models/poll.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/models/read-receipt.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/models/related-relations.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/models/relations-container.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/models/relations.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-member.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-state.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-summary.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/models/room.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/models/search-result.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/models/thread.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/models/typed-event-emitter.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/models/user.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/pushprocessor.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/randomstring.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/realtime-callbacks.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/MSC3906Rendezvous.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousChannel.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousCode.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousError.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousFailureReason.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousIntent.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousTransport.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/channels/index.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/index.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/transports/index.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/room-hierarchy.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/KeyClaimManager.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/OutgoingRequestProcessor.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/RoomEncryptor.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/browserify-index.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/constants.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/index.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/rust-crypto.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/scheduler.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/secret-storage.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/service-types.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/sliding-sync-sdk.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/sliding-sync.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/store/index.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-backend.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-local-backend.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-remote-backend.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-store-worker.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/store/local-storage-events-emitter.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/store/memory.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/store/stub.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/sync-accumulator.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/sync.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/timeline-window.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/utils.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/audioContext.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/call.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callEventHandler.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callEventTypes.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callFeed.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCall.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCallEventHandler.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/mediaHandler.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/connectionStats.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/connectionStatsReporter.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/groupCallStats.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaSsrcHandler.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackHandler.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackStats.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackStatsHandler.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReport.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportBuilder.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportEmitter.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportGatherer.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsValueFormatter.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/trackStatsReporter.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/transportStats.ts create mode 100644 includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/transportStatsReporter.ts (limited to 'includes/external/matrix/node_modules/matrix-js-sdk/src') 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 new file mode 100644 index 0000000..8e30497 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/IIdentityServerProvider.ts @@ -0,0 +1,24 @@ +/* +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; +} 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 new file mode 100644 index 0000000..da3b01b --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/PushRules.ts @@ -0,0 +1,209 @@ +/* +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 = { + set_tweak: N; + value?: V; +}; + +export type TweakHighlight = Tweak; +export type TweakSound = Tweak; + +export type Tweaks = TweakHighlight | TweakSound; + +export enum ConditionOperator { + ExactEquals = "==", + LessThan = "<", + GreaterThan = ">", + GreaterThanOrEqual = ">=", + LessThanOrEqual = "<=", +} + +export type PushRuleAction = Tweaks | PushRuleActionName; + +export type MemberCountCondition = + | `${Op}${N}` + | (Op extends ConditionOperator.ExactEquals ? `${N}` : never); + +export type AnyMemberCountCondition = MemberCountCondition; + +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 { + [k: string]: any; // for custom conditions, there can be other fields here + kind: N; +} + +export interface IEventMatchCondition extends IPushRuleCondition { + 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 { + key: string; + value: string | boolean | null | number; +} + +export interface IEventPropertyContainsCondition extends IPushRuleCondition { + key: string; + value: string | boolean | null | number; +} + +export interface IContainsDisplayNameCondition extends IPushRuleCondition { + // no additional fields +} + +export interface IRoomMemberCountCondition extends IPushRuleCondition { + is: AnyMemberCountCondition; +} + +export interface ISenderNotificationPermissionCondition + extends IPushRuleCondition { + key: string; +} + +export interface ICallStartedCondition extends IPushRuleCondition { + // no additional fields +} + +export interface ICallStartedPrefixCondition extends IPushRuleCondition { + // no additional fields +} + +// XXX: custom conditions are possible but always fail, and break the typescript discriminated union so ignore them here +// IPushRuleCondition> 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 { + 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 new file mode 100644 index 0000000..070332a --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/another-json.d.ts @@ -0,0 +1,19 @@ +/* +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 new file mode 100644 index 0000000..2b8f5d7 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/auth.ts @@ -0,0 +1,117 @@ +/* +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 new file mode 100644 index 0000000..e6bfb8f --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/beacon.ts @@ -0,0 +1,140 @@ +/* +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; 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 new file mode 100644 index 0000000..7711840 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/crypto.ts @@ -0,0 +1,99 @@ +/* +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 type and content 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; + /** 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; + device_id: string; // eslint-disable-line camelcase + user_id: string; // eslint-disable-line camelcase + keys: Record; + 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 new file mode 100644 index 0000000..17af8df --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/event.ts @@ -0,0 +1,251 @@ +/* +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 new file mode 100644 index 0000000..db9ea18 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/extensible_events.ts @@ -0,0 +1,151 @@ +/* +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 extends NamespacedValue + ? TSNamespaceValue | TSNamespaceValue + : never; + +/** + * Represents a namespaced value, if the value is a string. Used to extract provided types + * from a TSNamespace (in cases where only stable *or* unstable is provided). + */ +export type TSNamespaceValue = 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] 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 | string; + +/** + * An m.relates_to relationship + */ +export type RelatesToRelationship = { + "m.relates_to": { + // See https://github.com/microsoft/TypeScript/issues/23182#issuecomment-379091887 for array syntax + rel_type: [R] extends [never] ? AnyRelation : TSNamespace; + event_id: string; + } & DefaultNever; +}; + +/** + * Partial types for a Matrix Event. + */ +export interface IPartialEvent { + type: string; + content: TContent; +} + +/** + * Represents a potentially namespaced event type. + */ +export type ExtensibleEventType = NamespacedValue | 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, + expected: Optional, +): boolean { + if (typeof given === "string") { + if (typeof expected === "string") { + return expected === given; + } else { + return (expected as NamespacedValue).matches(given as string); + } + } else { + if (typeof expected === "string") { + return (given as NamespacedValue).matches(expected as string); + } else { + const expectedNs = expected as NamespacedValue; + const givenNs = given as NamespacedValue; + 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 new file mode 100644 index 0000000..749eb7f --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/global.d.ts @@ -0,0 +1,99 @@ +/* +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` 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; + getUserMedia(constraints: MediaStreamConstraints | DesktopCapturerConstraints): Promise; + } + + 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 { + status: "fulfilled"; + value: T; + } + export interface ISettledRejected { + status: "rejected"; + reason: any; + } + + interface PromiseConstructor { + allSettled(promises: Promise[]): Promise | 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 new file mode 100644 index 0000000..b92d986 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/local_notifications.ts @@ -0,0 +1,19 @@ +/* +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 new file mode 100644 index 0000000..d1a826f --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/location.ts @@ -0,0 +1,92 @@ +/* +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; + +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 new file mode 100644 index 0000000..49f92f3 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/partials.ts @@ -0,0 +1,89 @@ +/* +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 new file mode 100644 index 0000000..3b06f93 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/polls.ts @@ -0,0 +1,119 @@ +/* +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 | TSNamespace | 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; + +/** + * 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 & + 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 new file mode 100644 index 0000000..7592403 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/read_receipts.ts @@ -0,0 +1,61 @@ +/* +Copyright 2022 Šimon Brandner + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT 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; + +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>; + +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 new file mode 100644 index 0000000..12f4d8e --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/requests.ts @@ -0,0 +1,243 @@ +/* +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 returned Room object will have no current state. + * 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. + * + * Raises an Error if the server does not support it. + * Check for server-side support before using this param with + * client.canSupport.get(Feature.RelationBasedRedactions). + * {@link https://github.com/matrix-org/matrix-spec-proposals/pull/3912} + */ + with_relations?: Array; +} + +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; + events_default?: number; + invite?: number; + kick?: number; + notifications?: Record; + redact?: number; + state_default?: number; + users?: Record; + 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; + }; + 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 new file mode 100644 index 0000000..3a6d4fd --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/search.ts @@ -0,0 +1,119 @@ +/* +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; + 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; + 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 new file mode 100644 index 0000000..a209f37 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/signed.ts @@ -0,0 +1,25 @@ +/* +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 new file mode 100644 index 0000000..9edab27 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/spaces.ts @@ -0,0 +1,37 @@ +/* +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 new file mode 100644 index 0000000..1d4ce41 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/synapse.ts @@ -0,0 +1,40 @@ +/* +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 new file mode 100644 index 0000000..d9a2a6f --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/sync.ts @@ -0,0 +1,27 @@ +/* +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 new file mode 100644 index 0000000..c28ffc3 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/threepids.ts @@ -0,0 +1,29 @@ +/* +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 new file mode 100644 index 0000000..04d1464 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/topic.ts @@ -0,0 +1,63 @@ +/* +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 pizza", + * "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 new file mode 100644 index 0000000..e611420 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/uia.ts @@ -0,0 +1,29 @@ +/* +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 & { + auth?: IAuthData; +}; + +/** + * Helper type to represent HTTP response body for a UIA enabled endpoint + */ +export type UIAResponse = 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 new file mode 100644 index 0000000..a1a7e5d --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/NamespacedValue.ts @@ -0,0 +1,120 @@ +/* +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 { + // 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` as a default type for that namespace. + public findIn(obj: any): Optional { + 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 extends NamespacedValue { + 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 extends NamespacedValue { + // 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 new file mode 100644 index 0000000..565e8ea --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/ReEmitter.ts @@ -0,0 +1,91 @@ +/* +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 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> extends ReEmitter { + public constructor(target: TypedEventEmitter) { + super(target); + } + + public reEmit( + source: TypedEventEmitter, + eventNames: T[], + ): void { + super.reEmit(source, eventNames); + } + + public stopReEmitting( + source: TypedEventEmitter, + 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 new file mode 100644 index 0000000..59eada4 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/ToDeviceMessageQueue.ts @@ -0,0 +1,148 @@ +/* +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 | 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 { + 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 => { + 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, e); + if (retryDelay === -1) { + // the scheduler function doesn't differentiate between fatal errors and just getting + // bored and giving up for now + if (Math.floor((e).httpStatus! / 100) === 4) { + logger.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 { + const contentMap: MapWithDefault> = 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 new file mode 100644 index 0000000..f4a3415 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/autodiscovery.ts @@ -0,0 +1,472 @@ +/* +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 { + state: AutoDiscoveryAction; + error?: IWellKnownConfig["error"] | null; +} + +export interface ClientConfig extends Omit { + "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 { + // 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; + // @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 { + 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 { + 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 { + 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 { + 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)?.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 new file mode 100644 index 0000000..200b2a3 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/browser-index.ts @@ -0,0 +1,47 @@ +/* +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 new file mode 100644 index 0000000..0e47ff6 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/client.ts @@ -0,0 +1,9680 @@ +/* +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; + + /** + * 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; + + /** + * 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 on the /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 "chronological", messages will + * appear in the timeline when the call to `sendEvent` was made. If "detached", + * pending messages will appear in a separate list, accessbile via {@link 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; +} + +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; + +export type SendToDeviceContentMap = Map>>; + +export interface ISignedKey { + keys: Record; + signatures: ISignatures; + user_id: string; + algorithms: string[]; + device_id: string; +} + +export type KeySignatures = Record>; +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; +} + +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; + "one_time_keys"?: Record; + "org.matrix.msc2732.fallback_keys"?: Record; +} + +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; // 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; + instances: IInstance[]; +} + +interface IThirdPartyLocation { + alias: string; + protocol: string; + fields: object; +} + +interface IThirdPartyUser { + userid: string; + protocol: string; + fields: object; +} + +interface IRoomSummary extends Omit { + room_type?: RoomType; + membership?: string; + is_encrypted: boolean; +} + +interface IRoomKeysResponse { + sessions: IKeyBackupRoomSessions; +} + +interface IRoomsKeysResponse { + rooms: Record; +} + +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: + *
    + * + *
  • PREPARED: The client has synced with the server at least once and is + * ready for methods to be called on it. This will be immediately followed by + * a state of SYNCING. This is the equivalent of "syncComplete" in the + * previous API.
  • + * + *
  • CATCHUP: The client has detected the connection to the server might be + * available again and will now try to do a sync again. As this sync might take + * a long time (depending how long ago was last synced, and general server + * performance) the client is put in this mode so the UI can reflect trying + * to catch up with the server after losing connection.
  • + * + *
  • SYNCING : The client is currently polling for new events from the server. + * This will be called after processing latest events from a sync.
  • + * + *
  • ERROR : The client has had a problem syncing with the server. If this is + * called before PREPARED then there was a problem performing the initial + * sync. If this is called after PREPARED then there was a problem polling + * the server for updates. This may be called multiple times even if the state is + * already ERROR. This is the equivalent of "syncError" in the previous + * API.
  • + * + *
  • RECONNECTING: The sync connection has dropped, but not (yet) in a way that + * should be considered erroneous. + *
  • + * + *
  • STOPPED: The client has stopped syncing with server due to stopClient + * being called. + *
  • + *
+ * State transition diagram: + * ``` + * +---->STOPPED + * | + * +----->PREPARED -------> SYNCING <--+ + * | ^ | ^ | + * | CATCHUP ----------+ | | | + * | ^ V | | + * null ------+ | +------- RECONNECTING | + * | V V | + * +------->ERROR ---------------------+ + * + * NB: 'null' will never be emitted by this event. + * + * ``` + * Transitions: + *
    + * + *
  • `null -> PREPARED` : Occurs when the initial sync is completed + * first time. This involves setting up filters and obtaining push rules. + * + *
  • `null -> ERROR` : Occurs when the initial sync failed first time. + * + *
  • `ERROR -> PREPARED` : Occurs when the initial sync succeeds + * after previously failing. + * + *
  • `PREPARED -> SYNCING` : Occurs immediately after transitioning + * to PREPARED. Starts listening for live updates rather than catching up. + * + *
  • `SYNCING -> RECONNECTING` : Occurs when the live update fails. + * + *
  • `RECONNECTING -> RECONNECTING` : Can occur if the update calls + * continue to fail, but the keepalive calls (to /versions) succeed. + * + *
  • `RECONNECTING -> ERROR` : Occurs when the keepalive call also fails + * + *
  • `ERROR -> SYNCING` : Occurs when the client has performed a + * live update after having previously failed. + * + *
  • `ERROR -> ERROR` : Occurs when the client has failed to keepalive + * for a second time or more.
  • + * + *
  • `SYNCING -> SYNCING` : Occurs when the client has performed a live + * update. This is called after processing.
  • + * + *
  • `* -> STOPPED` : Occurs once the client has stopped syncing or + * trying to sync after stopClient has been called.
  • + *
+ * + * @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" or null. + * + * @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. + *

+ * 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. This event is experimental and + * may change. + * @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. + * This event is experimental and may change. + * @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 { + public static readonly RESTORE_BACKUP_ERROR_BAD_KEY = "RESTORE_BACKUP_ERROR_BAD_KEY"; + + public reEmitter = new TypedReEmitter(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 } = {}; + public identityServer?: IIdentityServerProvider; + public http: MatrixHttpApi; // 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; 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; + protected syncedLeftRooms = false; + protected clientOpts?: IStoredClientOpts; + protected clientWellKnownIntervalID?: ReturnType; + protected canResetTimelineCallback?: ResetTimelineCallback; + + public canSupport = new Map(); + + // 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; + + public cachedCapabilities?: { + capabilities: ICapabilities; + expiration: number; + }; + protected clientWellKnown?: IClientWellKnown; + protected clientWellKnownPromise?: Promise; + protected turnServers: ITurnServer[] = []; + protected turnServersExpiry = 0; + protected checkTurnServersIntervalID?: ReturnType; + protected exportedOlmDeviceToImport?: IExportedOlmDevice; + protected txnCtr = 0; + protected mediaHandler = new MediaHandler(this); + protected sessionId: string; + protected pendingEventEncryption = new Map>(); + + 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[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 { + 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 { + 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 { + try { + return await this.http.authedRequest( + 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 { + 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 { + 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 { + 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 { + if (this.clientRunning) { + throw new Error("Cannot clear stores while client is running"); + } + + const promises: Promise[] = []; + + 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 => { + 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 { + 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 { + 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. This method is experimental + * and may change without warning. + * @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 explicitly 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 { + 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(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 { + 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[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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 | 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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[], payload: object): Promise { + 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 { + 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 { + 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 { + 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 { + let res: IKeyBackupInfo; + try { + res = await this.http.authedRequest( + Method.Get, + "/room_keys/version", + undefined, + undefined, + { prefix: ClientPrefix.V3 }, + ); + } catch (e) { + if ((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 { + 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 { + 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> { + 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 | 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 { + 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(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 { + 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; + public sendKeyBackup( + roomId: string, + sessionId: undefined, + version: string | undefined, + data: IKeyBackup, + ): Promise; + public sendKeyBackup( + roomId: string, + sessionId: string, + version: string | undefined, + data: IKeyBackup, + ): Promise; + public async sendKeyBackup( + roomId: string | undefined, + sessionId: string | undefined, + version: string | undefined, + data: IKeyBackup, + ): Promise { + 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 { + 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 { + 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 { + 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; + public async restoreKeyBackupWithPassword( + password: string, + targetRoomId: string, + targetSessionId: undefined, + backupInfo: IKeyBackupInfo, + opts: IKeyBackupRestoreOpts, + ): Promise; + public async restoreKeyBackupWithPassword( + password: string, + targetRoomId: string, + targetSessionId: string, + backupInfo: IKeyBackupInfo, + opts: IKeyBackupRestoreOpts, + ): Promise; + public async restoreKeyBackupWithPassword( + password: string, + targetRoomId: string | undefined, + targetSessionId: string | undefined, + backupInfo: IKeyBackupInfo, + opts: IKeyBackupRestoreOpts, + ): Promise { + 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 { + 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; + public restoreKeyBackupWithRecoveryKey( + recoveryKey: string, + targetRoomId: string, + targetSessionId: undefined, + backupInfo: IKeyBackupInfo, + opts?: IKeyBackupRestoreOpts, + ): Promise; + public restoreKeyBackupWithRecoveryKey( + recoveryKey: string, + targetRoomId: string, + targetSessionId: string, + backupInfo: IKeyBackupInfo, + opts?: IKeyBackupRestoreOpts, + ): Promise; + public restoreKeyBackupWithRecoveryKey( + recoveryKey: string, + targetRoomId: string | undefined, + targetSessionId: string | undefined, + backupInfo: IKeyBackupInfo, + opts?: IKeyBackupRestoreOpts, + ): Promise { + const privKey = decodeRecoveryKey(recoveryKey); + return this.restoreKeyBackup(privKey, targetRoomId!, targetSessionId!, backupInfo, opts); + } + + public async restoreKeyBackupWithCache( + targetRoomId: undefined, + targetSessionId: undefined, + backupInfo: IKeyBackupInfo, + opts?: IKeyBackupRestoreOpts, + ): Promise; + public async restoreKeyBackupWithCache( + targetRoomId: string, + targetSessionId: undefined, + backupInfo: IKeyBackupInfo, + opts?: IKeyBackupRestoreOpts, + ): Promise; + public async restoreKeyBackupWithCache( + targetRoomId: string, + targetSessionId: string, + backupInfo: IKeyBackupInfo, + opts?: IKeyBackupRestoreOpts, + ): Promise; + public async restoreKeyBackupWithCache( + targetRoomId: string | undefined, + targetSessionId: string | undefined, + backupInfo: IKeyBackupInfo, + opts?: IKeyBackupRestoreOpts, + ): Promise { + 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, + targetRoomId: undefined, + targetSessionId: undefined, + backupInfo: IKeyBackupInfo, + opts?: IKeyBackupRestoreOpts, + ): Promise; + private async restoreKeyBackup( + privKey: ArrayLike, + targetRoomId: string, + targetSessionId: undefined, + backupInfo: IKeyBackupInfo, + opts?: IKeyBackupRestoreOpts, + ): Promise; + private async restoreKeyBackup( + privKey: ArrayLike, + targetRoomId: string, + targetSessionId: string, + backupInfo: IKeyBackupInfo, + opts?: IKeyBackupRestoreOpts, + ): Promise; + private async restoreKeyBackup( + privKey: ArrayLike, + targetRoomId: string | undefined, + targetSessionId: string | undefined, + backupInfo: IKeyBackupInfo, + opts?: IKeyBackupRestoreOpts, + ): Promise { + 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( + 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; + public deleteKeysFromBackup(roomId: string, sessionId: undefined, version?: string): Promise; + public deleteKeysFromBackup(roomId: string, sessionId: string, version?: string): Promise; + public async deleteKeysFromBackup(roomId?: string, sessionId?: string, version?: string): Promise { + 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 { + 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 = 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 { + 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(eventType: string): Promise { + if (this.isInitialSyncComplete()) { + const event = this.store.getAccountData(eventType); + if (!event) { + return null; + } + // The network version below returns just the content, so this branch + // does the same to match. + return event.getContent(); + } + const path = utils.encodeUri("/user/$userId/account_data/$type", { + $userId: this.credentials.userId!, + $type: eventType, + }); + try { + return await this.http.authedRequest(Method.Get, path); + } catch (e) { + if ((e).data?.errcode === "M_NOT_FOUND") { + return null; + } + throw e; + } + } + + public async deleteAccountData(eventType: string): Promise { + 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 }; + 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 { + 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 = Promise.resolve(); + + if (opts.inviteSignUrl) { + const url = new URL(opts.inviteSignUrl); + url.searchParams.set("mxid", this.credentials.userId!); + signPromise = this.http.requestOtherUrl(Method.Post, url); + } + + const queryString: Record = {}; + 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 { + // 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 { + 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 { + 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 { + 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): 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 { + let content = { + users: {} as Record, + }; + 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 { + 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 { + return this.sendStateEvent(roomId, M_BEACON_INFO.name, beaconInfoContent, this.getUserId()!); + } + + public sendEvent(roomId: string, eventType: string, content: IContent, txnId?: string): Promise; + public sendEvent( + roomId: string, + threadId: string | null, + eventType: string, + content: IContent, + txnId?: string, + ): Promise; + public sendEvent( + roomId: string, + threadIdOrEventType: string | null, + eventTypeOrContent: string | IContent, + contentOrTxnId?: IContent | string, + txnIdOrVoid?: string, + ): Promise { + 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 { + 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 { + 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 | 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!", (e).stack || err); + } + if (err instanceof MatrixError) { + err.event = event; + } + throw err; + }); + } + + private encryptEventIfNeeded(event: MatrixEvent, room?: Room): Promise | 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 { + 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(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; + public redactEvent( + roomId: string, + threadId: string | null, + eventId: string, + txnId?: string | undefined, + opts?: IRedactOpts, + ): Promise; + public redactEvent( + roomId: string, + threadId: string | null, + eventId?: string, + txnId?: string | IRedactOpts, + opts?: IRedactOpts, + ): Promise { + 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; + public sendMessage( + roomId: string, + threadId: string | null, + content: IContent, + txnId?: string, + ): Promise; + public sendMessage( + roomId: string, + threadId: string | null | IContent, + content?: IContent | string, + txnId?: string, + ): Promise { + 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; + public sendTextMessage( + roomId: string, + threadId: string | null, + body: string, + txnId?: string, + ): Promise; + public sendTextMessage( + roomId: string, + threadId: string | null, + body: string, + txnId?: string, + ): Promise { + 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; + public sendNotice( + roomId: string, + threadId: string | null, + body: string, + txnId?: string, + ): Promise; + public sendNotice( + roomId: string, + threadId: string | null, + body: string, + txnId?: string, + ): Promise { + 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; + public sendEmoteMessage( + roomId: string, + threadId: string | null, + body: string, + txnId?: string, + ): Promise; + public sendEmoteMessage( + roomId: string, + threadId: string | null, + body: string, + txnId?: string, + ): Promise { + 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; + public sendImageMessage( + roomId: string, + threadId: string | null, + url: string, + info?: IImageInfo, + text?: string, + ): Promise; + public sendImageMessage( + roomId: string, + threadId: string | null, + url?: string | IImageInfo, + info?: IImageInfo | string, + text = "Image", + ): Promise { + 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; + public sendStickerMessage( + roomId: string, + threadId: string | null, + url: string, + info?: IImageInfo, + text?: string, + ): Promise; + public sendStickerMessage( + roomId: string, + threadId: string | null, + url?: string | IImageInfo, + info?: IImageInfo | string, + text = "Sticker", + ): Promise { + 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; + public sendHtmlMessage( + roomId: string, + threadId: string | null, + body: string, + htmlBody: string, + ): Promise; + public sendHtmlMessage( + roomId: string, + threadId: string | null, + body: string, + htmlBody?: string, + ): Promise { + 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; + public sendHtmlNotice( + roomId: string, + threadId: string | null, + body: string, + htmlBody: string, + ): Promise; + public sendHtmlNotice( + roomId: string, + threadId: string | null, + body: string, + htmlBody?: string, + ): Promise { + 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; + public sendHtmlEmote( + roomId: string, + threadId: string | null, + body: string, + htmlBody: string, + ): Promise; + public sendHtmlEmote( + roomId: string, + threadId: string | null, + body: string, + htmlBody?: string, + ): Promise { + 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 { + // 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( + 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 = { + 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[] = []; + + const doLeave = (roomId: string): Promise => { + 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. This method is experimental and + * may change. + * @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 { + 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 { + 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 same + * Promise will be returned. If there was a problem requesting scrollback, there + * will be a small delay before another request can be made (to prevent tight-looping + * when there is no connection). + * + * @param room - 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 { + let timeToWaitMs = 0; + + let info = this.ongoingScrollbacks[room.roomId] || {}; + if (info.promise) { + return info.promise; + } else if (info.errorTs) { + const timeWaitedMs = Date.now() - info.errorTs; + timeToWaitMs = Math.max(SCROLLBACK_DELAY_MS - timeWaitedMs, 0); + } + + if (room.oldState.paginationToken === null) { + return Promise.resolve(room); // already at the start. + } + // attempt to grab more events from the store first + const numAdded = this.store.scrollback(room, limit).length; + if (numAdded === limit) { + // store contained everything we needed. + return Promise.resolve(room); + } + // reduce the required number of events appropriately + limit = limit - numAdded; + + const promise = new Promise((resolve, reject) => { + // wait for a time before doing this request + // (which may be 0 in order not to special case the code paths) + 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 + * + *

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> { + // 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 | 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(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 { + 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 = { + 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(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 = 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> { + // 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 = { + dir: "b", + }; + if (this.clientOpts?.lazyLoadMembers) { + params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER); + } + + const res = await this.http.authedRequest(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 { + const path = utils.encodeUri("/rooms/$roomId/messages", { $roomId: roomId }); + + const params: Record = { + 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 { + const path = utils.encodeUri("/rooms/$roomId/threads", { $roomId: roomId }); + + const params: Record = { + 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(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 { + 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; + let promise: Promise; + + if (isNotifTimeline) { + path = "/notifications"; + params = { + limit: (opts.limit ?? 30).toString(), + only: "highlight", + }; + + if (token && token !== "end") { + params.from = token; + } + + promise = this.http + .authedRequest(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 { + 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 { + const writePromise = this.sendStateEvent( + roomId, + EventType.RoomGuestAccess, + { + guest_access: opts.allowJoin ? "can_join" : "forbidden", + }, + "", + ); + + let readPromise: Promise = Promise.resolve(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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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( + endpoint: string, + params: Record, + ): Promise { + 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 | undefined { + let promise: Promise | 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((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 { + 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 { + // 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(searchResults: T): Promise { + // 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; + } + + 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(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(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 { + // 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 { + const path = utils.encodeUri("/user/$userId/filter", { + $userId: this.credentials.userId!, + }); + return this.http.authedRequest(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 { + 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(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 { + 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 ((error).errcode !== "M_UNKNOWN" && (error).errcode !== "M_NOT_FOUND") { + throw error; + } + } + // if the filter doesn't exist anymore on the server, remove from store + if (!existingId) { + this.store.setFilterIdByName(filterName, undefined); + } + } + + if (existingId) { + return existingId; + } + + // create a new filter + const createdFilter = await this.createFilter(filter.getDefinition()); + + this.store.setFilterIdByName(filterName, createdFilter.filterId); + return createdFilter.filterId!; + } + + /** + * Gets a bearer token from the homeserver that the user can + * present to a third party in order to prove their ownership + * of the Matrix account they are logged into. + * @returns Promise which resolves: Token object + * @returns Rejects: with an error response. + */ + public getOpenIdToken(): Promise { + 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 { + 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 { + 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 ((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, err, true); // fatal + } else { + // otherwise, if we failed for whatever reason, try again the next time we're called. + this.emit(ClientEvent.TurnServersError, err, false); // non-fatal + } + } + } + + return credentialsGood; + } + + /** + * Set whether to allow a fallback ICE server should be used for negotiating a + * WebRTC connection if the homeserver doesn't provide any servers. Defaults to + * false. + * + */ + 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. This function is implementation specific and may change + * as a result. + * @returns true if the user appears to be a Synapse administrator. + */ + public isSynapseAdministrator(): Promise { + 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. + * This function is implementation specific and may change as a + * result. + * @param userId - the User ID to look up. + * @returns the whois response - see Synapse docs for information. + */ + public whoisSynapseUser(userId: string): Promise { + 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. This + * function is implementation specific and may change as a result. + * @param userId - the User ID to deactivate. + * @returns the deactivate response - see Synapse docs for information. + */ + public deactivateSynapseUser(userId: string): Promise { + 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 { + // `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 { + 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 { + // XXX: Intended private, used in code + const primTypes = ["boolean", "string", "number"]; + const serializableOpts = Object.entries(this.clientOpts!) + .filter(([key, value]) => { + return primTypes.includes(typeof value); + }) + .reduce>((obj, [key, value]) => { + obj[key] = value; + return obj; + }, {}); + return this.store.storeClientOptions(serializableOpts); + } + + /** + * Gets a set of room IDs in common with another user + * @param userId - The userId to check. + * @returns Promise which resolves to a set of rooms + * @returns Rejects: with an error response. + */ + // eslint-disable-next-line + public async _unstable_getSharedRooms(userId: string): Promise { + 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 { + if (this.serverVersionsPromise) { + return this.serverVersionsPromise; + } + + this.serverVersionsPromise = this.http + .request( + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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. This + * method is experimental and may change. + * @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 { + 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 { + 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 { + // 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 { + // 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 { + 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 { + 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 { + 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 { + // 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 { + // 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 { + // 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 { + // 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> { + 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 { + 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 { + 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> { + 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 = {}; + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + 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): 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 { + // 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//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//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 { + 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 { + 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 { + 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 { + return this.http.authedRequest(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, + body: Pick, + ): 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): 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 { + 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 { + return this.http.authedRequest(Method.Post, "/keys/upload", undefined, content); + } + + public uploadKeySignatures(content: KeySignatures): Promise { + 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 { + 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 { + const queries: Record> = {}; + + 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 { + const params: Record = { + client_secret: clientSecret, + email: email, + send_attempt: sendAttempt?.toString(), + }; + if (nextLink) { + params.next_link = nextLink; + } + + return this.http.idServerRequest( + 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 { + const params: Record = { + client_secret: clientSecret, + country: phoneCountry, + phone_number: phoneNumber, + send_attempt: sendAttempt?.toString(), + }; + if (nextLink) { + params.next_link = nextLink; + } + + return this.http.idServerRequest( + 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 { + // 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 { + // 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 { + // 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 = { + // 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 = { + // 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 { + // 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 { + // 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 { + // 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(); + + 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 { + 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>(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 { + 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 { + // 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 { + // 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 { + 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(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(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 { + 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 { + const qps: Record = {}; + 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(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 { + 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 { + 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 { + 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 ( + (err).errcode === "M_UNRECOGNIZED" && + // XXX: The 400 status code check should be removed in the future + // when Synapse is compliant with MSC3743. + ((err).httpStatus === 400 || + // This the correct standard status code for an unsupported + // endpoint according to MSC3743. Not Found and Method Not Allowed + // both indicate that this endpoint+verb combination is + // not supported. + (err).httpStatus === 404 || + (err).httpStatus === 405) + ) { + return await this.http.authedRequest(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 new file mode 100644 index 0000000..a0b4621 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/common-crypto/CryptoBackend.ts @@ -0,0 +1,170 @@ +/* +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; + + /** + * 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; + + /** + * 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; + + /** + * 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): Promise; + + /** + * 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): Promise; + + /** + * 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; + + /** + * 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 new file mode 100644 index 0000000..7af3298 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/common-crypto/README.md @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000..6790886 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/content-helpers.ts @@ -0,0 +1,288 @@ +/* +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(wireEventContent); + const asset = M_ASSET.findIn(wireEventContent); + const timestamp = M_TIMESTAMP.findIn(wireEventContent); + const text = M_TEXT.findIn(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(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(content) ?? undefined; + const asset = M_ASSET.findIn(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 & { + uri?: string; // override from MLocationContent to allow optionals + timestamp?: number; +}; + +export const parseBeaconContent = (content: MBeaconEventContent): BeaconLocationState => { + const location = M_LOCATION.findIn(content); + const timestamp = M_TIMESTAMP.findIn(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 new file mode 100644 index 0000000..2575412 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/content-repo.ts @@ -0,0 +1,79 @@ +/* +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 = {}; + + 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 new file mode 100644 index 0000000..50617c9 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto-api.ts @@ -0,0 +1,75 @@ +/* +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; + + /** + * 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; + + /** + * 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; +} 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 new file mode 100644 index 0000000..31ed2d4 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/CrossSigning.ts @@ -0,0 +1,803 @@ +/* +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; + storeCrossSigningKeyCache?(type: string, key?: Uint8Array): Promise; +} + +export interface ICrossSigningInfo { + keys: Record; + firstUse: boolean; + crossSigningVerifiedBefore: boolean; +} + +export class CrossSigningInfo { + public keys: Record = {}; + 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, + ): Promise | 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): 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, + secretStorage: SecretStorage, + ): Promise { + 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 { + 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 { + 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> { + 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 { + 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 = {}; + const keys: Record = {}; + 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): void { + const signingKeys: Record = {}; + 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(data: T, type: string): Promise { + 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 { + 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 { + if (userId !== this.userId) { + throw new Error(`Trying to sign ${userId}'s device; can only sign our own device`); + } + if (!this.keys.self_signing) { + logger.info("No self signing key: not signing device"); + return; + } + return this.signObject>( + { + algorithms: device.algorithms, + keys: device.keys, + device_id: device.deviceId, + user_id: userId, + }, + "self_signing", + ); + } + + /** + * Check whether a given user is trusted. + * + * @param 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; + 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 { + const key = await new Promise((resolve) => { + return store.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + store.getSecretStorePrivateKey(txn, resolve, type); + }); + }); + + if (key && key.ciphertext) { + const pickleKey = Buffer.from(olmDevice.pickleKey); + const decrypted = await decryptAES(key, pickleKey, type); + return decodeBase64(decrypted); + } else { + return key; + } + }, + storeCrossSigningKeyCache: async function ( + type: keyof SecretStorePrivateKeys, + key?: Uint8Array, + ): Promise { + 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 { + // If this is a self-verification, ask the other party for keys + if (baseApis.getUserId() !== userId) { + return; + } + logger.log("Cross-signing: Self-verification done; requesting keys"); + // This happens asynchronously, and we're not concerned about waiting for + // it. We return here in order to test. + return new Promise((resolve, reject) => { + const client = baseApis; + const original = client.crypto!.crossSigningInfo; + + // We already have all of the infrastructure we need to validate and + // cache cross-signing keys, so instead of replicating that, here we set + // up callbacks that request them from the other device and call + // CrossSigningInfo.getCrossSigningKey() to validate/cache + const crossSigning = new CrossSigningInfo( + original.userId, + { + getCrossSigningKey: async (type): Promise => { + 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((resolve) => { + setTimeout(resolve, KEY_REQUEST_TIMEOUT_MS, new Error("Timeout")); + }); + + // also request and cache the key backup key + const backupKeyPromise = (async (): Promise => { + 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([ + Promise.all([ + crossSigning.getCrossSigningKey("master"), + crossSigning.getCrossSigningKey("self_signing"), + crossSigning.getCrossSigningKey("user_signing"), + backupKeyPromise, + ]) as Promise, + 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 new file mode 100644 index 0000000..a1ff0eb --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/DeviceList.ts @@ -0,0 +1,989 @@ +/* +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>; + +type EmittedEvents = CryptoEvent.WillUpdateDevices | CryptoEvent.DevicesUpdated | CryptoEvent.UserCrossSigningUpdated; + +export class DeviceList extends TypedEventEmitter { + 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 = {}; + + // 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>(); + + // Set whenever changes are made other than setting the sync token + private dirty = false; + + // Promise resolved when device data is saved + private savePromise: Promise | 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 | 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 { + 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 { + 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 { + const usersToDownload: string[] = []; + const promises: Promise[] = []; + + 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 { + 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): 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 { + 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): 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 { + 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 = {}; + + // deferred which is resolved when the queued users are downloaded. + // non-null indicates that we have users queued for download. + private queuedQueryDeferred?: IDeferred; + + 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 { + 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 { + 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[1] = {}; + if (this.syncToken) { + opts.token = this.syncToken; + } + + const factories: Array<() => Promise> = []; + 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 { + 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 = {}; + 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 = {}; + 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, + userResult: IDownloadKeyResult["device_keys"]["user_id"], + localUserId: string, + localDeviceId: string, +): Promise { + 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, + deviceResult: IDownloadKeyResult["device_keys"]["user_id"]["device_id"], +): Promise { + 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 new file mode 100644 index 0000000..4efe677 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/EncryptionSetup.ts @@ -0,0 +1,356 @@ +/* +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, 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 { + 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 { + // 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, + 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 { + const baseApis = crypto.baseApis; + // upload cross-signing keys + if (this.crossSigningKeys) { + const keys: Partial = {}; + 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 + implements IAccountDataClient +{ + // + public readonly values = new Map(); + + /** + * @param existingValues - existing account data + */ + public constructor(private readonly existingValues: Map) { + super(); + } + + /** + * @returns the content of the account data + */ + public getAccountDataFromServer(type: string): Promise { + 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(); + + // cache callbacks + public getCrossSigningKeyCache(type: string, expectedPublicKey: string): Promise { + return this.getCrossSigningKey(type, expectedPublicKey); + } + + public storeCrossSigningKeyCache(type: string, key: Uint8Array): Promise { + this.privateKeys.set(type, key); + return Promise.resolve(); + } + + // non-cache callbacks + public getCrossSigningKey(type: string, expectedPubkey: string): Promise { + return Promise.resolve(this.privateKeys.get(type) ?? null); + } + + public saveCrossSigningKeys(privateKeys: Record): 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(); + + public constructor(private readonly delegateCryptoCallbacks?: ICryptoCallbacks) {} + + public async getSecretStorageKey( + { keys }: { keys: Record }, + 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 new file mode 100644 index 0000000..82a0a9a --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OlmDevice.ts @@ -0,0 +1,1496 @@ +/* +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; + /** 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; + 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 { + 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 = {}; + + // Store a set of decrypted message indexes for each group session. + // This partially mitigates a replay attack where a MITM resends a group + // message into the room. + // + // When we decrypt a message and the message index matches a previously + // decrypted message, one possible cause of that is that we are decrypting + // the same event, and may not indicate an actual replay attack. For + // example, this could happen if we receive events, forget about them, and + // then re-fetch them when we backfill. So we store the event ID and + // timestamp corresponding to each message index when we first decrypt it, + // and compare these against the event ID and timestamp every time we use + // that same index. If they match, then we're probably decrypting the same + // event and we don't consider it a replay attack. + // + // Keys are strings of form "||" + // Values are objects of the form "{id: , timestamp: }" + private inboundGroupSessionMessageIndexes: Record = {}; + + // 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> = {}; // set by consumers + + // Used by olm to serialise prekey message decryptions + public olmPrekeyPromise: Promise = 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 { + 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 { + 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 { + 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 { + const result: Partial = { + 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(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 { + 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 + * curve25519, which is itself an object mapping key id to Curve25519 + * key. + */ + public async getOneTimeKeys(): Promise { + 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 { + 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 { + 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 { + 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>> { + let result: Record>; + 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 { + 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 { + 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 { + 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 { + 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 { + const sessionInfos = await this.getSessionInfoForDevice(theirDeviceIdentityKey, nowait, log); + + if (sessionInfos.length === 0) { + return null; + } + // Use the session that has most recently received a message + let idxOfBest = 0; + for (let i = 1; i < sessionInfos.length; i++) { + const thisSessInfo = sessionInfos[i]; + const thisLastReceived = + thisSessInfo.lastReceivedMessageTs === undefined ? 0 : thisSessInfo.lastReceivedMessageTs; + + const bestSessInfo = sessionInfos[idxOfBest]; + const bestLastReceived = + bestSessInfo.lastReceivedMessageTs === undefined ? 0 : bestSessInfo.lastReceivedMessageTs; + if ( + thisLastReceived > bestLastReceived || + (thisLastReceived === bestLastReceived && thisSessInfo.sessionId < bestSessInfo.sessionId) + ) { + idxOfBest = i; + } + } + return sessionInfos[idxOfBest].sessionId; + } + + /** + * Get information on the active Olm sessions for a device. + *

+ * Returns an array, with an entry for each active session. The first entry in + * the result will be the one used for outgoing messages. Each entry contains + * the keys 'hasReceivedMessage' (true if the session has received an incoming + * message and is therefore past the pre-key stage), and 'sessionId'. + * + * @param 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 { + 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 { + 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 { + 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 { + 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 { + return this.cryptoStore.getEndToEndSessionProblem(deviceKey, timestamp); + } + + public filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise { + 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(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( + 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, + exportFormat: boolean, + extraSessionData: OlmGroupSessionExtraData = {}, + ): Promise { + 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 { + 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 { + 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; + try { + res = session.decrypt(body); + } catch (e) { + if ((e)?.message === "OLM.UNKNOWN_MESSAGE_INDEX" && withheld) { + error = new algorithms.DecryptionError( + "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", + calculateWithheldMessage(withheld), + { + session: senderKey + "|" + sessionId, + }, + ); + } else { + error = e; + } + return; + } + + let plaintext: 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 { + 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 { + 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 = { + "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 new file mode 100644 index 0000000..4628b3e --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OutgoingRoomKeyRequestManager.ts @@ -0,0 +1,485 @@ +/* +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; + + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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>>(() => 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 new file mode 100644 index 0000000..a73efcd --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/RoomList.ts @@ -0,0 +1,63 @@ +/* +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 = {}; + + public constructor(private readonly cryptoStore?: CryptoStore) {} + + public async init(): Promise { + 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 { + // 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 new file mode 100644 index 0000000..5c9049f --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/SecretStorage.ts @@ -0,0 +1,583 @@ +/* +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; + cancel: (reason: string) => void; +} + +export interface IAccountDataClient extends TypedEventEmitter { + // Subset of MatrixClient (which also uses any for the event content) + getAccountDataFromServer: (eventType: string) => Promise; + getAccountData: (eventType: string) => IContent | null; + setAccountData: (eventType: string, content: any) => Promise<{}>; +} + +interface ISecretRequestInternal { + name: string; + devices: string[]; + deferred: IDeferred; +} + +interface IDecryptors { + encrypt: (plaintext: string) => Promise; + decrypt: (ciphertext: IEncryptedPayload) => Promise; +} + +interface ISecretInfo { + encrypted: { + [keyId: string]: IEncryptedPayload; + }; +} + +/** + * Implements Secure Secret Storage and Sharing (MSC1946) + */ +export class SecretStorage { + private requests = new Map(); + + // 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 { + 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 { + return new Promise((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 { + 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( + `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 { + if (!keyId) { + keyId = await this.getDefaultKeyId(); + } + if (!keyId) { + return null; + } + + const keyInfo = await this.accountDataAdapter.getAccountDataFromServer( + "m.secret_storage.key." + keyId, + ); + return keyInfo ? [keyId, keyInfo] : null; + } + + /** + * Check whether we have a key with a given ID. + * + * @param keyId - The ID of the key to check + * for. Defaults to the default key ID if not provided. + * @returns Whether we have the key. + */ + public async hasKey(keyId?: string): Promise { + 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 { + 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 { + const encrypted: Record = {}; + + if (!keys) { + const defaultKeyId = await this.getDefaultKeyId(); + if (!defaultKeyId) { + throw new Error("No keys specified and no default key present"); + } + keys = [defaultKeyId]; + } + + if (keys.length === 0) { + throw new Error("Zero keys given to encrypt with!"); + } + + for (const keyId of keys) { + // get key information from key storage + const keyInfo = await this.accountDataAdapter.getAccountDataFromServer( + "m.secret_storage.key." + keyId, + ); + if (!keyInfo) { + throw new Error("Unknown key: " + keyId); + } + + // encrypt secret, based on the algorithm + if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { + const keys = { [keyId]: keyInfo }; + const [, encryption] = await this.getSecretStorageKey(keys, name); + encrypted[keyId] = await encryption.encrypt(secret); + } else { + logger.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 { + const secretInfo = await this.accountDataAdapter.getAccountDataFromServer(name); + if (!secretInfo) { + return; + } + if (!secretInfo.encrypted) { + throw new Error("Content is not encrypted!"); + } + + // get possible keys to decrypt + const keys: Record = {}; + for (const keyId of Object.keys(secretInfo.encrypted)) { + // get key information from key storage + const keyInfo = await this.accountDataAdapter.getAccountDataFromServer( + "m.secret_storage.key." + keyId, + ); + const encInfo = secretInfo.encrypted[keyId]; + // only use keys we understand the encryption algorithm of + if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { + if (encInfo.iv && encInfo.ciphertext && encInfo.mac) { + keys[keyId] = keyInfo; + } + } + } + + if (Object.keys(keys).length === 0) { + throw new Error( + `Could not decrypt ${name} because none of ` + + `the keys it is encrypted with are for a supported algorithm`, + ); + } + + // fetch private key from app + const [keyId, decryption] = await this.getSecretStorageKey(keys, name); + const encInfo = secretInfo.encrypted[keyId]; + + return decryption.decrypt(encInfo); + } + + /** + * Check if a secret is stored on the server. + * + * @param name - the name of the secret + * + * @returns map of key name to key info the secret is encrypted + * with, or null if it is not present or not encrypted with a trusted + * key + */ + public async isStored(name: string): Promise | null> { + // check if secret exists + const secretInfo = await this.accountDataAdapter.getAccountDataFromServer(name); + if (!secretInfo?.encrypted) return null; + + const ret: Record = {}; + + // filter secret encryption keys with supported algorithm + for (const keyId of Object.keys(secretInfo.encrypted)) { + // get key information from key storage + const keyInfo = await this.accountDataAdapter.getAccountDataFromServer( + "m.secret_storage.key." + keyId, + ); + if (!keyInfo) continue; + const encInfo = secretInfo.encrypted[keyId]; + + // only use keys we understand the encryption algorithm of + if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { + if (encInfo.iv && encInfo.ciphertext && encInfo.mac) { + ret[keyId] = keyInfo; + } + } + } + return Object.keys(ret).length ? ret : null; + } + + /** + * 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, name: string, devices: string[]): ISecretRequest { + const requestId = this.baseApis.makeTxnId(); + + const deferred = defer(); + 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 = 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 = 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, event: MatrixEvent): Promise { + 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, 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, + 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 { + return encryptAES(secret, privateKey, name); + }, + decrypt: function (encInfo: IEncryptedPayload): Promise { + 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 new file mode 100644 index 0000000..48470af --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/aes.ts @@ -0,0 +1,157 @@ +/* +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 { + 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 { + 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 { + 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 new file mode 100644 index 0000000..6473009 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/base.ts @@ -0,0 +1,268 @@ +/* +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 EncryptionAlgorithm>(); + +export type DecryptionClassParams

= Omit; + +/** + * map of registered encryption algorithm classes. Map from string to {@link DecryptionAlgorithm} class + */ +export const DECRYPTION_CLASSES = new Map 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; + + /** + * 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; + + 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; + + /** + * Handle a key event + * + * @param params - event key event + */ + public async onRoomKeyEvent(params: MatrixEvent): Promise { + // ignore by default + } + + /** + * Import a room key + * + * @param opts - object + */ + public async importRoomKey(session: IMegolmSessionData, opts: object): Promise { + // 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 { + 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 { + // ignore by default + return false; + } + + public onRoomKeyWithheldEvent?(event: MatrixEvent): Promise; + public sendSharedHistoryInboundSessions?(devicesByUser: Map): Promise; +} + +/** + * 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) { + super(msg); + this.code = code; + this.name = "DecryptionError"; + this.detailedString = detailedStringForDecryptionError(this, details); + } +} + +function detailedStringForDecryptionError(err: DecryptionError, details?: Record): 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

( + algorithm: string, + encryptor: new (params: P) => EncryptionAlgorithm, + decryptor: new (params: DecryptionClassParams

) => 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 new file mode 100644 index 0000000..b3c5b0e --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/index.ts @@ -0,0 +1,20 @@ +/* +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 new file mode 100644 index 0000000..061e169 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/megolm.ts @@ -0,0 +1,2208 @@ +/* +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>; + +export interface IOlmDevice { + 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>; + 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 { + 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> = new MapWithDefault(() => new Map()); + public blockedDevicesNotified: MapWithDefault> = 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(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 = {}; + + private readonly sessionRotationPeriodMsgs: number; + private readonly sessionRotationPeriodMs: number; + private encryptionPreparation?: { + promise: Promise; + startTime: number; + cancel: () => void; + }; + + protected readonly roomId: string; + private readonly prefixedLogger: PrefixedLogger; + + public constructor(params: IParams & Required>) { + 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 { + // 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 => { + 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 { + // 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 { + // now check if we need to share with any devices + const shareMap: Record = {}; + + 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 => { + // 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 => { + 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 => { + // 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 = 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 => { + 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> = 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 { + 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>, + devicesByUser: Map, + 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( + devicesByUser: Map>, + ): IOlmDevice[][] { + const maxDevicesPerRequest = 20; + + // use an array where the slices of a content map gets stored + let currentSlice: IOlmDevice[] = []; + 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 { + 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[], + payload: IPayload, + ): Promise { + const contentMap: MapWithDefault> = 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 { + 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, + errorDevices: IOlmDevice[], + otkTimeout: number, + failedServers?: string[], + ): Promise { + 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>, + ): Promise { + 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 { + 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> = 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>, + ): Promise { + 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 => { + 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 { + 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> = 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; + private async getDevicesInRoom( + room: Room, + forceDistributeToUnverified = false, + isCancelled?: () => boolean, + ): Promise { + 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>(() => 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>>(); + + // this gets stubbed out by the unit tests. + private olmlib = olmlib; + + protected readonly roomId: string; + private readonly prefixedLogger: PrefixedLogger; + + public constructor(params: DecryptionClassParams>>) { + 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 { + 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 ((e).name === "DecryptionError") { + // re-throw decryption errors as-is + throw e; + } + + let errorCode = "OLM_DECRYPT_GROUP_MESSAGE_ERROR"; + + if ((e)?.message === "OLM.UNKNOWN_MESSAGE_INDEX") { + this.requestKeysForEvent(event); + + errorCode = "OLM_UNKNOWN_MESSAGE_INDEX"; + } + + throw new 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>()); + } + 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>(); + 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>(); + + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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): Promise { + 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[] = []; + const contentMap: Map> = 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 new file mode 100644 index 0000000..1a79554 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/olm.ts @@ -0,0 +1,329 @@ +/* +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 | 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 { + 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 { + // 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[] = []; + + 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 { + 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 { + // 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 { + const sessionIds = await this.olmDevice.getSessionIdsForDevice(theirDeviceIdentityKey); + + // try each session in turn. + const decryptionErrors: Record = {}; + 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 + + ": " + + (e).message, + ); + } + + // otherwise it's probably a message for another session; carry on, but + // keep a record of the error + decryptionErrors[sessionId] = (e).message; + } + } + + if (message.type !== 0) { + // not a prekey message, so it should have matched an existing session, but it + // didn't work. + + if (sessionIds.length === 0) { + throw new Error("No existing sessions"); + } + + throw new Error( + "Error decrypting non-prekey message with existing sessions: " + JSON.stringify(decryptionErrors), + ); + } + + // prekey message which doesn't match any existing sessions: make a new + // session. + + let res: IInboundSession; + try { + res = await this.olmDevice.createInboundSession(theirDeviceIdentityKey, message.type, message.body); + } catch (e) { + decryptionErrors["(new)"] = (e).message; + throw new Error("Error decrypting prekey message: " + JSON.stringify(decryptionErrors)); + } + + logger.log("created new inbound Olm session ID " + res.session_id + " with " + theirDeviceIdentityKey); + return res.payload; + } +} + +registerAlgorithm(olmlib.OLM_ALGORITHM, OlmEncryption, OlmDecryption); 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 new file mode 100644 index 0000000..9e9ba52 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/api.ts @@ -0,0 +1,127 @@ +/* +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; + + /** + * 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; +} + +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 new file mode 100644 index 0000000..d240bda --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/backup.ts @@ -0,0 +1,813 @@ +/* +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>; + +interface BackupAlgorithmClass { + algorithmName: string; + // initialize from an existing backup + init(authData: AuthData, getKey: GetKey): Promise; + + // prepare a brand new backup + prepare(key?: string | Uint8Array | null): Promise<[Uint8Array, AuthData]>; + + checkBackupVersion(info: IKeyBackupInfo): void; +} + +interface BackupAlgorithm { + untrusted: boolean; + encryptSession(data: Record): Promise; + decryptSessions(ciphertexts: Record): Promise; + authData: AuthData; + keyMatches(key: ArrayLike): Promise; + 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 = {}; // 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 { + 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 { + 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 { + 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 { + 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 { + 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 ((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 { + 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 { + 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 { + 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 { + 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 ((err).data) { + if ( + (err).data.errcode == "M_NOT_FOUND" || + (err).data.errcode == "M_WRONG_ROOM_KEYS_VERSION" + ) { + // Re-check key backup status on error, so we can be + // sure to present the current situation when asked. + await this.checkKeyBackup(); + // Backup version has changed or this backup version + // has been deleted + this.baseApis.crypto!.emit(CryptoEvent.KeyBackupFailed, (err).data.errcode!); + throw err; + } + } + } + if (numFailures) { + // exponential backoff if we have failures + await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4))); + } + } + } finally { + this.sendingBackups = false; + } + } + + /** + * Take some e2e keys waiting to be backed up and send them + * to the backup. + * + * @param limit - Maximum number of keys to back up + * @returns Number of sessions backed up + */ + public async backupPendingKeys(limit: number): Promise { + 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 { + 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 { + 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 { + 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 { + 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, + ) {} + + public static async init(authData: AuthData, getKey: () => Promise): Promise { + 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 = {}; + 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): Promise { + const plainText: Record = 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>, + ): Promise { + 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 { + 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): Promise { + 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 = {}; + 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): Promise { + const plainText: Record = 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>, + ): Promise { + 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 { + 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 = { + [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 new file mode 100644 index 0000000..704754f --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/crypto.ts @@ -0,0 +1,50 @@ +/* +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 new file mode 100644 index 0000000..373b236 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/dehydration.ts @@ -0,0 +1,271 @@ +/* +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 { + 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 { + 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 { + 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 { + if (this.inProgress) { + logger.log("Dehydration already in progress -- not starting new dehydration"); + return; + } + this.inProgress = true; + if (this.timeoutId) { + global.clearTimeout(this.timeoutId); + this.timeoutId = undefined; + } + try { + const pickleKey = Buffer.from(this.crypto.olmDevice.pickleKey); + + // update the crypto store with the timestamp + const key = await encryptAES(encodeBase64(this.key!), pickleKey, DEHYDRATION_ALGORITHM); + await this.crypto.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.crypto.cryptoStore.storeSecretStorePrivateKey(txn, "dehydration", { + keyInfo: this.keyInfo, + key, + deviceDisplayName: this.deviceDisplayName!, + time: Date.now(), + }); + }); + logger.log("Attempting to dehydrate device"); + + logger.log("Creating account"); + // create the account and all the necessary keys + const account = new global.Olm.Account(); + account.create(); + const e2eKeys = JSON.parse(account.identity_keys()); + + const maxKeys = account.max_number_of_one_time_keys(); + // FIXME: generate in small batches? + account.generate_one_time_keys(maxKeys / 2); + account.generate_fallback_key(); + const otks: Record = JSON.parse(account.one_time_keys()); + const fallbacks: Record = JSON.parse(account.fallback_key()); + account.mark_keys_as_published(); + + // dehydrate the account and store it on the server + const pickledAccount = account.pickle(new Uint8Array(this.key!)); + + const deviceData: { [props: string]: any } = { + algorithm: DEHYDRATION_ALGORITHM, + account: pickledAccount, + }; + if (this.keyInfo!.passphrase) { + deviceData.passphrase = this.keyInfo!.passphrase; + } + + logger.log("Uploading account to server"); + // 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 = {}; + 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 = {}; + 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 new file mode 100644 index 0000000..b4bb4fd --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/deviceinfo.ts @@ -0,0 +1,161 @@ +/* +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; + algorithms: string[]; + verified: DeviceVerification; + known: boolean; + unsigned?: Record; + 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, 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 `: -> ` */ + public keys: Record = {}; + /** 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 = {}; + 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 new file mode 100644 index 0000000..68df6ca --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/index.ts @@ -0,0 +1,3936 @@ +/* +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; +} + +export interface ICryptoCallbacks { + getCrossSigningKey?: (keyType: string, pubKey: string) => Promise; + saveCrossSigningKeys?: (keys: Record) => void; + shouldUpgradeDeviceVerifications?: (users: Record) => Promise; + getSecretStorageKey?: ( + keys: { keys: Record }, + 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; + getDehydrationKey?: ( + keyInfo: SecretStorageKeyDescription, + checkFunc: (key: Uint8Array) => void, + ) => Promise; + getBackupKey?: () => Promise; +} + +/* 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; + [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; + /** + * Fires when a key verification is requested. + */ + [CryptoEvent.VerificationRequest]: (request: VerificationRequest) => 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 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; + private readonly verificationMethods: Map; + 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(); + // map from algorithm to DecryptionAlgorithm instance, for each room + private roomDecryptors = new Map>(); + + private deviceKeys: Record = {}; // 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 } = {}; + + // 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> = 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; + + /** + * 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, + ) { + 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, + 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 => { + 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 { + 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 { + const decryption = new global.Olm.PkDecryption(); + try { + const keyInfo: Partial = {}; + 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 { + 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 { + 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 { + 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 { + 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 => { + 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 => ({} as IRecoveryKey), + keyBackupInfo, + setupNewKeyBackup, + setupNewSecretStorage, + getKeyBackupPassphrase, + }: ICreateSecretStorageOpts = {}): Promise { + 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 => { + 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 => { + 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 => { + 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 { + return this.secretStorage.addKey(algorithm, opts, keyID); + } + + public hasSecretStorageKey(keyID?: string): Promise { + return this.secretStorage.hasKey(keyID); + } + + public getSecretStorageKey(keyID?: string): Promise { + return this.secretStorage.getKey(keyID); + } + + public storeSecret(name: string, secret: string, keys?: string[]): Promise { + return this.secretStorage.store(name, secret, keys); + } + + public getSecret(name: string): Promise { + return this.secretStorage.get(name); + } + + public isSecretStored(name: string): Promise | 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 { + return this.secretStorage.getDefaultKeyId(); + } + + public setDefaultSecretStorageKeyId(k: string): Promise { + return this.secretStorage.setDefaultKeyId(k); + } + + public checkSecretStorageKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise { + 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 { + let key = await new Promise((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): Promise { + 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 { + 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 => { + 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 = {}; + 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 { + // 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, + ): Promise { + 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 => { + 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 { + 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 = {}; + + 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 => { + 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 | null): Promise { + 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 { + 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 { + 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, + }); + }); + } + + /** + * 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 => { + 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 { + const promises: Promise[] = []; + + let fallbackJson: Record | 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 = {}; + + 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 = { + 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 { + 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 | 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 { + 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, + ): Promise { + // 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 => { + 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 => { + 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 { + 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 { + 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 { + 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 { + 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 { + const transactionId = ToDeviceChannel.makeTransactionId(); + const channel = new ToDeviceChannel(this.baseApis, userId, [deviceId], transactionId, deviceId); + const request = new VerificationRequest(channel, this.verificationMethods, this.baseApis); + this.toDeviceVerificationRequests.setRequestBySenderAndTxnId(userId, transactionId, request); + const verifier = request.beginKeyVerification(method, { userId, deviceId }); + // either reject by an error from verify() while sending .start + // or resolve when the request receives the + // local (fake remote) echo for sending the .start event + await Promise.race([verifier.verify(), request.waitFor((r) => r.started)]); + return request; + } + + /** + * Get information on the active olm sessions with a user + *

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

+ * This method is provided for debugging purposes. + * + * @param userId - id of user to inspect + */ + public async getOlmSessionsForUser(userId: string): Promise> { + 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 = {}; + + 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 { + 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 { + 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 m.room.encryption 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 not 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 { + 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 | 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 { + 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 { + const roomId = room.roomId; + const trackMembers = async (): Promise => { + // 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 user Id → DeviceInfo[] + const devicesByUser: Map = 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 { + 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 { + 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 { + 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 { + 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 { + 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["device_lists"], + ): Promise { + // 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 { + 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 { + 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 { + const content = event.getContent(); + await this.setRoomEncryptionImpl(room, content); + } + + /** + * Called before the result of a sync is processed + * + * @param syncData - the data from the 'MatrixClient.sync' event + */ + public async onSyncWillProcess(syncData: ISyncStateData): Promise { + 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 { + 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["device_lists"]): Promise { + 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 { + 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[], payload: object): Promise { + 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 { + // 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): Promise { + const currentCount = oneTimeKeysCounts.get("signed_curve25519") || 0; + this.updateOneTimeKeyCount(currentCount); + return Promise.resolve(); + } + + public preprocessUnusedFallbackKeys(unusedFallbackKeys: Set): Promise { + 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 { + // 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((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 { + 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 { + 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 { + 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 { + 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 | undefined; + let alg: DecryptionAlgorithm | undefined; + + if (roomId) { + decryptors = this.roomDecryptors.get(roomId); + if (!decryptors) { + decryptors = new Map(); + this.roomDecryptors.set(roomId, decryptors); + } + + alg = decryptors.get(algorithm); + if (alg) { + return alg; + } + } + + const AlgClass = algorithms.DECRYPTION_CLASSES.get(algorithm); + if (!AlgClass) { + throw new algorithms.DecryptionError( + "UNKNOWN_ENCRYPTION_ALGORITHM", + 'Unknown encryption algorithm "' + algorithm + '".', + ); + } + alg = new AlgClass({ + userId: this.userId, + crypto: this, + olmDevice: this.olmDevice, + baseApis: this.baseApis, + roomId: roomId ?? undefined, + }); + + if (decryptors) { + decryptors.set(algorithm, alg); + } + return alg; + } + + /** + * Get all the room decryptors for a given encryption algorithm. + * + * @param algorithm - The encryption algorithm + * + * @returns An array of room decryptors + */ + 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(obj: T): Promise { + 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 new file mode 100644 index 0000000..f6fe7b6 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/key_passphrase.ts @@ -0,0 +1,93 @@ +/* +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 { + 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 { + 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 { + 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 new file mode 100644 index 0000000..67e213c --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/keybackup.ts @@ -0,0 +1,77 @@ +/* +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 { + 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 new file mode 100644 index 0000000..c37b7f0 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/olmlib.ts @@ -0,0 +1,566 @@ +/* +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, + ourUserId: string, + ourDeviceId: string | undefined, + olmDevice: OlmDevice, + recipientUserId: string, + recipientDevice: DeviceInfo, + payloadFields: Record, +): Promise { + 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, +): Promise<[Map, Map>]> { + // map user Id → DeviceInfo[] + const devicesWithoutSession: MapWithDefault = new MapWithDefault(() => []); + // map user Id → device Id → IExistingOlmSession + const sessions: MapWithDefault> = new MapWithDefault(() => new Map()); + + const promises: Promise[] = []; + + 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 => { + 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, + force = false, + otkTimeout?: number, + failedServers?: string[], + log = logger, +): Promise>> { + const devicesWithoutSession: [string, string][] = [ + // [userId, deviceId], ... + ]; + // map user Id → device Id → IExistingOlmSession + const result: Map> = new Map(); + // map device key → resolve session fn + const resolveSession: Map 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[] = []; + 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 { + 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 { + 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 new file mode 100644 index 0000000..4107b76 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/recoverykey.ts @@ -0,0 +1,62 @@ +/* +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): 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 new file mode 100644 index 0000000..4c88ec2 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/base.ts @@ -0,0 +1,226 @@ +/* +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; + deleteAllData(): Promise; + getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise; + getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise; + getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise; + getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise; + getOutgoingRoomKeyRequestsByTarget( + userId: string, + deviceId: string, + wantedStates: number[], + ): Promise; + updateOutgoingRoomKeyRequest( + requestId: string, + expectedState: number, + updates: Partial, + ): Promise; + deleteOutgoingRoomKeyRequest(requestId: string, expectedState: number): Promise; + + // Olm Account + getAccount(txn: unknown, func: (accountPickle: string | null) => void): void; + storeAccount(txn: unknown, accountPickle: string): void; + getCrossSigningKeys(txn: unknown, func: (keys: Record | null) => void): void; + getSecretStorePrivateKey( + txn: unknown, + func: (key: SecretStorePrivateKeys[K] | null) => void, + type: K, + ): void; + storeCrossSigningKeys(txn: unknown, keys: Record): void; + storeSecretStorePrivateKey( + 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; + getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise; + filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise; + + // 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) => void): void; + getSessionsNeedingBackup(limit: number): Promise; + countSessionsNeedingBackup(txn?: unknown): Promise; + unmarkSessionsNeedingBackup(sessions: ISession[], txn?: unknown): Promise; + markSessionsNeedingBackup(sessions: ISession[], txn?: unknown): Promise; + 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; + + // Session key backups + doTxn(mode: Mode, stores: Iterable, func: (txn: unknown) => T, log?: PrefixedLogger): Promise; +} + +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; + 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; // 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 new file mode 100644 index 0000000..7827697 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store-backend.ts @@ -0,0 +1,1062 @@ +/* +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 { + // 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 { + 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 { + 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 { + 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 { + 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): 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 { + 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 { + let stateIndex = 0; + const results: OutgoingRoomKeyRequest[] = []; + + function onsuccess(this: IDBRequest): 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, + ): Promise { + let result: OutgoingRoomKeyRequest | null = null; + + function onsuccess(this: IDBRequest): 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 { + 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(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, e); + } + }; + } + + public storeAccount(txn: IDBTransaction, accountPickle: string): void { + const objectStore = txn.objectStore("account"); + objectStore.put(accountPickle, "-"); + } + + public getCrossSigningKeys( + txn: IDBTransaction, + func: (keys: Record | 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, e); + } + }; + } + + public getSecretStorePrivateKey( + 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, e); + } + }; + } + + public storeCrossSigningKeys(txn: IDBTransaction, keys: Record): void { + const objectStore = txn.objectStore("account"); + objectStore.put(keys, "crossSigningKeys"); + } + + public storeSecretStorePrivateKey( + 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, 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[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, 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, 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, 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 { + 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 { + 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 { + 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((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, 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, 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, e); + } + cursor.continue(); + } else { + try { + func(null); + } catch (e) { + abortWithException(txn, 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, 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) => void): void { + const rooms: Parameters[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, e); + } + } + }; + } + + // session backups + + public getSessionsNeedingBackup(limit: number): Promise { + 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 { + 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 { + 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 { + 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 { + 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( + mode: Mode, + stores: string | string[], + func: (txn: IDBTransaction) => T, + log: PrefixedLogger = logger, + ): Promise { + 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(txn: IDBTransaction): Promise { + 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 new file mode 100644 index 0000000..320235f --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store.ts @@ -0,0 +1,708 @@ +/* +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 { + return IndexedDBHelpers.exists(indexedDB, dbName); + } + + private backendPromise?: Promise; + 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 { + if (this.backendPromise) { + return this.backendPromise; + } + + this.backendPromise = new Promise((resolve, reject) => { + if (!this.indexedDB) { + reject(new Error("no indexeddb support available")); + return; + } + + logger.log(`connecting to indexeddb ${this.dbName}`); + + const req = this.indexedDB.open(this.dbName, IndexedDBCryptoStoreBackend.VERSION); + + req.onupgradeneeded = (ev): 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 { + return new Promise((resolve, reject) => { + if (!this.indexedDB) { + reject(new Error("no indexeddb support available")); + return; + } + + logger.log(`Removing indexeddb instance: ${this.dbName}`); + const req = this.indexedDB.deleteDatabase(this.dbName); + + req.onblocked = (): 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 { + 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 { + 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 { + 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 { + 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 { + 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, + ): Promise { + 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 { + 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 | 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( + 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): 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( + 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 { + return this.backend!.storeEndToEndSessionProblem(deviceKey, type, fixed); + } + + public getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise { + return this.backend!.getEndToEndSessionProblem(deviceKey, timestamp); + } + + public filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise { + 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) => 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 { + 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 { + 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 { + 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 { + 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 { + 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( + mode: Mode, + stores: Iterable, + func: (txn: IDBTransaction) => T, + log?: PrefixedLogger, + ): Promise { + return this.backend!.doTxn(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 new file mode 100644 index 0000000..5552540 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/localStorage-crypto-store.ts @@ -0,0 +1,403 @@ +/* +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 { + const sessions = getJsonItem(this.store, keyEndToEndSessions(deviceKey)); + const fixedSessions: Record = {}; + + // 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 { + const key = keyEndToEndSessionProblems(deviceKey); + const problems = getJsonItem(this.store, key) || []; + problems.push({ type, fixed, time: Date.now() }); + problems.sort((a, b) => { + return a.time - b.time; + }); + setJsonItem(this.store, key, problems); + } + + public async getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise { + const key = keyEndToEndSessionProblems(deviceKey); + const problems = getJsonItem(this.store, key) || []; + if (!problems.length) { + return null; + } + const lastProblem = problems[problems.length - 1]; + for (const problem of problems) { + if (problem.time > timestamp) { + return Object.assign({}, problem, { fixed: lastProblem.fixed }); + } + } + if (lastProblem.fixed) { + return null; + } else { + return lastProblem; + } + } + + public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise { + const notifiedErrorDevices = + getJsonItem(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) => void): void { + const result: Record = {}; + 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 { + const sessionsNeedingBackup = getJsonItem(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 { + const sessionsNeedingBackup = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; + return Promise.resolve(Object.keys(sessionsNeedingBackup).length); + } + + public unmarkSessionsNeedingBackup(sessions: ISession[]): Promise { + 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 { + 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 { + 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(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 | null) => void): void { + const keys = getJsonItem>(this.store, KEY_CROSS_SIGNING_KEYS); + func(keys); + } + + public getSecretStorePrivateKey( + txn: unknown, + func: (key: SecretStorePrivateKeys[K] | null) => void, + type: K, + ): void { + const key = getJsonItem(this.store, E2E_PREFIX + `ssss_cache.${type}`); + func(key); + } + + public storeCrossSigningKeys(txn: unknown, keys: Record): void { + setJsonItem(this.store, KEY_CROSS_SIGNING_KEYS, keys); + } + + public storeSecretStorePrivateKey( + txn: unknown, + type: K, + key: SecretStorePrivateKeys[K], + ): void { + setJsonItem(this.store, E2E_PREFIX + `ssss_cache.${type}`, key); + } + + public doTxn(mode: Mode, stores: Iterable, func: (txn: unknown) => T): Promise { + return Promise.resolve(func(null)); + } +} + +function getJsonItem(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, (e).message); + logger.log((e).stack); + } + return null; +} + +function setJsonItem(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 new file mode 100644 index 0000000..29ae81b --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/memory-crypto-store.ts @@ -0,0 +1,533 @@ +/* +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 | null = null; + private privateKeys: Partial = {}; + + 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 = {}; + // 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(); // 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + return Promise.resolve(this.outgoingRoomKeyRequests.filter((r) => r.state == wantedState)); + } + + public getOutgoingRoomKeyRequestsByTarget( + userId: string, + deviceId: string, + wantedStates: number[], + ): Promise { + 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, + ): Promise { + 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 { + 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 | null) => void): void { + func(this.crossSigningKeys); + } + + public getSecretStorePrivateKey( + 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): void { + this.crossSigningKeys = keys; + } + + public storeSecretStorePrivateKey( + 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 { + 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 { + 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 { + 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) => void): void { + func(this.rooms); + } + + public getSessionsNeedingBackup(limit: number): Promise { + 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 { + return Promise.resolve(Object.keys(this.sessionsNeedingBackup).length); + } + + public unmarkSessionsNeedingBackup(sessions: ISession[]): Promise { + for (const session of sessions) { + const sessionKey = session.senderKey + "/" + session.sessionId; + delete this.sessionsNeedingBackup[sessionKey]; + } + return Promise.resolve(); + } + + public markSessionsNeedingBackup(sessions: ISession[]): Promise { + 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 { + const parked = this.parkedSharedHistory.get(roomId) ?? []; + this.parkedSharedHistory.delete(roomId); + return Promise.resolve(parked); + } + + // Session key backups + + public doTxn(mode: Mode, stores: Iterable, func: (txn?: unknown) => T): Promise { + 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 new file mode 100644 index 0000000..89c700c --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Base.ts @@ -0,0 +1,369 @@ +/* +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, +> extends TypedEventEmitter { + private cancelled = false; + private _done = false; + private promise: Promise | null = null; + private transactionTimeoutTimer: ReturnType | 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. + * + *

Once a verifier object is created, the verification can be started by + * calling the verify() method, which will return a promise that will + * resolve when the verification is completed, or reject if it could not + * complete.

+ * + *

Subclasses must have a NAME class property.

+ * + * @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): Promise { + return this.channel.send(type, uncompletedContent); + } + + protected waitForEvent(type: string): Promise { + 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 { + 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 { + 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((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; + + protected async verifyKeys(userId: string, keys: Record, verifier: KeyVerifier): Promise { + // 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 new file mode 100644 index 0000000..da73ebb --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Error.ts @@ -0,0 +1,76 @@ +/* +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): MatrixEvent { + const content = Object.assign({}, { code, reason }, extraData); + return new MatrixEvent({ + type: EventType.KeyVerificationCancel, + content, + }); +} + +export function errorFactory(code: string, reason: string): (extraData?: Record) => MatrixEvent { + return function (extraData?: Record) { + 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 new file mode 100644 index 0000000..c437e0c --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/IllegalMethod.ts @@ -0,0 +1,50 @@ +/* +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 { + 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 => { + 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 new file mode 100644 index 0000000..bfb532e --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/QRCode.ts @@ -0,0 +1,311 @@ +/* +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 { + 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 => { + if (!this.startEvent) { + // TODO: Support scanning QR codes + throw new Error("It is not currently possible to start verification" + "with this method yet."); + } + + const { qrCodeData } = this.request; + // 1. check the secret + if (this.startEvent.getContent()["secret"] !== qrCodeData?.encodedSharedSecret) { + throw newKeyMismatchError(); + } + + // 2. ask if other user shows shield as well + await new Promise((resolve, reject) => { + this.reciprocateQREvent = { + confirm: resolve, + cancel: () => reject(newUserCancelledError()), + }; + this.emit(QrCodeEvent.ShowReciprocateQr, this.reciprocateQREvent); + }); + + // 3. determine key to sign / mark as trusted + const keys: Record = {}; + + 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 { + 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 { + 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 new file mode 100644 index 0000000..a8d237d --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SAS.ts @@ -0,0 +1,492 @@ +/* +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; + 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(anArray: T[], aSet: Set): 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 { + 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 => { + 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> { + 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 { + const sasBytes = calculateKeyAgreement[keyAgreement](this, olmSAS, 6); + const verifySAS = new Promise((resolve, reject) => { + this.sasEvent = { + sas: generateSas(sasBytes, sasMethods), + confirm: async (): Promise => { + 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 { + 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 { + // 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 { + const mac: Record = {}; + 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 { + 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 new file mode 100644 index 0000000..0cb4630 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SASDecimal.ts @@ -0,0 +1,37 @@ +/* +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 new file mode 100644 index 0000000..48415f9 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/Channel.ts @@ -0,0 +1,34 @@ +/* +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): Promise; + completeContent(type: string, content: Record): Record; + sendCompleted(type: string, content: Record): Promise; + completedContentFromEvent(event: MatrixEvent): Record; + canCreateRequest(type: string): boolean; + handleEvent(event: MatrixEvent, request: VerificationRequest, isLiveEvent: boolean): Promise; +} 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 new file mode 100644 index 0000000..ff11bf1 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/InRoomChannel.ts @@ -0,0 +1,356 @@ +/* +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 { + // 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 { + // 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): Record { + 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): Promise { + 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): Promise { + 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>(); + + 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 new file mode 100644 index 0000000..d51b85a --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/ToDeviceChannel.ts @@ -0,0 +1,354 @@ +/* +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; + +/** + * 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 { + 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 { + 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): Record { + // 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 = {}): Promise { + // 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): Promise { + 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, devices: string[]): Promise { + if (devices.length) { + const deviceMessages: Map> = 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>(); + + 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 new file mode 100644 index 0000000..617432e --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/VerificationRequest.ts @@ -0,0 +1,926 @@ +/* +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 extends TypedEventEmitter< + VerificationRequestEvent, + EventHandlerMap +> { + private eventsByUs = new Map(); + private eventsByThem = new Map(); + private _observeOnly = false; + private timeoutTimer: ReturnType | 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; + + public constructor( + public readonly channel: C, + private readonly verificationMethods: Map, + 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 | 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + // 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 => { + 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 { + 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 | 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 new file mode 100644 index 0000000..a08b79a --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/embedded.ts @@ -0,0 +1,347 @@ +/* +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((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 { + 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)); + + 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 { + if (roomIdOrAlias === this.roomId) return this.room!; + throw new Error(`Unknown room: ${roomIdOrAlias}`); + } + + protected async encryptAndSendEvent(room: Room, event: MatrixEvent): Promise { + 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 { + 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 { + // map: user Id → device Id → payload + const contentMap: MapWithDefault> = 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[], payload: object): Promise { + // map: user Id → device Id → payload + const contentMap: MapWithDefault> = 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 { + 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): Promise { + await this.widgetApi.transport.reply(ev.detail, {}); + } + + private onEvent = async (ev: CustomEvent): Promise => { + 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); + 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): Promise => { + 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 { + 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 new file mode 100644 index 0000000..9d24651 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/errors.ts @@ -0,0 +1,53 @@ +/* +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 new file mode 100644 index 0000000..828d87e --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/event-mapper.ts @@ -0,0 +1,97 @@ +/* +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) => 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): 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>(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 new file mode 100644 index 0000000..0496592 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/ExtensibleEvent.ts @@ -0,0 +1,58 @@ +/* +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 { + protected constructor(public readonly wireFormat: IPartialEvent) {} + + /** + * 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; + + /** + * 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 new file mode 100644 index 0000000..12e59ad --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/InvalidEventError.ts @@ -0,0 +1,24 @@ +/* +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 new file mode 100644 index 0000000..3d049f4 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/MessageEvent.ts @@ -0,0 +1,145 @@ +/* +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 { + /** + * The default text for the event. + */ + public readonly text: string; + + /** + * The default HTML for the event, if provided. + */ + public readonly html: Optional; + + /** + * 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) { + super(wireFormat); + + const mmessage = M_MESSAGE.findIn(this.wireContent); + const mtext = M_TEXT.findIn(this.wireContent); + const mhtml = M_HTML.findIn(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 { + 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 new file mode 100644 index 0000000..243f190 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollEndEvent.ts @@ -0,0 +1,97 @@ +/* +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 { + /** + * 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) { + 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 { + 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 new file mode 100644 index 0000000..a61fc2e --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollResponseEvent.ts @@ -0,0 +1,143 @@ +/* +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 { + 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) { + 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(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 { + 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 new file mode 100644 index 0000000..8584bf9 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollStartEvent.ts @@ -0,0 +1,207 @@ +/* +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) { + 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 { + 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 { + /** + * 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) { + super(wireFormat); + + const poll = M_POLL_START.findIn(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 { + 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 new file mode 100644 index 0000000..0660442 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/utilities.ts @@ -0,0 +1,35 @@ +/* +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(s: Optional): 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): 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 new file mode 100644 index 0000000..9141e81 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/feature.ts @@ -0,0 +1,75 @@ +/* +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 = { + [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> { + const supportMap = new Map(); + for (const [feature, supportCondition] of Object.entries(featureSupportResolver)) { + const supportMatrixVersion = versions.versions?.includes(supportCondition.matrixVersion || "") ?? false; + const supportUnstablePrefixes = + supportCondition.unstablePrefixes?.every((unstablePrefix) => { + return versions.unstable_features?.[unstablePrefix] === true; + }) ?? false; + if (supportMatrixVersion) { + supportMap.set(feature 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 new file mode 100644 index 0000000..e28571d --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/filter-component.ts @@ -0,0 +1,204 @@ +/* +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; + "related_by_rel_types"?: string[]; + + // Unstable values + "io.element.relation_senders"?: Array; + "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 = 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, + 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[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 new file mode 100644 index 0000000..4d74c8c --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/filter.ts @@ -0,0 +1,242 @@ +/* +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, 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; + "related_by_senders"?: Array; + "related_by_rel_types"?: string[]; + "unread_thread_notifications"?: boolean; + "org.matrix.msc3773.unread_thread_notifications"?: boolean; + + // Unstable values + "io.element.relation_senders"?: Array; + "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 new file mode 100644 index 0000000..e48fc02 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/errors.ts @@ -0,0 +1,84 @@ +/* +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 { + [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 new file mode 100644 index 0000000..ecb0908 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/fetch.ts @@ -0,0 +1,311 @@ +/* +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 | BodyInit; + +interface TypedResponse extends Response { + json(): Promise; +} + +export type ResponseType = O extends undefined + ? T + : O extends { onlyData: true } + ? T + : TypedResponse; + +export class FetchHttpApi { + private abortController = new AbortController(); + + public constructor( + private eventEmitter: TypedEventEmitter, + 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 { + 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>( + method: Method, + path: string, + params: Record | undefined, + prefix: string, + accessToken?: string, + ): Promise> { + if (!this.opts.idBaseUrl) { + throw new Error("No identity server base URL set"); + } + + let queryParams: QueryDict | undefined = undefined; + let body: Record | 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 after 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( + method: Method, + path: string, + queryParams?: QueryDict, + body?: Body, + opts: IRequestOpts = {}, + ): Promise> { + if (!queryParams) queryParams = {}; + + if (this.opts.accessToken) { + if (this.opts.useAuthorizationHeader) { + if (!opts.headers) { + opts.headers = {}; + } + if (!opts.headers.Authorization) { + opts.headers.Authorization = "Bearer " + this.opts.accessToken; + } + if (queryParams.access_token) { + delete queryParams.access_token; + } + } else if (!queryParams.access_token) { + queryParams.access_token = this.opts.accessToken; + } + } + + const requestPromise = this.request(method, path, queryParams, body, opts); + + requestPromise.catch((err: 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 after 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 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 request( + method: Method, + path: string, + queryParams?: QueryDict, + body?: Body, + opts?: IRequestOpts, + ): Promise> { + const fullUri = this.getUrl(path, queryParams, opts?.prefix, opts?.baseUrl); + return this.requestOtherUrl(method, fullUri, body, opts); + } + + /** + * Perform a request to an arbitrary URL. + * @param method - The HTTP method e.g. "GET". + * @param url - The HTTP URL object. + * + * @param body - The HTTP JSON body. + * + * @param opts - additional options + * + * @returns Promise which resolves to data unless `onlyData` is specified as false, + * where the resolved value will be a fetch Response object. + * @returns Rejects with an error if a problem + * occurred. This includes network problems and Matrix-specific error JSON. + */ + public async requestOtherUrl( + method: Method, + url: URL | string, + body?: Body, + opts: Pick = {}, + ): Promise> { + 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 ((e).name === "AbortError") { + throw e; + } + throw new ConnectionError("fetch failed", 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; + } + + /** + * Form and return a homeserver request URL based on the given path params and prefix. + * @param path - The HTTP path after 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 new file mode 100644 index 0000000..c5e8e2a --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/index.ts @@ -0,0 +1,191 @@ +/* +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 extends FetchHttpApi { + 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 { + 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(); + + 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 ((err).name === "AbortError") { + defer.reject(err); + return; + } + defer.reject(new ConnectionError("request failed", 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 = { "Content-Type": contentType }; + + this.authedRequest(Method.Post, "/upload", queryParams, file, { + prefix: MediaPrefix.R0, + headers, + abortSignal: abortController.signal, + }) + .then((response) => { + return this.opts.onlyData ? 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): 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 new file mode 100644 index 0000000..9946aa3 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/interface.ts @@ -0,0 +1,147 @@ +/* +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; + + 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; + 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 file.name. + */ + name?: string; + /** + * Content-type for the upload. Defaults to + * file.type, or applicaton/octet-stream. + */ + 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; + 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 new file mode 100644 index 0000000..1914360 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/method.ts @@ -0,0 +1,22 @@ +/* +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 new file mode 100644 index 0000000..f15b1ac --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/prefix.ts @@ -0,0 +1,48 @@ +/* +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 new file mode 100644 index 0000000..c49be74 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/utils.ts @@ -0,0 +1,153 @@ +/* +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 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(maxAttempts: number, callback: () => Promise): Promise { + 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 new file mode 100644 index 0000000..c9a5dcf --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/index.ts @@ -0,0 +1,25 @@ +/* +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 new file mode 100644 index 0000000..6f99ae5 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/indexeddb-helpers.ts @@ -0,0 +1,50 @@ +/* +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 { + return new Promise((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 new file mode 100644 index 0000000..68dcf0f --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/indexeddb-worker.ts @@ -0,0 +1,24 @@ +/* +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 new file mode 100644 index 0000000..7d9c183 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/interactive-auth.ts @@ -0,0 +1,617 @@ +/* +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>; + data?: Record; + 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; + /** + * 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; // LEGACY +} + +/** + * Abstracts the logic used to drive the interactive auth process. + * + *

Components implementing an interactive auth flow should instantiate one of + * these, passing in the necessary callbacks to the constructor. They should + * then call attemptAuth, which will return a promise which will resolve or + * reject when the interactive-auth process completes. + * + *

Meanwhile, calls will be made to the startAuthStage and doRequest + * callbacks, and information gathered from the user can be submitted with + * submitAuthDict. + * + * @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 | 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 | 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 { + // 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 { + 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 = { + 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 | 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 { + 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 => { + 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 { + try { + const result = await this.requestCallback(auth, background); + this.attemptAuthDeferred!.resolve(result); + this.attemptAuthDeferred = null; + } catch (error) { + // sometimes UI auth errors don't come with flows + const errorFlows = (error).data?.flows ?? null; + const haveFlows = this.data.flows || Boolean(errorFlows); + if ((error).httpStatus !== 401 || !(error).data || !haveFlows) { + // doesn't look like an interactive-auth failure. + if (!background) { + this.attemptAuthDeferred?.reject(error); + } else { + // We ignore all failures here (even non-UI auth related ones) + // since we don't want to suddenly fail if the internet connection + // had a blip whilst we were polling + logger.log("Background poll request failed doing UI auth: ignoring", error); + } + } + if (!(error).data) { + (error).data = {}; + } + // if the error didn't come with flows, completed flows or session ID, + // copy over the ones we have. Synapse sometimes sends responses without + // any UI auth data (eg. when polling for email validation, if the email + // has not yet been validated). This appears to be a Synapse bug, which + // we workaround here. + if ( + !(error).data.flows && + !(error).data.completed && + !(error).data.session + ) { + (error).data.flows = this.data.flows; + (error).data.completed = this.data.completed; + (error).data.session = this.data.session; + } + this.data = (error).data; + try { + this.startNextAuthStage(); + } catch (e) { + this.attemptAuthDeferred!.reject(e); + this.attemptAuthDeferred = null; + return; + } + + if (!this.emailSid && this.chosenFlow?.stages.includes(AuthType.Email)) { + try { + await this.requestEmailToken(); + // NB. promise is not resolved here - at some point, doRequest + // will be called again and if the user has jumped through all + // the hoops correctly, auth will be complete and the request + // will succeed. + // Also, we should expose the fact that this request has compledted + // so clients can know that the email has actually been sent. + } catch (e) { + // we failed to request an email token, so fail the request. + // This could be due to the email already beeing registered + // (or not being registered, depending on what we're trying + // to do) or it could be a network failure. Either way, pass + // the failure up as the user can't complete auth if we can't + // send the email, for whatever reason. + this.attemptAuthDeferred!.reject(e); + this.attemptAuthDeferred = null; + } + } + } + } + + /** + * Pick the next stage and call the callback + * + * @internal + * @throws {@link NoAuthFlowFoundError} If no suitable authentication flow can be found + */ + 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 new file mode 100644 index 0000000..ba7f742 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/logger.ts @@ -0,0 +1,82 @@ +/* +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 { + (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 new file mode 100644 index 0000000..591c5e3 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/matrix.ts @@ -0,0 +1,110 @@ +/* +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 new file mode 100644 index 0000000..27be4b8 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/MSC3089Branch.ts @@ -0,0 +1,258 @@ +/* +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 { + 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 { + 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 { + 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 { + 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, + additionalContent?: IContent, + ): Promise { + 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 { + 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 new file mode 100644 index 0000000..b0e71d9 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/MSC3089TreeSpace.ts @@ -0,0 +1,566 @@ +/* +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 { + 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 { + const promises: Promise[] = [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 { + 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 { + 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 { + 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 { + 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 { + 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, + additionalContent?: IContent, + ): Promise { + 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 new file mode 100644 index 0000000..8efc3ed --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/ToDeviceMessage.ts @@ -0,0 +1,38 @@ +/* +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; + +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 new file mode 100644 index 0000000..3801831 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/beacon.ts @@ -0,0 +1,209 @@ +/* +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 +// `_` +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, 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; + 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(); + 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 new file mode 100644 index 0000000..0401cd5 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-context.ts @@ -0,0 +1,110 @@ +/* +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.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 new file mode 100644 index 0000000..a5113e0 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-status.ts @@ -0,0 +1,39 @@ +/* +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 new file mode 100644 index 0000000..5cb0499 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-timeline-set.ts @@ -0,0 +1,906 @@ +/* +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 { + /** Whether the sync response came from cache */ + fromCache?: boolean; +} + +export interface IAddLiveEventOptions + extends Pick { + /** Applies to events in the timeline only. If this is 'replace' then if a + * duplicate is encountered, the event passed to this function will replace + * the existing event in the timeline. If this is not specified, or is + * 'ignore', then the event passed to this function will be ignored + * entirely, preserving the existing event in the timeline. Events are + * identical based on their event ID only. */ + 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 { + public readonly relations: RelationsContainer; + private readonly timelineSupport: boolean; + private readonly displayPendingEvents: boolean; + private liveTimeline: EventTimeline; + private timelines: EventTimeline[]; + private _eventIdToTimeline = new Map(); + 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. + * + *

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

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

In order that we can find events from their ids later, we also maintain a + * map from event_id to timeline and index. + * + * @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(); + + 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. + * + *

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(); + } 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 + * + *

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 last 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 new file mode 100644 index 0000000..d1ba321 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-timeline.ts @@ -0,0 +1,458 @@ +/* +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 { + // 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 { + /** 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 | null> = { + [Direction.Backward]: null, + [Direction.Forward]: null, + }; + + /** + * Construct a new EventTimeline + * + *

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

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

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

Once a timeline joins up with its neighbour, they are linked together into a + * doubly-linked list. + * + * @param eventTimelineSet - 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 + * + *

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. + * + *

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 new file mode 100644 index 0000000..2db3479 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event.ts @@ -0,0 +1,1631 @@ +/* +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>; + +export interface IUnsigned { + "age"?: number; + "prev_sender"?: string; + "prev_content"?: IContent; + "redacted_because"?: IEvent; + "transaction_id"?: string; + "invite_room_state"?: StrippedState[]; + "m.relations"?: Record; // 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; + 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; + +export class MatrixEvent extends TypedEventEmitter { + 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 = 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 | 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. This property is experimental and may change. + * @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; + + /** + * Construct a Matrix Event object + * + * @param event - The raw (possibly encrypted) event. Do not access + * this property directly unless you absolutely have to. Prefer the getter + * methods defined on this class. Using the getter methods shields your app + * from changes to event JSON between Matrix versions. + */ + public constructor(public event: Partial = {}) { + 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 { + 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. $143350589368169JsLZx:localhost + * + */ + 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. !cURbafjkfsMDVwdRDQ:matrix.org + * + */ + 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 { + 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 { + 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(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) + * This method is experimental and may change. + * @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 undefined + * 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 + * "m.room.encrypted" + * + * @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 | 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 { + // 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 { + 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 { + // 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 ? (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> { + 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(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(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(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> = { + [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 new file mode 100644 index 0000000..173ba62 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/invites-ignorer.ts @@ -0,0 +1,368 @@ +/* +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 { + 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 { + 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 { + // 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> { + // 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 { + 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 { + 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 { + 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 new file mode 100644 index 0000000..1d4344a --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/poll.ts @@ -0,0 +1,268 @@ +/* +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, 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(); + + 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 { + // 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 { + 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 new file mode 100644 index 0000000..5858fe5 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/read-receipt.ts @@ -0,0 +1,312 @@ +/* +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, + SuperclassArguments extends ListenerMap = Arguments, +> extends TypedEventEmitter { + // 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>( + () => 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 read up to 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 new file mode 100644 index 0000000..a005169 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/related-relations.ts @@ -0,0 +1,39 @@ +/* +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((c, p) => [...c, ...p.getRelations()], []); + } + + public on(ev: T, fn: Listener): void { + this.relations.forEach((r) => r.on(ev, fn)); + } + + public off(ev: T, fn: Listener): 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 new file mode 100644 index 0000000..d328b1c --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/relations-container.ts @@ -0,0 +1,146 @@ +/* +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>>(); + + 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, relationType or 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>(); + 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>(); + this.relations.set(relatesToEventId!, relationsForEvent); + } + + let relationsWithRelType = relationsForEvent.get(relationType!); + if (!relationsWithRelType) { + relationsWithRelType = new Map(); + relationsForEvent.set(relationType!, relationsWithRelType); + } + + let relationsWithEventType = relationsWithRelType.get(eventType); + if (!relationsWithEventType) { + relationsWithEventType = new Relations(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 new file mode 100644 index 0000000..d2b637c --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/relations.ts @@ -0,0 +1,368 @@ +/* +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 { + private relationEventIds = new Set(); + private relations = new Set(); + private annotationsByKey: Record> = {}; + private annotationsBySender: Record> = {}; + private sortedAnnotationsByKey: [string, Set][] = []; + 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 { + 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 { + 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 => { + 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][] | 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> | 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 { + 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(RelationType.Replace); + const minTs = replaceRelation?.origin_server_ts; + + const lastReplacement = this.getRelations().reduce((last, event) => { + if (event.getSender() !== this.targetEvent!.getSender()) { + return last; + } + if (minTs && minTs > event.getTs()) { + return last; + } + if (last && last.getTs() > event.getTs()) { + return last; + } + return event; + }, null); + + if (lastReplacement?.shouldAttemptDecryption() && this.client.isCryptoEnabled()) { + await lastReplacement.attemptDecryption(this.client.crypto!); + } else if (lastReplacement?.isBeingDecrypted()) { + await lastReplacement.getDecryptionPromise(); + } + + return lastReplacement; + } + + /* + * @param targetEvent - the event the relations are related to. + */ + public async setTargetEvent(event: MatrixEvent): Promise { + 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 new file mode 100644 index 0000000..116a93b --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-member.ts @@ -0,0 +1,453 @@ +/* +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 { + 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 before 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 new file mode 100644 index 0000000..f975b9c --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-state.ts @@ -0,0 +1,1081 @@ +/* +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; + events?: Record; + // 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 { + public readonly reEmitter = new TypedReEmitter(this); + private sentinels: Record = {}; // userId: RoomMember + // stores fuzzy matches to a list of userIDs (applies utils.removeHiddenChars to keys) + private displayNameToUserIds = new Map(); + private userIdsToDisplayNames: Record = {}; + private tokenToInvite: Record = {}; // 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 = {}; // userId: RoomMember + // The state events dictionary, keyed on the event type and then the state_key value. + public events = new Map>(); // Map> + // The pagination token for this state. + public paginationToken: string | null = null; + + public readonly beacons = new Map(); + 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 { + if ( + !events.length || + // discard locations if we have no beacons + !this.beacons.size + ) { + return; + } + + const beaconByEventIdDict = [...this.beacons.values()].reduce>((dict, beacon) => { + dict[beacon.beaconInfoId] = beacon; + return dict; + }, {}); + + const processBeaconRelation = (beaconInfoEventId: 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 === (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(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 = {}; + + 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 = 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 new file mode 100644 index 0000000..936ec1d --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-summary.ts @@ -0,0 +1,44 @@ +/* +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 new file mode 100644 index 0000000..133b210 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room.ts @@ -0,0 +1,3487 @@ +/* +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 "chronological", messages will appear in the timeline when the call to `sendEvent` was made. + * If "detached", 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>; + +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. + * + *

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

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

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

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

This event is raised to reflect each of the transitions above. + * + * @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 & + EventTimelineSetHandlerMap & + Pick & + Pick< + RoomStateEventHandlerMap, + | RoomStateEvent.Events + | RoomStateEvent.Members + | RoomStateEvent.NewMember + | RoomStateEvent.Update + | RoomStateEvent.Marker + | BeaconEvent.New + > & + Pick; + +export class Room extends ReadReceipt { + public readonly reEmitter: TypedReEmitter; + private txnToEvent: Map = new Map(); // Pending in-flight requests { string: MatrixEvent } + private notificationCounts: NotificationCount = {}; + private readonly threadNotifications = new Map(); + public readonly cachedThreadReadReceipts = new Map(); + // 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(); + private readonly timelineSets: EventTimelineSet[]; + public readonly polls: Map = new Map(); + public readonly threadsTimelineSets: EventTimelineSet[] = []; + // any filtered timeline sets we're maintaining for this room + private readonly filteredTimelineSets: Record = {}; // 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; + + // 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> = {}; // $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 = 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(); + 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(); + + /** + * Construct a new Room. + * + *

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

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

In order that we can find events from their ids later, we also maintain a + * map from event_id to timeline and index. + * + * @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) => { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + // 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. + * + *

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 { + 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 + * + *

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 last 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 { + 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 { + 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 { + 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 { + 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( + THREAD_RELATION_TYPE.name, + )!; + const threadBMetadata = eventB.getServerAggregatedRelation( + 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( + 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 { + 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 { + 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, + ): { + 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, + 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. + * + *

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

This is an internal method, intended for use by MatrixClient. + * + * @param 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. + * + *

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. + * + *

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 { + const threadRoots = new Set(); + 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(); + 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.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 new file mode 100644 index 0000000..21192a6 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/search-result.ts @@ -0,0 +1,54 @@ +/* +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 new file mode 100644 index 0000000..9a4ead3 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/thread.ts @@ -0,0 +1,669 @@ +/* +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 | 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 { + 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; + + 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 { + 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 => { + 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 => { + 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 { + 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): Promise { + 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(THREAD_RELATION_TYPE.name); + } + + private async processRootEvent(): Promise { + 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. + * + *

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 { + 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 { + 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 { + 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): void { + if (event) { + EventTimeline.setEventMetadata(event, this.roomState, false); + event.setThread(this); + } + } + + public clearEventMetadata(event: Optional): 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 { + 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 new file mode 100644 index 0000000..3cfe602 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/typed-event-emitter.ts @@ -0,0 +1,114 @@ +/* +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 = { [eventName in E]: AnyListener }; +type EventEmitterEventListener = (eventName: string, listener: AnyListener) => void; +type EventEmitterErrorListener = (error: Error) => void; + +export type Listener, 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, + SuperclassArguments extends ListenerMap = Arguments, +> extends EventEmitter { + public addListener( + event: T, + listener: Listener, + ): this { + return super.addListener(event, listener); + } + + public emit(event: T, ...args: Parameters): boolean; + public emit(event: T, ...args: Parameters): boolean; + public emit(event: T, ...args: any[]): boolean { + return super.emit(event, ...args); + } + + public eventNames(): (Events | EventEmitterEvents)[] { + return super.eventNames() as Array; + } + + public listenerCount(event: Events | EventEmitterEvents): number { + return super.listenerCount(event); + } + + public listeners(event: Events | EventEmitterEvents): ReturnType { + return super.listeners(event); + } + + public off(event: T, listener: Listener): this { + return super.off(event, listener); + } + + public on(event: T, listener: Listener): this { + return super.on(event, listener); + } + + public once(event: T, listener: Listener): this { + return super.once(event, listener); + } + + public prependListener( + event: T, + listener: Listener, + ): this { + return super.prependListener(event, listener); + } + + public prependOnceListener( + event: T, + listener: Listener, + ): this { + return super.prependOnceListener(event, listener); + } + + public removeAllListeners(event?: Events | EventEmitterEvents): this { + return super.removeAllListeners(event); + } + + public removeListener( + event: T, + listener: Listener, + ): this { + return super.removeListener(event, listener); + } + + public rawListeners(event: Events | EventEmitterEvents): ReturnType { + 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 new file mode 100644 index 0000000..054a174 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/user.ts @@ -0,0 +1,281 @@ +/* +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 { + 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 before 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 new file mode 100644 index 0000000..78d26fe --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/pushprocessor.ts @@ -0,0 +1,770 @@ +/* +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>; +} + +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(); + + /** + * 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 = {}; // $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 | null { + const rawrule: Pick = { + 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 & Pick, 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 new file mode 100644 index 0000000..0ed46fb --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/randomstring.ts @@ -0,0 +1,42 @@ +/* +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 new file mode 100644 index 0000000..1b03a57 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/realtime-callbacks.ts @@ -0,0 +1,191 @@ +/* +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(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 new file mode 100644 index 0000000..f431c83 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/MSC3906Rendezvous.ts @@ -0,0 +1,264 @@ +/* +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, + 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 { + if (this._code) { + return; + } + + this._code = JSON.stringify(await this.channel.generateCode(this.ourIntent)); + } + + public async startAfterShowingCode(): Promise { + 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 { + return (await this.channel.receive()) as MSC3906RendezvousPayload; + } + + private async send(payload: MSC3906RendezvousPayload): Promise { + await this.channel.send(payload); + } + + public async declineLoginOnExistingDevice(): Promise { + logger.info("User declined sign in"); + await this.send({ type: PayloadType.Finish, outcome: Outcome.Declined }); + } + + public async approveLoginOnExistingDevice(loginToken: string): Promise { + // 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 { + 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 { + 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 { + this.onFailure?.(reason); + await this.channel.cancel(reason); + } + + public async close(): Promise { + 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 new file mode 100644 index 0000000..549ebc8 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousChannel.ts @@ -0,0 +1,48 @@ +/* +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 { + /** + * @returns the checksum/confirmation digits to be shown to the user + */ + connect(): Promise; + + /** + * Send a payload via the channel. + * @param data - payload to send + */ + send(data: T): Promise; + + /** + * Receive a payload from the channel. + * @returns the received payload + */ + receive(): Promise | undefined>; + + /** + * Close the channel and clear up any resources. + */ + close(): Promise; + + /** + * @returns a representation of the channel that can be encoded in a QR or similar + */ + generateCode(intent: RendezvousIntent): Promise; + + cancel(reason: RendezvousFailureReason): Promise; +} 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 new file mode 100644 index 0000000..86608aa --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousCode.ts @@ -0,0 +1,25 @@ +/* +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 new file mode 100644 index 0000000..8b76fc1 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousError.ts @@ -0,0 +1,23 @@ +/* +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 new file mode 100644 index 0000000..b19a91c --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousFailureReason.ts @@ -0,0 +1,31 @@ +/* +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 new file mode 100644 index 0000000..db53ef9 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousIntent.ts @@ -0,0 +1,20 @@ +/* +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 new file mode 100644 index 0000000..08905be --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousTransport.ts @@ -0,0 +1,58 @@ +/* +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 { + /** + * 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; + + /** + * Send data via the transport. + * @param data - the data itself + */ + send(data: T): Promise; + + /** + * Receive data from the transport. + */ + receive(): Promise | undefined>; + + /** + * Cancel the rendezvous. This will call `onCancelled()` if it is set. + * @param reason - the reason for the cancellation/failure + */ + cancel(reason: RendezvousFailureReason): Promise; +} 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 new file mode 100644 index 0000000..be60ee5 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts @@ -0,0 +1,259 @@ +/* +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 { + 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 implements RendezvousChannel { + private olmSAS?: SAS; + private ourPublicKey: Uint8Array; + private aesKey?: CryptoKey; + private connected = false; + + public constructor( + private transport: RendezvousTransport, + 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 { + 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 { + 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; + 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 { + 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 { + 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> { + 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 | 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; + if (data.ciphertext && data.iv) { + return this.decrypt(data as EncryptedPayload); + } + + throw new Error("Data received but no ciphertext"); + } + + public async close(): Promise { + if (this.olmSAS) { + this.olmSAS.free(); + this.olmSAS = undefined; + } + } + + public async cancel(reason: RendezvousFailureReason): Promise { + 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 new file mode 100644 index 0000000..f157bbe --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/channels/index.ts @@ -0,0 +1,17 @@ +/* +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 new file mode 100644 index 0000000..379b133 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/index.ts @@ -0,0 +1,23 @@ +/* +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 new file mode 100644 index 0000000..430ee92 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts @@ -0,0 +1,193 @@ +/* +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 implements RendezvousTransport { + 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 { + 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 { + if (this.fetchFn) { + return this.fetchFn(resource, options); + } + return global.fetch(resource, options); + } + + private async getPostEndpoint(): Promise { + 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 { + 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 = { "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 | 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 = {}; + 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 { + 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 new file mode 100644 index 0000000..6d8d642 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/transports/index.ts @@ -0,0 +1,17 @@ +/* +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 new file mode 100644 index 0000000..5c0b61d --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/room-hierarchy.ts @@ -0,0 +1,152 @@ +/* +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>(); + // Map from room id to list of rooms which claim this room as their child + public readonly backRefs = new Map(); + // Map from room id to object + public readonly roomMap = new Map(); + private loadRequest?: ReturnType; + 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 { + 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 ((e).errcode === "M_UNRECOGNIZED") { + this.serverSupportError = e; + } else { + throw e; + } + + return []; + } finally { + this.loadRequest = undefined; + } + + if (this._rooms) { + this._rooms = this._rooms.concat(rooms); + } else { + this._rooms = rooms; + } + + rooms.forEach((room) => { + this.roomMap.set(room.room_id, room); + + room.children_state.forEach((ev) => { + if (ev.type !== 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 new file mode 100644 index 0000000..9df8f89 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/KeyClaimManager.ts @@ -0,0 +1,77 @@ +/* +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; + 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): Promise { + // 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): Promise { + // 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 new file mode 100644 index 0000000..7ac9a21 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/OutgoingRequestProcessor.ts @@ -0,0 +1,116 @@ +/* +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, + ) {} + + public async makeOutgoingRequest(msg: OutgoingRequest): Promise { + 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 { + const opts = { + // inhibit the JSON stringification and parsing within HttpApi. + json: false, + + // nevertheless, we are sending, and accept, JSON. + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + }, + + // we use the full prefix + prefix: "", + }; + + try { + const response = await this.http.authedRequest(method, path, queryParams, body, opts); + logger.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 new file mode 100644 index 0000000..1649a69 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/RoomEncryptor.ts @@ -0,0 +1,153 @@ +/* +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 { + 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 { + 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 { + 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 new file mode 100644 index 0000000..7f91e90 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/browserify-index.ts @@ -0,0 +1,31 @@ +/* +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, + _userId: string, + _deviceId: string, +): Promise { + 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 new file mode 100644 index 0000000..9d72060 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/constants.ts @@ -0,0 +1,18 @@ +/* +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 new file mode 100644 index 0000000..e2c541f --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/index.ts @@ -0,0 +1,45 @@ +/* +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, + userId: string, + deviceId: string, +): Promise { + // 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 new file mode 100644 index 0000000..4a0b1f8 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/rust-crypto.ts @@ -0,0 +1,334 @@ +/* +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 = {}; + + private keyClaimManager: KeyClaimManager; + private outgoingRequestProcessor: OutgoingRequestProcessor; + + public constructor( + private readonly olmMachine: RustSdkCryptoJs.OlmMachine, + http: MatrixHttpApi, + _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 { + 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 { + 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 = {}; + + 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 { + // TODO + return false; + } + + public prepareToEncrypt(room: Room): void { + const encryptor = this.roomEncryptors[room.roomId]; + + if (encryptor) { + encryptor.ensureEncryptionSession(); + } + } + + public forceDiscardSession(roomId: string): Promise { + return this.roomEncryptors[roomId]?.forceDiscardSession(); + } + + public async exportRoomKeys(): Promise { + // 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(), + unusedFallbackKeys = new Set(), + }: { + events?: IToDeviceEvent[]; + oneTimeKeysCounts?: Map; + unusedFallbackKeys?: Set; + }): Promise { + 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 { + // 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): Promise { + 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): Promise { + 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 { + 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 { + 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 new file mode 100644 index 0000000..6b6bae1 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/scheduler.ts @@ -0,0 +1,335 @@ +/* +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 { + event: MatrixEvent; + defer: IDeferred; + 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 = (event: MatrixEvent) => Promise; + +// eslint-disable-next-line camelcase +export class MatrixScheduler { + /** + * Retries events up to 4 times using exponential backoff. This produces wait + * times of 2, 4, 8, and 16 seconds (30s total) after which we give up. If the + * failure was due to a rate limited request, the time specified in the error is + * waited before being retried. + * @param attempts - Number of attempts that have been made, including the one that just failed (ie. starting at 1) + * @see retryAlgorithm + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + 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[]> = {}; + private activeQueues: string[] = []; + private procFn: ProcessFunction | 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 will 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): 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 | 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(); + 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 | undefined; + while ((obj = this.removeNextEvent(queueName))) { + obj.defer.reject(err); + } + this.disableQueue(queueName); + } + + private peekNextEvent(queueName: string): IQueueEntry | undefined { + const queue = this.queues[queueName]; + if (!Array.isArray(queue)) { + return undefined; + } + return queue[0]; + } + + private removeNextEvent(queueName: string): IQueueEntry | 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 new file mode 100644 index 0000000..f0c19c4 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/secret-storage.ts @@ -0,0 +1,88 @@ +/* +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 new file mode 100644 index 0000000..3ed08bb --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/service-types.ts @@ -0,0 +1,20 @@ +/* +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 new file mode 100644 index 0000000..93e29e0 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/sliding-sync-sdk.ts @@ -0,0 +1,1027 @@ +/* +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 { + 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 { + // 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["to_device"]["events"]; + next_batch: string | null; +}; + +class ExtensionToDevice implements Extension { + 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 { + 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; +}; + +class ExtensionAccountData implements Extension { + 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>((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(); + 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; +}; + +class ExtensionTyping implements Extension { + 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; +}; + +class ExtensionReceipts implements Extension { + 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[] = [ + 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 { + 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 { + 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 { + 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(); + 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 => { + 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; + 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 { + 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(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 new file mode 100644 index 0000000..dde5f1b --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/sliding-sync.ts @@ -0,0 +1,961 @@ +/* +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; + unsubscribe_rooms?: string[]; + room_subscriptions?: Record; + 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; + rooms: Record; + extensions: Record; +} + +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 = {}; + 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 { + /** + * 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) => 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 { + private lists: Map; + 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 & { txnId: string })[] = []; + // map of extension name to req/resp handler + private extensions: Record> = {}; + + private desiredRoomSubscriptions = new Set(); // the *desired* room subscriptions + private confirmedRoomSubscriptions = new Set(); + + // map of custom subscription name to the subscription + private customSubscriptions: Map = new Map(); + // map of room ID to custom subscription name + private roomIdToCustomSubscription: Map = new Map(); + + private pendingReq?: Promise; + 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, + private roomSubscriptionInfo: MSC3575RoomSubscription, + private readonly client: MatrixClient, + private readonly timeoutMS: number, + ) { + super(); + this.lists = new Map(); + lists.forEach((list, key) => { + this.lists.set(key, new SlidingList(list)); + }); + } + + /** + * Add a custom room subscription, referred to by an arbitrary name. If a subscription with this + * name already exists, it is replaced. No requests are sent by calling this method. + * @param name - The name of the subscription. Only used to reference this subscription in + * useCustomSubscription. + * @param sub - The subscription information. + */ + 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 } | 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 { + 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 { + 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 { + 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): Promise { + 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 { + this.roomSubscriptionInfo = rs; + this.confirmedRoomSubscriptions = new Set(); + return this.resend(); + } + + /** + * Register an extension to send with the /sync request. + * @param ext - The extension to register. + */ + public registerExtension(ext: Extension): 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 { + const ext: Record = {}; + Object.keys(this.extensions).forEach((extName) => { + ext[extName] = this.extensions[extName].onRequest(isInitial); + }); + return ext; + } + + private onPreExtensionsResponse(ext: Record): void { + Object.keys(ext).forEach((extName) => { + if (this.extensions[extName].when() == ExtensionState.PreProcess) { + this.extensions[extName].onResponse(ext[extName]); + } + }); + } + + private onPostExtensionsResponse(ext: Record): 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 { + 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(); + 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(); // 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 { + 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 = {}; + 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 ((err).httpStatus) { + this.invokeLifecycleListeners(SlidingSyncState.RequestFinished, null, err); + if ((err).httpStatus === 400) { + // session probably expired TODO: assign an errcode + // so drop state and re-request + this.resetup(); + currentPos = undefined; + await sleep(50); // in case the 400 was for something else; don't tightloop + continue; + } // else fallthrough to generic error handling + } else if (this.needsResend || (err).name === "AbortError") { + continue; // don't sleep as we caused this error by abort()ing the request. + } + logger.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 = 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, setB: Set): Set => { + 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 new file mode 100644 index 0000000..650dd9a --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/index.ts @@ -0,0 +1,248 @@ +/* +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; // 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; + + /** + * 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; + + /** + * 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; + + /** + * @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; + + /** + * @returns If there is a saved sync, the nextBatch token + * for this sync, otherwise null. + */ + getSavedSyncToken(): Promise; + + /** + * Delete all data from this store. Does nothing since this store + * doesn't store anything. + * @returns An immediately resolved promise. + */ + deleteAllData(): Promise; + + /** + * 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; + + /** + * 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; + + clearOutOfBandMembers(roomId: string): Promise; + + getClientOptions(): Promise; + + storeClientOptions(options: IStartClientOpts): Promise; + + getPendingEvents(roomId: string): Promise[]>; + + setPendingEvents(roomId: string, events: Partial[]): Promise; + + /** + * Stores batches of outgoing to-device messages + */ + saveToDeviceBatches(batch: ToDeviceBatchWithTxnId[]): Promise; + + /** + * Fetches the oldest batch of to-device messages in the queue + */ + getOldestToDeviceBatch(): Promise; + + /** + * Removes a specific batch of to-device messages from the queue + */ + removeToDeviceBatch(id: number): Promise; +} 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 new file mode 100644 index 0000000..008867d --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-backend.ts @@ -0,0 +1,40 @@ +/* +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; + syncToDatabase(userTuples: UserTuple[]): Promise; + isNewlyCreated(): Promise; + setSyncData(syncData: ISyncResponse): Promise; + getSavedSync(): Promise; + getNextBatchToken(): Promise; + clearDatabase(): Promise; + getOutOfBandMembers(roomId: string): Promise; + setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise; + clearOutOfBandMembers(roomId: string): Promise; + getUserPresenceEvents(): Promise; + getClientOptions(): Promise; + storeClientOptions(options: IStoredClientOpts): Promise; + saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise; + getOldestToDeviceBatch(): Promise; + removeToDeviceBatch(id: number): Promise; +} + +export type UserTuple = [userId: string, presenceEvent: Partial]; 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 new file mode 100644 index 0000000..80fed44 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-local-backend.ts @@ -0,0 +1,597 @@ +/* +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( + store: IDBObjectStore, + keyRange: IDBKeyRange | IDBValidKey | undefined, + resultMapper: (cursor: IDBCursorWithValue) => T, +): Promise { + 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 { + return new Promise((resolve, reject) => { + txn.oncomplete = function (event): void { + resolve(event); + }; + txn.onerror = function (): void { + reject(txn.error); + }; + }); +} + +function reqAsEventPromise(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = function (event): void { + resolve(event); + }; + req.onerror = function (): void { + reject(req.error); + }; + }); +} + +function reqAsPromise(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = (): void => resolve(req); + req.onerror = (err): void => reject(err); + }); +} + +function reqAsCursorPromise(req: IDBRequest): Promise { + return reqAsEventPromise(req).then((event) => req.result); +} + +export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { + public static exists(indexedDB: IDBFactory, dbName: string): Promise { + 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; + 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 { + 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 { + 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 { + 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 { + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(["oob_membership_events"], "readonly"); + const store = tx.objectStore("oob_membership_events"); + const roomIndex = store.index("room"); + const range = IDBKeyRange.only(roomId); + const request = roomIndex.openCursor(range); + + const membershipEvents: 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 { + 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 { + // the approach to delete all members for a room + // is to get the min and max state key from the index + // for that room, and then delete between those + // keys in the store. + // this should be way faster than deleting every member + // individually for a large room. + const readTx = this.db!.transaction(["oob_membership_events"], "readonly"); + const store = readTx.objectStore("oob_membership_events"); + const roomIndex = store.index("room"); + const roomRange = IDBKeyRange.only(roomId); + + const minStateKeyProm = reqAsCursorPromise(roomIndex.openKeyCursor(roomRange, "next")).then( + (cursor) => (cursor?.primaryKey)[1], + ); + const maxStateKeyProm = reqAsCursorPromise(roomIndex.openKeyCursor(roomRange, "prev")).then( + (cursor) => (cursor?.primaryKey)[1], + ); + const [minStateKey, maxStateKey] = await Promise.all([minStateKeyProm, maxStateKeyProm]); + + const writeTx = this.db!.transaction(["oob_membership_events"], "readwrite"); + const writeStore = writeTx.objectStore("oob_membership_events"); + const membersKeyRange = IDBKeyRange.bound([roomId, minStateKey], [roomId, maxStateKey]); + + logger.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 { + 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 { + 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 { + return Promise.resolve(this.syncAccumulator.getNextBatchToken()); + } + + public setSyncData(syncData: ISyncResponse): Promise { + 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 { + 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 { + 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 { + logger.log("Persisting sync data up to", nextBatch); + return utils.promiseTry(() => { + const txn = this.db!.transaction(["sync"], "readwrite"); + const store = txn.objectStore("sync"); + store.put({ + clobber: "-", // constant key so will always clobber + nextBatch, + 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 { + return utils.promiseTry(() => { + const txn = this.db!.transaction(["accountData"], "readwrite"); + const store = txn.objectStore("accountData"); + for (const event of accountData) { + store.put(event); // put == UPSERT + } + return txnAsPromise(txn).then(); + }); + } + + /** + * Persist a list of [user id, presence event] they are for. + * Users with the same 'userId' will be replaced. + * Presence events should be the event in its raw form (not the Event + * object) + * @param tuples - An array of [userid, event] tuples + * @returns Promise which resolves if the users were persisted. + */ + private persistUserPresenceEvents(tuples: UserTuple[]): Promise { + return utils.promiseTry(() => { + const txn = this.db!.transaction(["users"], "readwrite"); + const store = txn.objectStore("users"); + for (const tuple of tuples) { + store.put({ + userId: tuple[0], + event: tuple[1], + }); // put == UPSERT + } + return txnAsPromise(txn).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 { + return utils.promiseTry(() => { + const txn = this.db!.transaction(["users"], "readonly"); + const store = txn.objectStore("users"); + return selectQuery(store, undefined, (cursor) => { + return [cursor.value.userId, cursor.value.event]; + }); + }); + } + + /** + * Load all the account data events from the database. This is not cached. + * @returns A list of raw global account events. + */ + private loadAccountData(): Promise { + logger.log(`LocalIndexedDBStoreBackend: loading account data...`); + return utils.promiseTry(() => { + const txn = this.db!.transaction(["accountData"], "readonly"); + const store = txn.objectStore("accountData"); + return selectQuery(store, undefined, (cursor) => { + return cursor.value; + }).then((result: 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 { + logger.log(`LocalIndexedDBStoreBackend: loading sync data...`); + return utils.promiseTry(() => { + const txn = this.db!.transaction(["sync"], "readonly"); + const store = txn.objectStore("sync"); + return selectQuery(store, undefined, (cursor) => { + return cursor.value; + }).then((results: 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 { + 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 { + 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 { + 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 { + 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 { + 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 new file mode 100644 index 0000000..7e2aa0c --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-remote-backend.ts @@ -0,0 +1,203 @@ +/* +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> = {}; // seq: promise + // Once we start connecting, we keep the promise and re-use it + // if we try to connect again + private startPromise?: Promise; + // 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 { + 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 { + return this.ensureStarted().then(() => this.doCmd("clearDatabase")); + } + + /** @returns whether or not the database was newly created in this session. */ + public isNewlyCreated(): Promise { + 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 { + return this.doCmd("getSavedSync"); + } + + public getNextBatchToken(): Promise { + return this.doCmd("getNextBatchToken"); + } + + public setSyncData(syncData: ISyncResponse): Promise { + return this.doCmd("setSyncData", [syncData]); + } + + public syncToDatabase(userTuples: UserTuple[]): Promise { + 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 { + 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 { + return this.doCmd("setOutOfBandMembers", [roomId, membershipEvents]); + } + + public clearOutOfBandMembers(roomId: string): Promise { + return this.doCmd("clearOutOfBandMembers", [roomId]); + } + + public getClientOptions(): Promise { + return this.doCmd("getClientOptions"); + } + + public storeClientOptions(options: IStoredClientOpts): Promise { + 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 { + return this.doCmd("getUserPresenceEvents"); + } + + public async saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise { + return this.doCmd("saveToDeviceBatches", [batches]); + } + + public async getOldestToDeviceBatch(): Promise { + return this.doCmd("getOldestToDeviceBatch"); + } + + public async removeToDeviceBatch(id: number): Promise { + return this.doCmd("removeToDeviceBatch", [id]); + } + + private ensureStarted(): Promise { + 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(command: string, args?: any): Promise { + // wrap in a q so if the postMessage throws, + // the promise automatically gets rejected + return Promise.resolve().then(() => { + const seq = this.nextSeq++; + const def = defer(); + + this.inFlight[seq] = def; + + this.worker?.postMessage({ command, 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 new file mode 100644 index 0000000..52a7fa6 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-store-worker.ts @@ -0,0 +1,157 @@ +/* +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["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 | 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 new file mode 100644 index 0000000..cc77bf9 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb.ts @@ -0,0 +1,383 @@ +/* +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 { + 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 = {}; // user_id : timestamp + private emitter = new TypedEventEmitter(); + + /** + * 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 { + 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 => { + return this.backend.getSavedSync(); + }, "getSavedSync"); + + /** @returns whether or not the database was newly created in this session. */ + public isNewlyCreated = this.degradable((): Promise => { + 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 => { + 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 => { + 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 { + if (force || this.wantsSave()) { + return this.reallySave(); + } + return Promise.resolve(); + } + + private reallySave = this.degradable((): Promise => { + 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][] = []; + 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 => { + 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 => { + 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 => { + 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 => { + return this.backend.getClientOptions(); + }, "getClientOptions"); + + public storeClientOptions = this.degradable((options: IStoredClientOpts): Promise => { + 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, R = void>( + func: DegradableFn, + fallback?: keyof MemoryStore, + ): DegradableFn { + 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[]> { + 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[]): Promise { + 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 { + return this.backend.saveToDeviceBatches(batches); + } + + public getOldestToDeviceBatch(): Promise { + return this.backend.getOldestToDeviceBatch(); + } + + public removeToDeviceBatch(id: number): Promise { + 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, T> = (...args: A) => Promise; 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 new file mode 100644 index 0000000..adb70cb --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/local-storage-events-emitter.ts @@ -0,0 +1,46 @@ +/* +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 {} +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 new file mode 100644 index 0000000..d859ddd --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/memory.ts @@ -0,0 +1,436 @@ +/* +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 = {}; // roomId: Room + private users: Record = {}; // userId: User + private syncToken: string | null = null; + // userId: { + // filterId: Filter + // } + private filters: MapWithDefault> = new MapWithDefault(() => new Map()); + public accountData: Map = new Map(); // type: content + protected readonly localStorage?: Storage; + private oobMembers: Map = new Map(); // roomId: [member events] + private pendingEvents: { [roomId: string]: Partial[] } = {}; + 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 { + 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 { + 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 { + 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 { + return Promise.resolve(null); + } + + /** + * @returns If there is a saved sync, the nextBatch token + * for this sync, otherwise null. + */ + public getSavedSyncToken(): Promise { + return Promise.resolve(null); + } + + /** + * Delete all data from this store. + * @returns An immediately resolved promise. + */ + public deleteAllData(): Promise { + 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 { + 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 { + this.oobMembers.set(roomId, membershipEvents); + return Promise.resolve(); + } + + public clearOutOfBandMembers(roomId: string): Promise { + this.oobMembers.delete(roomId); + return Promise.resolve(); + } + + public getClientOptions(): Promise { + return Promise.resolve(this.clientOptions); + } + + public storeClientOptions(options: IStoredClientOpts): Promise { + this.clientOptions = Object.assign({}, options); + return Promise.resolve(); + } + + public async getPendingEvents(roomId: string): Promise[]> { + return this.pendingEvents[roomId] ?? []; + } + + public async setPendingEvents(roomId: string, events: Partial[]): Promise { + this.pendingEvents[roomId] = events; + } + + public saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise { + 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 { + if (this.pendingToDeviceBatches.length === 0) return null; + return this.pendingToDeviceBatches[0]; + } + + public removeToDeviceBatch(id: number): Promise { + 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 new file mode 100644 index 0000000..e4402ed --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/stub.ts @@ -0,0 +1,267 @@ +/* +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 { + 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 { + 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 { + 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 { + return Promise.resolve(null); + } + + /** + * @returns If there is a saved sync, the nextBatch token + * for this sync, otherwise null. + */ + public getSavedSyncToken(): Promise { + 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 { + return Promise.resolve(); + } + + public getOutOfBandMembers(): Promise { + return Promise.resolve(null); + } + + public setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise { + return Promise.resolve(); + } + + public clearOutOfBandMembers(): Promise { + return Promise.resolve(); + } + + public getClientOptions(): Promise { + return Promise.resolve(undefined); + } + + public storeClientOptions(options: IStoredClientOpts): Promise { + return Promise.resolve(); + } + + public async getPendingEvents(roomId: string): Promise[]> { + return []; + } + + public setPendingEvents(roomId: string, events: Partial[]): Promise { + return Promise.resolve(); + } + + public async saveToDeviceBatches(batch: ToDeviceBatch[]): Promise { + return Promise.resolve(); + } + + public getOldestToDeviceBatch(): Promise { + return Promise.resolve(null); + } + + public async removeToDeviceBatch(id: number): Promise { + 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 new file mode 100644 index 0000000..fef03d7 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/sync-accumulator.ts @@ -0,0 +1,715 @@ +/* +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; + 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; + "org.matrix.msc3773.unread_thread_notifications"?: Record; +} + +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; + [Category.Invite]: Record; + [Category.Leave]: Record; +} + +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; + + "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; + _accountData: { [eventType: string]: IMinimalEvent }; + _unreadNotifications: Partial; + _unreadThreadNotifications?: Record>; + _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 = {}; // $event_type: Object + private inviteRooms: Record = {}; // $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(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> + > = 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>, 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 new file mode 100644 index 0000000..dc5217c --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/sync.ts @@ -0,0 +1,1898 @@ +/* +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 or 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 & { + 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 = null; + private currentSyncRequest?: Promise; + 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; + private connectionReturnedDefer?: IDeferred; + 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 { + 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(Method.Get, "/sync", qps as any, undefined, { + localTimeoutMs, + }); + + let leaveRooms: WrappedRoom[] = []; + 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 { + 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( + 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 | undefined, error: Error): Promise { + // 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 { + // 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 => { + 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(err)) return; + // wait for saved sync to complete before doing anything else, + // otherwise the sync state will end up being incorrect + debuglog("Waiting for saved sync before retrying push rules..."); + await this.recoverFromSyncStartupError(this.savedSyncPromise, err); + return this.getPushRules(); // try again + } + }; + + 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 => { + 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(err)) return {}; + // wait for saved sync to complete before doing anything else, + // otherwise the sync state will end up being incorrect + debuglog("Waiting for saved sync before retrying filter..."); + await this.recoverFromSyncStartupError(this.savedSyncPromise, err); + return this.getFilter(); // try again + } + return { filter, filterId }; + }; + + private savedSyncPromise?: Promise; + + /** + * Main entry point + */ + public async sync(): Promise { + 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 explicitly 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 { + 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 { + 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(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, 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 { + const qps = this.getSyncParams(syncOptions, syncToken); + return this.client.http.authedRequest(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 { + 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 { + 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>((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(); + 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[] = []; + let joinRooms: WrappedRoom[] = []; + let leaveRooms: WrappedRoom[] = []; + + 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(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(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 { + 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( + obj: Record, + ): Array> { + // 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 { + // 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): 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 new file mode 100644 index 0000000..be64c3b --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/timeline-window.ts @@ -0,0 +1,507 @@ +/* +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. + * + *

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. + * + *

Before the window is useful, it must be initialised by calling {@link TimelineWindow#load}. + * + *

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 { + // 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): 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 + * + *

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 { + // 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; + + // 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 new file mode 100644 index 0000000..0c3aea7 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/utils.ts @@ -0,0 +1,770 @@ +/* +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(); + +/** + * 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; + +/** + * 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 { + const o: Record = {}; + 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 { + 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(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(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(ms: number, value?: T): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms, value); + }); +} + +/** + * Promise/async version of {@link setImmediate}. + */ +export function immediate(): Promise { + return new Promise(setImmediate); +} + +export function isNullOrUndefined(val: any): boolean { + return val === null || val === undefined; +} + +export interface IDeferred { + resolve: (value: T | Promise) => void; + reject: (reason?: any) => void; + promise: Promise; +} + +// Returns a Deferred +export function defer(): IDeferred { + let resolve!: IDeferred["resolve"]; + let reject!: IDeferred["reject"]; + + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + + return { resolve, reject, promise }; +} + +export async function promiseMapSeries( + promises: Array>, + fn: (t: T) => Promise | undefined, // if async we don't care about the type as we only await resolution +): Promise { + for (const o of promises) { + await fn(await o); + } +} + +export function promiseTry(fn: () => T | Promise): Promise { + return Promise.resolve(fn()); +} + +// Creates and awaits all promises, running no more than `chunkSize` at the same time +export async function chunkPromises(fns: (() => Promise)[], chunkSize: number): Promise { + const results: T[] = []; + for (let i = 0; i < fns.length; i += chunkSize) { + results.push(...(await Promise.all(fns.slice(i, i + chunkSize).map((fn) => fn())))); + } + return results; +} + +/** + * 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(promiseFn: (attempt: number) => Promise): Promise { + 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>( + 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(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(x: Map, y: Map, 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 { + const targetMap = new Map(); + + for (const [key, value] of map) { + targetMap.set(key, processMapToObjectValue(value)); + } + + return Object.fromEntries(targetMap.entries()); +} + +export function unsafeProp(prop: K): boolean { + return prop === "__proto__" || prop === "prototype" || prop === "constructor"; +} + +export function safeSet(obj: Record, 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): boolean { + return !( + unsafeProp(event.room_id) || + unsafeProp(event.sender) || + unsafeProp(event.user_id) || + unsafeProp(event.event_id) + ); +} + +export class MapWithDefault extends Map { + 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 new file mode 100644 index 0000000..7cf3ed3 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/audioContext.ts @@ -0,0 +1,44 @@ +/* +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 new file mode 100644 index 0000000..cd75c10 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/call.ts @@ -0,0 +1,2962 @@ +/* +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 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT 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; + opponentDeviceId?: string; + opponentSessionId?: string; + groupCallId?: string; +} + +interface TurnServer { + urls: Array; + 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; +} + +/** + * 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 { + 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; + // A queue for candidates waiting to go out. + // We try to amalgamate candidates into a single candidate message where + // possible + private candidateSendQueue: Array = []; + private candidateSendTries = 0; + private candidatesEnded = false; + private feeds: Array = []; + + // our transceivers for each purpose and type of media + private transceivers = new Map(); + + 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; + private inviteTimeout?: ReturnType; + private readonly removeTrackListeners = new Map 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; + + // 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(); + + private remoteAssertedIdentity?: AssertedIdentity; + private remoteSDPStreamMetadata?: SDPStreamMetadata; + + private callLengthInterval?: ReturnType; + 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; + // 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 { + 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 { + 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 { + return this.feeds; + } + + /** + * Returns an array of all local CallFeeds + * @returns local CallFeeds + */ + public getLocalFeeds(): Array { + return this.feeds.filter((feed) => feed.isLocal()); + } + + /** + * Returns an array of all remote CallFeeds + * @returns remote CallFeeds + */ + public getRemoteFeeds(): Array { + return this.feeds.filter((feed) => !feed.isLocal()); + } + + private async initOpponentCrypto(): Promise { + 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 { + if (this.callHasEnded()) { + return this.callStatsAtEnd; + } + + return this.collectCallStats(); + } + + private async collectCallStats(): Promise { + // This happens when the call fails before it starts. + // For example when we fail to get capture sources + if (!this.peerConn) return; + + const statsReport = await this.peerConn.getStats(); + const stats: 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 { + const invite = event.getContent(); + this.direction = CallDirection.Inbound; + + // make sure we have valid turn creds. Unless something's gone wrong, it should + // poll and keep the credentials valid so this should be instant. + const haveTurnCreds = await this.client.checkTurnServers(); + if (!haveTurnCreds) { + logger.warn( + `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 { + 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(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 { + // 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), + 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 { + // 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 { + 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 { + 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 { + 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, all of the tracks need to be muted + * for this to return true. This means if there are no video tracks, this will + * return true. + * @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 { + 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, all of the tracks need to be muted + * for this to return true. This means if there are no audio tracks, this will + * return true. + * @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 { + 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 { + 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).name == "UnknownDeviceError") { + code = CallErrorCode.UnknownDevices; + message = "Unknown devices present in the room"; + } + this.emit(CallEvent.Error, new CallError(code, message, error), this); + throw error; + } + + // error handler re-throws so this won't happen on error, but + // we don't want the same error handling on the candidate queue + this.sendCandidateQueue(); + } + + 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(); + const codecToPayloadTypeMap = new Map(); + for (const rtp of media.rtp) { + payloadTypeToCodecMap.set(rtp.payload, rtp.codec); + codecToPayloadTypeMap.set(rtp.codec, rtp.payload); + } + + for (const mod of mods) { + if (mod.mediaType !== media.type) continue; + + if (!codecToPayloadTypeMap.has(mod.codec)) { + logger.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 { + const offer = await this.peerConn!.createOffer(); + this.mungeSdp(offer, getCodecParamMods(this.isPtt)); + return offer; + } + + private async createAnswer(): Promise { + const answer = await this.peerConn!.createAnswer(); + this.mungeSdp(answer, getCodecParamMods(this.isPtt)); + return answer; + } + + private async gotCallFeedsForAnswer(callFeeds: CallFeed[]): Promise { + 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 { + if (this.callHasEnded()) { + //debuglog("Ignoring remote ICE candidate because call has ended"); + return; + } + + const content = ev.getContent(); + 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 { + const content = event.getContent(); + 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 { + if (this.direction !== CallDirection.Inbound) { + logger.warn( + `Call ${this.callId} onSelectAnswerReceived() got select_answer for an outbound call: ignoring`, + ); + return; + } + + const selectedPartyId = event.getContent().selected_party_id; + + if (selectedPartyId === undefined || selectedPartyId === null) { + logger.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 { + const content = event.getContent(); + 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(); + const metadata = content[SDPStreamMetadataKey]; + this.updateRemoteSDPStreamMetadata(metadata); + } + + public async onAssertedIdentityReceived(event: MatrixEvent): Promise { + const content = event.getContent(); + if (!content.asserted_identity) return; + + this.remoteAssertedIdentity = { + id: content.asserted_identity.id, + displayName: content.asserted_identity.display_name, + }; + this.emit(CallEvent.AssertedIdentityChanged, this); + } + + 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 { + 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 { + 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).name == "UnknownDeviceError") { + code = CallErrorCode.UnknownDevices; + message = "Unknown devices present in the room"; + } + + this.emit(CallEvent.Error, new CallError(code, message, error), this); + this.terminate(CallParty.Local, code, false); + + // no need to carry on & send the candidate queue, but we also + // don't want to rethrow the error + return; + } + + this.sendCandidateQueue(); + if (this.state === CallState.CreateOffer) { + this.inviteOrAnswerSent = true; + this.state = CallState.InviteSent; + this.inviteTimeout = setTimeout(() => { + this.inviteTimeout = undefined; + if (this.state === CallState.InviteSent) { + this.hangup(CallErrorCode.InviteTimeout, false); + } + }, CALL_TIMEOUT_MS); + } + } + + 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 => { + 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 { + 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([[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 { + // 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 { + 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 { + 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 { + 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), 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 { + 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(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 { + 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(); + + 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 { + 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 { + 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, 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, +): 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 new file mode 100644 index 0000000..4ee183a --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callEventHandler.ts @@ -0,0 +1,425 @@ +/* +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; + public callEventBuffer: MatrixEvent[]; + public nextSeqByCall: Map = new Map(); + public toDeviceEventBuffers: Map> = new Map(); + + private client: MatrixClient; + private candidateEventsByCall: Map>; + private eventBufferPromiseChain?: Promise; + + public constructor(client: MatrixClient) { + this.client = client; + this.calls = new Map(); + // The sync code always emits one event at a time, so it will patiently + // wait for us to finish processing a call invite before delivering the + // next event, even if that next event is a hangup. We therefore accumulate + // all our call events and then process them on the 'sync' event, ie. + // each time a sync has completed. This way, we can avoid emitting incoming + // call events if we get both the invite and answer/hangup in the same sync. + // This happens quite often, eg. replaying sync from storage, catchup sync + // after loading and after we've been offline for a bit. + this.callEventBuffer = []; + this.candidateEventsByCall = new Map>(); + } + + 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 { + await Promise.all(eventBuffer.map((event) => this.client.decryptEventIfNeeded(event))); + + const callEvents = eventBuffer.filter((event) => { + const eventType = event.getType(); + return eventType.startsWith("m.call.") || eventType.startsWith("org.matrix.call."); + }); + + const ignoreCallIds = new Set(); + + // inspect the buffer and mark all calls which have been answered + // or hung up before passing them to the call event handler. + for (const event of callEvents) { + const eventType = event.getType(); + + if (eventType === 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 { + 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 new file mode 100644 index 0000000..f06ed5b --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callEventTypes.ts @@ -0,0 +1,92 @@ +// 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 new file mode 100644 index 0000000..505cf56 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callFeed.ts @@ -0,0 +1,361 @@ +/* +Copyright 2021 Šimon Brandner + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT 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 { + 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; + 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 new file mode 100644 index 0000000..c0896c4 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCall.ts @@ -0,0 +1,1598 @@ +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>; + +/** + * 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>) => void; + /** + * Fires whenever an error occurs when call.js encounters an issue with setting up the call. + *

+ * The error given will have a code equal to either `MatrixCall.ERR_LOCAL_OFFER_FAILED` or + * `MatrixCall.ERR_NO_USER_MEDIA`. `ERR_LOCAL_OFFER_FAILED` is emitted when the local client + * fails to create an offer. `ERR_NO_USER_MEDIA` is emitted when the user has denied access + * to their audio/video hardware. + * @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) => void; + [GroupCallStatsReportEvent.ByteSentStats]: (report: GroupCallStatsReport) => void; +}; + +export enum GroupCallErrorCode { + NoUserMedia = "no_user_media", + UnknownDevice = "unknown_device", + PlaceCallFailed = "place_call_failed", +} + +export interface GroupCallStatsReport { + 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>(); // user_id -> device_id -> MatrixCall + private callHandlers = new Map>(); // user_id -> device_id -> ICallHandlers + private activeSpeakerLoopInterval?: ReturnType; + private retryCallLoopInterval?: ReturnType; + private retryCallCounts: Map> = new Map(); // user_id -> device_id -> count + private reEmitter: ReEmitter; + private transmitTimer: ReturnType | null = null; + private participantsExpirationTimer: ReturnType | null = null; + private resendMemberStateTimer: ReturnType | null = null; + private initWithAudioMuted = false; + private initWithVideoMuted = false; + private initCallFeedPromise?: Promise; + + 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 { + 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>(); + + /** + * The current participants in the call, as a map from members to device IDs + * to participant info. + */ + public get participants(): Map> { + return this._participants; + } + + private set participants(value: Map>) { + const prevValue = this._participants; + const participantStateEqual = (x: ParticipantState, y: ParticipantState): boolean => + x.sessionId === y.sessionId && x.screensharing === y.screensharing; + const deviceMapsEqual = (x: Map, y: Map): 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 => { + const updates: Promise[] = []; + 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 { + // 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[] = []; + 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 { + 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(); + 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(); + + 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>(); + const now = Date.now(); + const entered = this.state === GroupCallState.Entered || this.enteredViaAnotherSession; + let nextExpiration = Infinity; + + for (const e of this.getMemberStateEvents()) { + const member = this.room.getMember(e.getStateKey()!); + const content = e.getContent>(); + const calls: Record[] = Array.isArray(content["m.calls"]) ? content["m.calls"] : []; + const call = calls.find((call) => call["m.call_id"] === this.groupCallId); + const devices: Record[] = 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(); + 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 { + const now = Date.now(); + const localUserId = this.client.getUserId()!; + + const event = this.getMemberStateEvents(localUserId); + const content = event?.getContent>() ?? {}; + const calls: Record[] = Array.isArray(content["m.calls"]) ? content["m.calls"] : []; + + let call: Record | null = null; + const otherCalls: Record[] = []; + 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[] = 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 { + 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 { + // 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 { + const { devices: myDevices } = await this.client.getDevices(); + const deviceMap = new Map(myDevices.map((d) => [d.device_id, d])); + + // updateDevices takes care of filtering out inactive devices for us + await this.updateDevices((devices) => { + const newDevices = devices.filter((d) => { + const device = deviceMap.get(d.device_id); + return ( + device?.last_seen_ts !== undefined && + !( + d.device_id === this.client.getDeviceId()! && + this.state !== GroupCallState.Entered && + !this.enteredViaAnotherSession + ) + ); + }); + + // Skip the update if the devices are unchanged + return newDevices.length === devices.length ? null : newDevices; + }); + } + + 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 new file mode 100644 index 0000000..08487bd --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCallEventHandler.ts @@ -0,0 +1,232 @@ +/* +Copyright 2021 Šimon Brandner + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT 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; + resolve?: () => void; +} + +export class GroupCallEventHandler { + public groupCalls = new Map(); // roomId -> GroupCall + + // All rooms we know about and whether we've seen a 'Room' event + // for them. The promise will be fulfilled once we've processed that + // event which means we're "up to date" on what calls are in a room + // and get + private roomDeferreds = new Map(); + + public constructor(private client: MatrixClient) {} + + public async start(): Promise { + // 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((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((resolve) => { + resolveFunc = resolve; + }), + }; + deferred.resolve = resolveFunc!; + this.roomDeferreds.set(roomId, deferred); + } + + return deferred; + } + + public waitUntilRoomReadyForGroupCalls(roomId: string): Promise { + 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 new file mode 100644 index 0000000..7f65835 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/mediaHandler.ts @@ -0,0 +1,469 @@ +/* +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 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT 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; + + 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 { + 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 { + 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 { + 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 { + 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 { + if (this.userMediaStreams.length === 0) return; + + const callMediaStreamParams: Map = 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 { + 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 { + 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 { + // 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 { + 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 { + 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 new file mode 100644 index 0000000..dbde6e5 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/connectionStats.ts @@ -0,0 +1,47 @@ +/* +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 new file mode 100644 index 0000000..c43b9b4 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/connectionStatsReporter.ts @@ -0,0 +1,28 @@ +/* +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 new file mode 100644 index 0000000..6d8c566 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/groupCallStats.ts @@ -0,0 +1,64 @@ +/* +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; + private readonly gatherers: Map = new Map(); + 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 new file mode 100644 index 0000000..e606051 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaSsrcHandler.ts @@ -0,0 +1,57 @@ +/* +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(), remote: new Map() }; + + 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(); + 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 { + 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 new file mode 100644 index 0000000..32580b1 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackHandler.ts @@ -0,0 +1,71 @@ +/* +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 new file mode 100644 index 0000000..69ee9bd --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackStats.ts @@ -0,0 +1,104 @@ +/* +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 new file mode 100644 index 0000000..6fb119c --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackStatsHandler.ts @@ -0,0 +1,86 @@ +/* +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(); + + 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 { + 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 new file mode 100644 index 0000000..56d6c4b --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReport.ts @@ -0,0 +1,56 @@ +/* +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 { + // 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; + remote: Map; +} + +export interface FramerateMap { + local: Map; + remote: Map; +} + +export interface CodecMap { + local: Map; + remote: Map; +} 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 new file mode 100644 index 0000000..c1af471 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportBuilder.ts @@ -0,0 +1,110 @@ +/* +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): 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(), + remote: new Map(), + }; + const framerates: FramerateMap = { local: new Map(), remote: new Map() }; + const codecs: CodecMap = { local: new Map(), remote: new Map() }; + + 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 new file mode 100644 index 0000000..cf01470 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportEmitter.ts @@ -0,0 +1,33 @@ +/* +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 { + 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 new file mode 100644 index 0000000..769ba6e --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportGatherer.ts @@ -0,0 +1,183 @@ +/* +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(), remote: new Map() }; + + 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 { + 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(); + + 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 new file mode 100644 index 0000000..c658fa6 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsValueFormatter.ts @@ -0,0 +1,27 @@ +/* +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 new file mode 100644 index 0000000..1f6fcd6 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/trackStatsReporter.ts @@ -0,0 +1,117 @@ +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 new file mode 100644 index 0000000..2b6e975 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/transportStats.ts @@ -0,0 +1,26 @@ +/* +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 new file mode 100644 index 0000000..d419a73 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/transportStatsReporter.ts @@ -0,0 +1,48 @@ +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; + } +} -- cgit