snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
1
ee/packages/federation-matrix/.env.example
Normal file
1
ee/packages/federation-matrix/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
ENTERPRISE_LICENSE_RC1=
|
||||
2
ee/packages/federation-matrix/.gitignore
vendored
Normal file
2
ee/packages/federation-matrix/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.nyc_output
|
||||
logs/
|
||||
992
ee/packages/federation-matrix/CHANGELOG.md
Normal file
992
ee/packages/federation-matrix/CHANGELOG.md
Normal file
@@ -0,0 +1,992 @@
|
||||
# @rocket.chat/federation-matrix
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#41717](https://github.com/RocketChat/Rocket.Chat/pull/41717)) Fixes federation endpoints rejecting valid requests, which broke:
|
||||
|
||||
- room history backfill
|
||||
- image thumbnails
|
||||
- room message pagination
|
||||
- accepting an invite from another homeserver
|
||||
|
||||
- <details><summary>Updated dependencies [097884fb750bb68396a299230e2f20219dfe0e5a, 4947601bbf042cd1b2385f8f5dda438e608faea7, 0869925e52ca61a440a01a6646935b89af8c7aae, 18f5eb45d098ca64d403fdae4704211000efba29, b89a8d411ef65f6931a5fd1cd057740bc00cd9ba, 6dc66fbe33b7dae6e386e51dadb56b0a70994e80, 20528861308cee6561444fd970c9a080013a7ecf, 5deefe2766132f13118f38dc42cf1c9895208961, 8984df841e1ecc46198e073982adabc1f069c352, 7f9592dc92567993f41d0e3510ecc9c00637a4cc, a9f3e6cd3f31eac17bec626287fcf815d802e9b9, 742009a09148e33d141f50691cdec0f7e9818535]:</summary>
|
||||
|
||||
- @rocket.chat/rest-typings@8.8.0
|
||||
- @rocket.chat/core-typings@8.8.0
|
||||
- @rocket.chat/models@2.5.0
|
||||
- @rocket.chat/core-services@0.16.0
|
||||
- @rocket.chat/network-broker@0.2.39
|
||||
</details>
|
||||
|
||||
## 0.2.1-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#41717](https://github.com/RocketChat/Rocket.Chat/pull/41717)) Fixes federation endpoints rejecting valid requests, which broke:
|
||||
|
||||
- room history backfill
|
||||
- image thumbnails
|
||||
- room message pagination
|
||||
- accepting an invite from another homeserver
|
||||
|
||||
- <details><summary>Updated dependencies [097884fb750bb68396a299230e2f20219dfe0e5a, 4947601bbf042cd1b2385f8f5dda438e608faea7, 0869925e52ca61a440a01a6646935b89af8c7aae, 18f5eb45d098ca64d403fdae4704211000efba29, b89a8d411ef65f6931a5fd1cd057740bc00cd9ba, 6dc66fbe33b7dae6e386e51dadb56b0a70994e80, 20528861308cee6561444fd970c9a080013a7ecf, 5deefe2766132f13118f38dc42cf1c9895208961, 8984df841e1ecc46198e073982adabc1f069c352, 7f9592dc92567993f41d0e3510ecc9c00637a4cc, a9f3e6cd3f31eac17bec626287fcf815d802e9b9, 742009a09148e33d141f50691cdec0f7e9818535]:</summary>
|
||||
|
||||
- @rocket.chat/rest-typings@8.8.0-rc.0
|
||||
- @rocket.chat/core-typings@8.8.0-rc.0
|
||||
- @rocket.chat/models@2.5.0-rc.0
|
||||
- @rocket.chat/core-services@0.16.0-rc.0
|
||||
- @rocket.chat/license@1.2.0
|
||||
- @rocket.chat/emitter@0.33.0
|
||||
- @rocket.chat/http-router@7.9.23
|
||||
- @rocket.chat/network-broker@0.2.39-rc.0
|
||||
</details>
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#39324](https://github.com/RocketChat/Rocket.Chat/pull/39324)) Adds support for running Rocket.Chat in FIPS mode. The monolith and all microservices (ddp-streamer, account-service, authorization-service, presence-service, queue-worker, omnichannel-transcript) can now enforce FIPS-compliant cryptography via Node.js/OpenSSL FIPS, with dedicated FIPS Docker images. Running in FIPS mode requires a license including the new `fips` module, and FIPS status is now reported in server logs and statistics.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [c7aff48a40a9a78924cbf27fd38930c536ee11e5, 5f92f9a27dca70d506d919351612bd32dc04241a, 13b4a7b2dc203959b77b3b0c5f154d3e34fe2058, 4b34bd62f2ac8d51efd2f48caea7092e87f30ce7, 1bf84cbe288df03fc622fbddbc0e434bda291c2f, 4b57346a59b5c9433c25845c886be11af1bf71d4, 8d8cd01d0a4e6872ed543320c966efd52140e884, 6ebabce27a153d36ea8426f3257ffff880e820bc, ffe1b646226eeeda5a4d4697c831e568ec1eec64, 3cd7db677a72521439b564dca7a4ca6d6c3a1c07, 719e3db9734708e812ceb33483ffaa2e064b4d59, b2b5edf5b37be3ee070290553f6aec42c39c98f6, 74d6cacb77cc2b029dfd1a12890cfcae7fd540cb, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 615ae2bf74bba0402e0151d9c0b8e4f8dd04cb17, 1cc7bbdef9330899a8207d3d55130f48321d68f1]:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.7.0
|
||||
- @rocket.chat/models@2.4.0
|
||||
- @rocket.chat/rest-typings@8.7.0
|
||||
- @rocket.chat/core-services@0.15.0
|
||||
- @rocket.chat/license@1.2.0
|
||||
- @rocket.chat/network-broker@0.2.38
|
||||
</details>
|
||||
|
||||
## 0.2.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#39324](https://github.com/RocketChat/Rocket.Chat/pull/39324)) Adds support for running Rocket.Chat in FIPS mode. The monolith and all microservices (ddp-streamer, account-service, authorization-service, presence-service, queue-worker, omnichannel-transcript) can now enforce FIPS-compliant cryptography via Node.js/OpenSSL FIPS, with dedicated FIPS Docker images. Running in FIPS mode requires a license including the new `fips` module, and FIPS status is now reported in server logs and statistics.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [c7aff48a40a9a78924cbf27fd38930c536ee11e5, 5f92f9a27dca70d506d919351612bd32dc04241a, 13b4a7b2dc203959b77b3b0c5f154d3e34fe2058, 4b34bd62f2ac8d51efd2f48caea7092e87f30ce7, 1bf84cbe288df03fc622fbddbc0e434bda291c2f, 4b57346a59b5c9433c25845c886be11af1bf71d4, 8d8cd01d0a4e6872ed543320c966efd52140e884, 6ebabce27a153d36ea8426f3257ffff880e820bc, ffe1b646226eeeda5a4d4697c831e568ec1eec64, 3cd7db677a72521439b564dca7a4ca6d6c3a1c07, 719e3db9734708e812ceb33483ffaa2e064b4d59, b2b5edf5b37be3ee070290553f6aec42c39c98f6, 74d6cacb77cc2b029dfd1a12890cfcae7fd540cb, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 615ae2bf74bba0402e0151d9c0b8e4f8dd04cb17, 1cc7bbdef9330899a8207d3d55130f48321d68f1]:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.7.0-rc.0
|
||||
- @rocket.chat/models@2.4.0-rc.0
|
||||
- @rocket.chat/rest-typings@8.7.0-rc.0
|
||||
- @rocket.chat/core-services@0.15.0-rc.0
|
||||
- @rocket.chat/license@1.2.0-rc.0
|
||||
- @rocket.chat/network-broker@0.2.38-rc.0
|
||||
</details>
|
||||
|
||||
## 0.1.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#41046](https://github.com/RocketChat/Rocket.Chat/pull/41046)) Fixes an issue where editing or deleting a message in a federated room caused subsequent messages to stop syncing between servers
|
||||
|
||||
Note: this prevents the issue from happening, but does not restore rooms that are already affected. Recovering those requires a separate, one-time repair.
|
||||
|
||||
- ([#40839](https://github.com/RocketChat/Rocket.Chat/pull/40839)) Fixes an issue where `description` was incorrectly being used as alternative text for image attachments
|
||||
|
||||
- <details><summary>Updated dependencies [73e12e1707baea845395e0582892f65456598672, a7279cebc73edfa4b991eb593730c08e8f5e9001, 7380c44c751eff9ee624d80bf26370411ffed78b, f4f361234f00bd44efe348df4355e9d3cf80efe0, 9a36221f1fbf5ca417325204637c9f32fe760443, f57901d91feaccedd00dee65b78775b20235825b, f57901d91feaccedd00dee65b78775b20235825b, f57901d91feaccedd00dee65b78775b20235825b, 9a36221f1fbf5ca417325204637c9f32fe760443, 9a36221f1fbf5ca417325204637c9f32fe760443, fa685d0ddfdf1167705a58b5d846a993144e3734, 6bd9182ae1d914a55e70866db43e8d2038f7be28, 6fa5378a940cbc809800b3c7d7c0639810bb0ab8, f63b965f82b0ddc590c633706f7c31c8c5251b53]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.3.1
|
||||
- @rocket.chat/core-services@0.14.2
|
||||
- @rocket.chat/core-typings@8.6.0
|
||||
- @rocket.chat/rest-typings@8.6.0
|
||||
- @rocket.chat/network-broker@0.2.37
|
||||
</details>
|
||||
|
||||
## 0.1.6-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#41046](https://github.com/RocketChat/Rocket.Chat/pull/41046)) Fixes an issue where editing or deleting a message in a federated room caused subsequent messages to stop syncing between servers
|
||||
|
||||
Note: this prevents the issue from happening, but does not restore rooms that are already affected. Recovering those requires a separate, one-time repair.
|
||||
|
||||
- <details><summary>Updated dependencies [6fa5378a940cbc809800b3c7d7c0639810bb0ab8]:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.6.0-rc.1
|
||||
- @rocket.chat/rest-typings@8.6.0-rc.1
|
||||
</details>
|
||||
|
||||
## 0.1.6-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#40839](https://github.com/RocketChat/Rocket.Chat/pull/40839)) Fixes an issue where `description` was incorrectly being used as alternative text for image attachments
|
||||
|
||||
- <details><summary>Updated dependencies [73e12e1707baea845395e0582892f65456598672, a7279cebc73edfa4b991eb593730c08e8f5e9001, 7380c44c751eff9ee624d80bf26370411ffed78b, f4f361234f00bd44efe348df4355e9d3cf80efe0, 9a36221f1fbf5ca417325204637c9f32fe760443, f57901d91feaccedd00dee65b78775b20235825b, f57901d91feaccedd00dee65b78775b20235825b, f57901d91feaccedd00dee65b78775b20235825b, 9a36221f1fbf5ca417325204637c9f32fe760443, 9a36221f1fbf5ca417325204637c9f32fe760443, fa685d0ddfdf1167705a58b5d846a993144e3734, 6bd9182ae1d914a55e70866db43e8d2038f7be28, f63b965f82b0ddc590c633706f7c31c8c5251b53]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.3.1-rc.0
|
||||
- @rocket.chat/core-services@0.14.2-rc.0
|
||||
- @rocket.chat/core-typings@8.6.0-rc.0
|
||||
- @rocket.chat/rest-typings@8.6.0-rc.0
|
||||
- @rocket.chat/network-broker@0.2.37-rc.0
|
||||
</details>
|
||||
|
||||
## 0.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#40938](https://github.com/RocketChat/Rocket.Chat/pull/40938) by [@dionisio-bot](https://github.com/dionisio-bot)) Fixes an issue where `description` was incorrectly being used as alternative text for image attachments
|
||||
|
||||
- <details><summary>Updated dependencies [01a184640f635866bf4c1c612696acc4eed62311]:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.5.1
|
||||
- @rocket.chat/rest-typings@8.5.1
|
||||
</details>
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [f7d47dd3517ec14ca2ec5c3c95fcdf9e1e2fb8b0, 4c3984593017d59edd631bf8ae4f35f9d3c3db36, b6b04aadfcc8558f888b334e37c46a77e5816237, 4704bf81ca370f120af32185a7c55407a26f8514, 12897e25d0dc25b7373f5264d38f38a5a7444257, e45585b70a3a7b75434c88e4b2ea9af0a0764a76, b1c2668b74bfb49ebaefe2f581b2f8be5d4d1dd6]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.3.0
|
||||
- @rocket.chat/rest-typings@8.5.0
|
||||
- @rocket.chat/core-typings@8.5.0
|
||||
- @rocket.chat/network-broker@0.2.36
|
||||
- @rocket.chat/core-services@0.14.1
|
||||
</details>
|
||||
|
||||
## 0.1.4-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [f7d47dd3517ec14ca2ec5c3c95fcdf9e1e2fb8b0, 4c3984593017d59edd631bf8ae4f35f9d3c3db36, ae9f740d6af20557eac61b4af902c868b4132b49, b6b04aadfcc8558f888b334e37c46a77e5816237, 4704bf81ca370f120af32185a7c55407a26f8514, 12897e25d0dc25b7373f5264d38f38a5a7444257, e45585b70a3a7b75434c88e4b2ea9af0a0764a76, b1c2668b74bfb49ebaefe2f581b2f8be5d4d1dd6]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.3.0-rc.0
|
||||
- @rocket.chat/rest-typings@8.5.0-rc.0
|
||||
- @rocket.chat/core-typings@8.5.0-rc.0
|
||||
- @rocket.chat/core-services@0.14.1-rc.0
|
||||
- @rocket.chat/network-broker@0.2.36-rc.0
|
||||
</details>
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#40114](https://github.com/RocketChat/Rocket.Chat/pull/40114)) Fixes a bug preventing users to join a room after being banned and unbanned.
|
||||
|
||||
- <details><summary>Updated dependencies [21cd54f87de5837fe6c999a44bd15be34b9fe905, 278b84f78360e53792a2e5d7620615039a0e15e9, 12c44d2db65af9f90c741da621164f5738fc19f5, 24b3671fe61b8b09c6a1b5dc6401b503b3fb92a0, 9713af36f5c0d673f2d2093015f322341706bab0, f3fa3ee2f2e53b777de9abd466f1b76a1ec1b96c, c544b805d1c03f8eae9d061cd48838206207a7c9, 4e99ff6c1a99ac569cf444f9de9b5fe5ac3262b2, 8c0e16ca29b393cfa50b425520db48ba5a74f678, f4dfb8ddc2049692371aeb084110b5768151b5df]:</summary>
|
||||
|
||||
- @rocket.chat/core-services@0.14.0
|
||||
- @rocket.chat/models@2.2.0
|
||||
- @rocket.chat/core-typings@8.4.0
|
||||
- @rocket.chat/rest-typings@8.4.0
|
||||
- @rocket.chat/network-broker@0.2.35
|
||||
- @rocket.chat/license@1.1.16
|
||||
- @rocket.chat/http-router@7.9.23
|
||||
</details>
|
||||
|
||||
## 0.1.3-rc.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.4.0-rc.2
|
||||
- @rocket.chat/rest-typings@8.4.0-rc.2
|
||||
- @rocket.chat/license@1.1.16-rc.2
|
||||
- @rocket.chat/core-services@0.14.0-rc.2
|
||||
- @rocket.chat/http-router@7.9.23-rc.2
|
||||
- @rocket.chat/models@2.2.0-rc.2
|
||||
- @rocket.chat/network-broker@0.2.35-rc.2
|
||||
</details>
|
||||
|
||||
## 0.1.3-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-services@0.14.0-rc.1
|
||||
- @rocket.chat/network-broker@0.2.35-rc.1
|
||||
- @rocket.chat/core-typings@8.4.0-rc.1
|
||||
- @rocket.chat/rest-typings@8.4.0-rc.1
|
||||
- @rocket.chat/license@1.1.16-rc.1
|
||||
- @rocket.chat/http-router@7.9.23-rc.1
|
||||
- @rocket.chat/models@2.2.0-rc.1
|
||||
</details>
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-services@0.13.4
|
||||
- @rocket.chat/core-typings@8.3.2
|
||||
- @rocket.chat/rest-typings@8.3.2
|
||||
- @rocket.chat/license@1.1.15
|
||||
- @rocket.chat/http-router@7.9.22
|
||||
- @rocket.chat/models@2.1.4
|
||||
- @rocket.chat/network-broker@0.2.34
|
||||
</details>
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.3.1
|
||||
- @rocket.chat/rest-typings@8.3.1
|
||||
- @rocket.chat/license@1.1.14
|
||||
- @rocket.chat/core-services@0.13.3
|
||||
- @rocket.chat/http-router@7.9.21
|
||||
- @rocket.chat/models@2.1.3
|
||||
- @rocket.chat/network-broker@0.2.33
|
||||
</details>
|
||||
|
||||
## 0.1.3-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#40114](https://github.com/RocketChat/Rocket.Chat/pull/40114)) Fixes a bug preventing users to join a room after being banned and unbanned.
|
||||
|
||||
- <details><summary>Updated dependencies [21cd54f87de5837fe6c999a44bd15be34b9fe905, 278b84f78360e53792a2e5d7620615039a0e15e9, 12c44d2db65af9f90c741da621164f5738fc19f5, 24b3671fe61b8b09c6a1b5dc6401b503b3fb92a0, 9713af36f5c0d673f2d2093015f322341706bab0, f3fa3ee2f2e53b777de9abd466f1b76a1ec1b96c, c544b805d1c03f8eae9d061cd48838206207a7c9, 4e99ff6c1a99ac569cf444f9de9b5fe5ac3262b2, 8c0e16ca29b393cfa50b425520db48ba5a74f678, f4dfb8ddc2049692371aeb084110b5768151b5df]:</summary>
|
||||
|
||||
- @rocket.chat/core-services@0.14.0-rc.0
|
||||
- @rocket.chat/models@2.2.0-rc.0
|
||||
- @rocket.chat/core-typings@8.4.0-rc.0
|
||||
- @rocket.chat/rest-typings@8.4.0-rc.0
|
||||
- @rocket.chat/network-broker@0.2.33-rc.0
|
||||
- @rocket.chat/license@1.1.14-rc.0
|
||||
- @rocket.chat/http-router@7.9.21-rc.0
|
||||
</details>
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#39750](https://github.com/RocketChat/Rocket.Chat/pull/39750)) Adds support to name changes on federated rooms
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#39752](https://github.com/RocketChat/Rocket.Chat/pull/39752)) Fixes an issue on Federation where all domains ending with the pattern where being allowed to communicate, the feature is meant to work with a list, url by url
|
||||
|
||||
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
|
||||
|
||||
- <details><summary>Updated dependencies [602b20a8c570b895eb296ecfe39c9b7fcb12fabd, e2068892bf1ffc88b15ab71ad743cf84e5d31ed5, cd2fc208d351032c0b729755af4886665dca08b6, a4e3c1635d55ec4ce04cbde741426770e43581fb, 539659af22bc19880eda047dfc0b152472ccb65c, 5518503736b72674753e711ba4089d177ab988a5, a4341ec67d1f0413f30bbabfd292d1b0a41728b2, 40253146de8d8f83737e71b0ade7c67e0c295a28, 803b8075514de54c9ff34ba0c9aa3ee5fc3bbe61, 1361a1f4f1e3c0cc3f2a191cef8eccc12a714cde, 2a2701098536b32143003be8d267891978c708c9, 78e37dc3deae4ff05f5e33f9134c7094fd6c1330, 37acece030bc9f39bdaa86ab0130eb818332033e, d8baf395181b70fef9ce448eb509f65b66049615, ddc0ed34b03072362d166f1160104a9332b362e8, d83a1a9753464ee916845b3c88757bbcf76884a5, eae3fb3136bd0b48294c050a71b0a36d05ca02b0, 4c2e444216efd514ab406fe8e9cd127ef971d566, 722df6f60bc86c51b204e28a39acb3dc8710bdeb, 78b3fe3ef20e3a545b84551ba3f85cb40e862ba7, 98a6c58a38c053c60db2b4d53a9df0e94fecf0ba, 29b453e1def8092a8d78c28736e2bfb24229717b, 39f2e87e1caa6842e69155f033205cfdc4767b9e, c117492ad90d291a361eedc929506f557495caf7, 7c7324184589a15bf3e67b4f0c1cc222f8d48db3]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.1.2
|
||||
- @rocket.chat/rest-typings@8.3.0
|
||||
- @rocket.chat/network-broker@0.2.32
|
||||
- @rocket.chat/core-services@0.13.2
|
||||
- @rocket.chat/core-typings@8.3.0
|
||||
- @rocket.chat/http-router@7.9.20
|
||||
- @rocket.chat/license@1.1.13
|
||||
</details>
|
||||
|
||||
## 0.1.0-rc.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.3.0-rc.4
|
||||
- @rocket.chat/rest-typings@8.3.0-rc.4
|
||||
- @rocket.chat/license@1.1.13-rc.4
|
||||
- @rocket.chat/core-services@0.13.2-rc.4
|
||||
- @rocket.chat/http-router@7.9.20-rc.4
|
||||
- @rocket.chat/models@2.1.2-rc.4
|
||||
- @rocket.chat/network-broker@0.2.32-rc.4
|
||||
</details>
|
||||
|
||||
## 0.1.0-rc.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.3.0-rc.3
|
||||
- @rocket.chat/rest-typings@8.3.0-rc.3
|
||||
- @rocket.chat/license@1.1.13-rc.3
|
||||
- @rocket.chat/core-services@0.13.2-rc.3
|
||||
- @rocket.chat/http-router@7.9.20-rc.3
|
||||
- @rocket.chat/models@2.1.2-rc.3
|
||||
- @rocket.chat/network-broker@0.2.32-rc.3
|
||||
</details>
|
||||
|
||||
## 0.1.0-rc.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.3.0-rc.2
|
||||
- @rocket.chat/rest-typings@8.3.0-rc.2
|
||||
- @rocket.chat/license@1.1.13-rc.2
|
||||
- @rocket.chat/core-services@0.13.2-rc.2
|
||||
- @rocket.chat/http-router@7.9.20-rc.2
|
||||
- @rocket.chat/models@2.1.2-rc.2
|
||||
- @rocket.chat/network-broker@0.2.32-rc.2
|
||||
</details>
|
||||
|
||||
## 0.1.0-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.3.0-rc.1
|
||||
- @rocket.chat/rest-typings@8.3.0-rc.1
|
||||
- @rocket.chat/license@1.1.13-rc.1
|
||||
- @rocket.chat/core-services@0.13.2-rc.1
|
||||
- @rocket.chat/http-router@7.9.20-rc.1
|
||||
- @rocket.chat/models@2.1.2-rc.1
|
||||
- @rocket.chat/network-broker@0.2.32-rc.1
|
||||
</details>
|
||||
|
||||
## 0.1.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#39750](https://github.com/RocketChat/Rocket.Chat/pull/39750)) Adds support to name changes on federated rooms
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#39752](https://github.com/RocketChat/Rocket.Chat/pull/39752)) Fixes an issue on Federation where all domains ending with the pattern where being allowed to communicate, the feature is meant to work with a list, url by url
|
||||
|
||||
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
|
||||
|
||||
- <details><summary>Updated dependencies [602b20a8c570b895eb296ecfe39c9b7fcb12fabd, e2068892bf1ffc88b15ab71ad743cf84e5d31ed5, cd2fc208d351032c0b729755af4886665dca08b6, a4e3c1635d55ec4ce04cbde741426770e43581fb, 539659af22bc19880eda047dfc0b152472ccb65c, 5518503736b72674753e711ba4089d177ab988a5, a4341ec67d1f0413f30bbabfd292d1b0a41728b2, 40253146de8d8f83737e71b0ade7c67e0c295a28, 803b8075514de54c9ff34ba0c9aa3ee5fc3bbe61, 1361a1f4f1e3c0cc3f2a191cef8eccc12a714cde, 2a2701098536b32143003be8d267891978c708c9, 78e37dc3deae4ff05f5e33f9134c7094fd6c1330, 37acece030bc9f39bdaa86ab0130eb818332033e, d8baf395181b70fef9ce448eb509f65b66049615, ddc0ed34b03072362d166f1160104a9332b362e8, d83a1a9753464ee916845b3c88757bbcf76884a5, eae3fb3136bd0b48294c050a71b0a36d05ca02b0, 4c2e444216efd514ab406fe8e9cd127ef971d566, 722df6f60bc86c51b204e28a39acb3dc8710bdeb, 78b3fe3ef20e3a545b84551ba3f85cb40e862ba7, 98a6c58a38c053c60db2b4d53a9df0e94fecf0ba, 29b453e1def8092a8d78c28736e2bfb24229717b, 39f2e87e1caa6842e69155f033205cfdc4767b9e, c117492ad90d291a361eedc929506f557495caf7, 7c7324184589a15bf3e67b4f0c1cc222f8d48db3]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.1.2-rc.0
|
||||
- @rocket.chat/rest-typings@8.3.0-rc.0
|
||||
- @rocket.chat/network-broker@0.2.32-rc.0
|
||||
- @rocket.chat/core-services@0.13.2-rc.0
|
||||
- @rocket.chat/core-typings@8.3.0-rc.0
|
||||
- @rocket.chat/http-router@7.9.20-rc.0
|
||||
- @rocket.chat/license@1.1.13-rc.0
|
||||
</details>
|
||||
|
||||
## 0.0.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.2.1
|
||||
- @rocket.chat/rest-typings@8.2.1
|
||||
- @rocket.chat/license@1.1.12
|
||||
- @rocket.chat/core-services@0.13.1
|
||||
- @rocket.chat/http-router@7.9.19
|
||||
- @rocket.chat/models@2.1.1
|
||||
- @rocket.chat/network-broker@0.2.31
|
||||
</details>
|
||||
|
||||
## 0.0.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#38596](https://github.com/RocketChat/Rocket.Chat/pull/38596)) Adjusts the minimum supported MongoDB version from 8.2 (Rapid Release with short support lifecycle) to 8, ensuring stable and long-term compatibility
|
||||
|
||||
- <details><summary>Updated dependencies [d3758a7d57ab602745369ef9d2ccdbf9271cf305, fbc4935dec220495201cf905017170d3cd1e275c, e57f15845e4df048dd2f08f11aa08215780a2c34, 11e1c51f0867a35c69ce9b6eeca25dbbe2c71872, 88da141f3c2af6f91980c7ca8b8777161f99a068, 1c474580b768358b49c93002b1277e7065df02fe, 75d089ca40248af963d7cd2a8034c3c6de6b971e, 3b003e6b69c11b280d55bcc8db2f3e4ae7a4a573, 87faec13b3c0efc3e85627f9b70c4561b7231416, 508b4a17d76dc1cd7d3a55bdba826216f51432e2, 123aebec2caa74b17d2b5dcbd2a2db2e687cf3ac]:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.2.0
|
||||
- @rocket.chat/models@2.1.0
|
||||
- @rocket.chat/core-services@0.13.0
|
||||
- @rocket.chat/rest-typings@8.2.0
|
||||
- @rocket.chat/http-router@7.9.18
|
||||
- @rocket.chat/license@1.1.11
|
||||
- @rocket.chat/network-broker@0.2.30
|
||||
</details>
|
||||
|
||||
## 0.0.13-rc.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.2.0-rc.2
|
||||
- @rocket.chat/rest-typings@8.2.0-rc.2
|
||||
- @rocket.chat/license@1.1.11-rc.2
|
||||
- @rocket.chat/core-services@0.13.0-rc.2
|
||||
- @rocket.chat/http-router@7.9.18-rc.2
|
||||
- @rocket.chat/models@2.1.0-rc.2
|
||||
- @rocket.chat/network-broker@0.2.30-rc.2
|
||||
</details>
|
||||
|
||||
## 0.0.13-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.2.0-rc.1
|
||||
- @rocket.chat/rest-typings@8.2.0-rc.1
|
||||
- @rocket.chat/license@1.1.11-rc.1
|
||||
- @rocket.chat/core-services@0.13.0-rc.1
|
||||
- @rocket.chat/http-router@7.9.18-rc.1
|
||||
- @rocket.chat/models@2.1.0-rc.1
|
||||
- @rocket.chat/network-broker@0.2.30-rc.1
|
||||
</details>
|
||||
|
||||
## 0.0.13-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#38596](https://github.com/RocketChat/Rocket.Chat/pull/38596)) Adjusts the minimum supported MongoDB version from 8.2 (Rapid Release with short support lifecycle) to 8, ensuring stable and long-term compatibility
|
||||
|
||||
- <details><summary>Updated dependencies [d3758a7d57ab602745369ef9d2ccdbf9271cf305, fbc4935dec220495201cf905017170d3cd1e275c, e57f15845e4df048dd2f08f11aa08215780a2c34, 11e1c51f0867a35c69ce9b6eeca25dbbe2c71872, 88da141f3c2af6f91980c7ca8b8777161f99a068, 1c474580b768358b49c93002b1277e7065df02fe, 75d089ca40248af963d7cd2a8034c3c6de6b971e, 3b003e6b69c11b280d55bcc8db2f3e4ae7a4a573, 87faec13b3c0efc3e85627f9b70c4561b7231416, 508b4a17d76dc1cd7d3a55bdba826216f51432e2, 123aebec2caa74b17d2b5dcbd2a2db2e687cf3ac]:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.2.0-rc.0
|
||||
- @rocket.chat/models@2.1.0-rc.0
|
||||
- @rocket.chat/core-services@0.13.0-rc.0
|
||||
- @rocket.chat/rest-typings@8.2.0-rc.0
|
||||
- @rocket.chat/http-router@7.9.18-rc.0
|
||||
- @rocket.chat/license@1.1.11-rc.0
|
||||
- @rocket.chat/network-broker@0.2.30-rc.0
|
||||
</details>
|
||||
|
||||
## 0.0.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.1.1
|
||||
- @rocket.chat/rest-typings@8.1.1
|
||||
- @rocket.chat/license@1.1.10
|
||||
- @rocket.chat/core-services@0.12.3
|
||||
- @rocket.chat/http-router@7.9.17
|
||||
- @rocket.chat/models@2.0.3
|
||||
- @rocket.chat/network-broker@0.2.29
|
||||
</details>
|
||||
|
||||
## 0.0.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [f4fa393fccb6abefbe6fb6550563e8ca21fc792d, 7b51ba84340480fa28e85c8f35d9d2f947d3250e, fd319d58a621d66c20029fd962ddb24a1a24ecc3, 5c3c52b1fc8b0bd52d4744614ad4d2b8e2e03937, 6654c5b481f91bdcb03d68ee0f3a12d58201137e, 020dfbcab6a940ca2e03d802d0f6b54714a34aa2, 5fa150953b86ff36face25083ed49e8c97a8044d, 05c415b94cb91907de39a39c6d277579258f334e, ccd76942bf74322f21deb855af5348856463b82a]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.0.2
|
||||
- @rocket.chat/core-services@0.12.2
|
||||
- @rocket.chat/rest-typings@8.1.0
|
||||
- @rocket.chat/core-typings@8.1.0
|
||||
- @rocket.chat/license@1.1.9
|
||||
- @rocket.chat/http-router@7.9.16
|
||||
- @rocket.chat/network-broker@0.2.28
|
||||
</details>
|
||||
|
||||
## 0.0.11-rc.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-services@0.12.2-rc.2
|
||||
- @rocket.chat/core-typings@8.1.0-rc.2
|
||||
- @rocket.chat/rest-typings@8.1.0-rc.2
|
||||
- @rocket.chat/network-broker@0.2.28-rc.2
|
||||
- @rocket.chat/license@1.1.9-rc.2
|
||||
- @rocket.chat/http-router@7.9.16-rc.2
|
||||
- @rocket.chat/models@2.0.2-rc.2
|
||||
</details>
|
||||
|
||||
## 0.0.11-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [fd319d58a621d66c20029fd962ddb24a1a24ecc3]:</summary>
|
||||
|
||||
- @rocket.chat/rest-typings@8.1.0-rc.1
|
||||
- @rocket.chat/core-services@0.12.1-rc.1
|
||||
- @rocket.chat/http-router@7.9.15-rc.1
|
||||
- @rocket.chat/models@2.0.1-rc.1
|
||||
- @rocket.chat/network-broker@0.2.27-rc.1
|
||||
- @rocket.chat/core-typings@8.1.0-rc.1
|
||||
- @rocket.chat/license@1.1.8-rc.1
|
||||
</details>
|
||||
|
||||
## 0.0.11-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [f4fa393fccb6abefbe6fb6550563e8ca21fc792d, 7b51ba84340480fa28e85c8f35d9d2f947d3250e, 5c3c52b1fc8b0bd52d4744614ad4d2b8e2e03937, 6654c5b481f91bdcb03d68ee0f3a12d58201137e, 020dfbcab6a940ca2e03d802d0f6b54714a34aa2, 5fa150953b86ff36face25083ed49e8c97a8044d, 05c415b94cb91907de39a39c6d277579258f334e, ccd76942bf74322f21deb855af5348856463b82a]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.0.1-rc.0
|
||||
- @rocket.chat/core-services@0.12.1-rc.0
|
||||
- @rocket.chat/rest-typings@8.1.0-rc.0
|
||||
- @rocket.chat/core-typings@8.1.0-rc.0
|
||||
- @rocket.chat/license@1.1.8-rc.0
|
||||
- @rocket.chat/http-router@7.9.15-rc.0
|
||||
- @rocket.chat/network-broker@0.2.27-rc.0
|
||||
</details>
|
||||
|
||||
## 0.0.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.0.1
|
||||
- @rocket.chat/rest-typings@8.0.1
|
||||
- @rocket.chat/license@1.1.8
|
||||
- @rocket.chat/core-services@0.12.1
|
||||
- @rocket.chat/http-router@7.9.15
|
||||
- @rocket.chat/models@2.0.1
|
||||
- @rocket.chat/network-broker@0.2.27
|
||||
</details>
|
||||
|
||||
## 0.0.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#37791](https://github.com/RocketChat/Rocket.Chat/pull/37791)) Fixes an issue where cases of invites that were canceled or disinvited were not being handled.
|
||||
|
||||
- ([#37677](https://github.com/RocketChat/Rocket.Chat/pull/37677)) Fixes an issue where membership updates were not reflected when the user was the first member on their own server.
|
||||
|
||||
- <details><summary>Updated dependencies [347b8f973440f3e2239f79c00c2d9b430859eef2, b802430fbfdc7fa69a976468dc6dee6f3c4de26f, 872da49986436d2efa65fc42e416b45d706fd59c, 176d5eae3fb249d7d20c3e260d9fadc1a56a2fca, ac11ea05ffadeca978c794ff38d5199d9acb2c29, 1baa03cced8f94584da1224ad59cad86f219707a, f3f0b273ab49e9d1048ba43d52eb36005274905e, ac11ea05ffadeca978c794ff38d5199d9acb2c29, a1d65f493fdb039d34ef4f65d243a97931763f4b, ddc935727e9a7275813006d9dcaa7fe866610844, a5a7343a835b04812c70699be1b13e54f0e10d48, 73d9eb2783176954f42aa2cbeda8abf1d49ac260, 476a070b0099b95e4c463ee85960c4dcfbd87120, 239f4b1171bcf448cfba345cc90c4b5cd7c21afc, dc67590d14d510b069dee074c55314c56f74bb11, be80b724a636877294b5e5baa501d070941131dd, 4aa3634186d97f4144c39f6b42a65107d3d30df0, dccdcc5b4a0da4814f72a020bc4eccb8ea2497d8, 5ac1863be4c6e82666989f4b569928c0805691ff, ec0f8b435dd12c218adffa8892737c7ced4debb8, cb3c5e3455606a045f95f168dae6ed32a387697c, 5b3f93c47a03b628d613a77005e92021cd6cee4b]:</summary>
|
||||
|
||||
- @rocket.chat/rest-typings@8.0.0
|
||||
- @rocket.chat/core-typings@8.0.0
|
||||
- @rocket.chat/models@2.0.0
|
||||
- @rocket.chat/core-services@0.12.0
|
||||
- @rocket.chat/http-router@7.9.14
|
||||
- @rocket.chat/license@1.1.7
|
||||
- @rocket.chat/network-broker@0.2.26
|
||||
</details>
|
||||
|
||||
## 0.0.9-rc.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.0.0-rc.5
|
||||
- @rocket.chat/rest-typings@8.0.0-rc.5
|
||||
- @rocket.chat/license@1.1.7-rc.5
|
||||
- @rocket.chat/core-services@0.12.0-rc.5
|
||||
- @rocket.chat/http-router@7.9.14-rc.5
|
||||
- @rocket.chat/models@2.0.0-rc.5
|
||||
- @rocket.chat/network-broker@0.2.26-rc.5
|
||||
</details>
|
||||
|
||||
## 0.0.9-rc.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.0.0-rc.4
|
||||
- @rocket.chat/rest-typings@8.0.0-rc.4
|
||||
- @rocket.chat/license@1.1.7-rc.4
|
||||
- @rocket.chat/core-services@0.12.0-rc.4
|
||||
- @rocket.chat/http-router@7.9.14-rc.4
|
||||
- @rocket.chat/models@2.0.0-rc.4
|
||||
- @rocket.chat/network-broker@0.2.26-rc.4
|
||||
</details>
|
||||
|
||||
## 0.0.9-rc.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-services@0.12.0-rc.3
|
||||
- @rocket.chat/network-broker@0.2.26-rc.3
|
||||
- @rocket.chat/core-typings@8.0.0-rc.3
|
||||
- @rocket.chat/rest-typings@8.0.0-rc.3
|
||||
- @rocket.chat/license@1.1.7-rc.3
|
||||
- @rocket.chat/http-router@7.9.14-rc.3
|
||||
- @rocket.chat/models@2.0.0-rc.3
|
||||
</details>
|
||||
|
||||
## 0.0.9-rc.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.0.0-rc.2
|
||||
- @rocket.chat/rest-typings@8.0.0-rc.2
|
||||
- @rocket.chat/license@1.1.7-rc.2
|
||||
- @rocket.chat/core-services@0.12.0-rc.2
|
||||
- @rocket.chat/http-router@7.9.14-rc.2
|
||||
- @rocket.chat/models@2.0.0-rc.2
|
||||
- @rocket.chat/network-broker@0.2.26-rc.2
|
||||
</details>
|
||||
|
||||
## 0.0.9-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.0.0-rc.1
|
||||
- @rocket.chat/rest-typings@8.0.0-rc.1
|
||||
- @rocket.chat/license@1.1.7-rc.1
|
||||
- @rocket.chat/core-services@0.12.0-rc.1
|
||||
- @rocket.chat/http-router@7.9.14-rc.1
|
||||
- @rocket.chat/models@2.0.0-rc.1
|
||||
- @rocket.chat/network-broker@0.2.26-rc.1
|
||||
</details>
|
||||
|
||||
## 0.0.9-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#37791](https://github.com/RocketChat/Rocket.Chat/pull/37791)) Fixes an issue where cases of invites that were canceled or disinvited were not being handled.
|
||||
|
||||
- ([#37677](https://github.com/RocketChat/Rocket.Chat/pull/37677)) Fixes an issue where membership updates were not reflected when the user was the first member on their own server.
|
||||
|
||||
- <details><summary>Updated dependencies [347b8f973440f3e2239f79c00c2d9b430859eef2, b802430fbfdc7fa69a976468dc6dee6f3c4de26f, 872da49986436d2efa65fc42e416b45d706fd59c, 176d5eae3fb249d7d20c3e260d9fadc1a56a2fca, ac11ea05ffadeca978c794ff38d5199d9acb2c29, 1baa03cced8f94584da1224ad59cad86f219707a, f3f0b273ab49e9d1048ba43d52eb36005274905e, ac11ea05ffadeca978c794ff38d5199d9acb2c29, a1d65f493fdb039d34ef4f65d243a97931763f4b, ddc935727e9a7275813006d9dcaa7fe866610844, a5a7343a835b04812c70699be1b13e54f0e10d48, 73d9eb2783176954f42aa2cbeda8abf1d49ac260, 476a070b0099b95e4c463ee85960c4dcfbd87120, 239f4b1171bcf448cfba345cc90c4b5cd7c21afc, dc67590d14d510b069dee074c55314c56f74bb11, be80b724a636877294b5e5baa501d070941131dd, 4aa3634186d97f4144c39f6b42a65107d3d30df0, dccdcc5b4a0da4814f72a020bc4eccb8ea2497d8, 5ac1863be4c6e82666989f4b569928c0805691ff, ec0f8b435dd12c218adffa8892737c7ced4debb8, cb3c5e3455606a045f95f168dae6ed32a387697c, 5b3f93c47a03b628d613a77005e92021cd6cee4b]:</summary>
|
||||
|
||||
- @rocket.chat/rest-typings@8.0.0-rc.0
|
||||
- @rocket.chat/core-typings@8.0.0-rc.0
|
||||
- @rocket.chat/models@2.0.0-rc.0
|
||||
- @rocket.chat/core-services@0.12.0-rc.0
|
||||
- @rocket.chat/http-router@7.9.14-rc.0
|
||||
- @rocket.chat/license@1.1.7-rc.0
|
||||
- @rocket.chat/network-broker@0.2.26-rc.0
|
||||
</details>
|
||||
|
||||
## 0.0.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [eef2b390a8e4664ad4b974e7ea579982245efce5]:</summary>
|
||||
|
||||
- @rocket.chat/core-services@0.11.2
|
||||
- @rocket.chat/network-broker@0.2.25
|
||||
- @rocket.chat/core-typings@7.13.2
|
||||
- @rocket.chat/rest-typings@7.13.2
|
||||
- @rocket.chat/license@1.1.6
|
||||
- @rocket.chat/http-router@7.9.13
|
||||
- @rocket.chat/models@1.8.2
|
||||
</details>
|
||||
|
||||
## 0.0.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.13.1
|
||||
- @rocket.chat/rest-typings@7.13.1
|
||||
- @rocket.chat/license@1.1.5
|
||||
- @rocket.chat/core-services@0.11.1
|
||||
- @rocket.chat/http-router@7.9.12
|
||||
- @rocket.chat/models@1.8.1
|
||||
- @rocket.chat/network-broker@0.2.24
|
||||
</details>
|
||||
|
||||
## 0.0.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [8596daf01ac84864caa63dd937971e557933d401, 83642cbe499ba399329449fb8cd652a0405c0795, 7f1b834a55b1240c226afde77713262da47f45dc, 5c7e8ec1de894e7b8eeb6e57b0c8a43bd22d2d46, 65fbcbed9f64004b953dd9d4182b3fccb8147339, 035e0c7c387d6c99703513c7c00bf37e86538b71]:</summary>
|
||||
|
||||
- @rocket.chat/core-services@0.11.0
|
||||
- @rocket.chat/models@1.8.0
|
||||
- @rocket.chat/core-typings@7.13.0
|
||||
- @rocket.chat/rest-typings@7.13.0
|
||||
- @rocket.chat/network-broker@0.2.23
|
||||
- @rocket.chat/license@1.1.4
|
||||
- @rocket.chat/http-router@7.9.11
|
||||
</details>
|
||||
|
||||
## 0.0.6-rc.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.13.0-rc.2
|
||||
- @rocket.chat/rest-typings@7.13.0-rc.2
|
||||
- @rocket.chat/license@1.1.4-rc.2
|
||||
- @rocket.chat/core-services@0.11.0-rc.2
|
||||
- @rocket.chat/http-router@7.9.11-rc.2
|
||||
- @rocket.chat/models@1.8.0-rc.2
|
||||
- @rocket.chat/network-broker@0.2.23-rc.2
|
||||
</details>
|
||||
|
||||
## 0.0.6-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
- @rocket.chat/core-typings@7.13.0-rc.1
|
||||
- @rocket.chat/rest-typings@7.13.0-rc.1
|
||||
- @rocket.chat/license@1.1.2-rc.1
|
||||
- @rocket.chat/core-services@0.11.0-rc.1
|
||||
- @rocket.chat/http-router@7.9.9-rc.1
|
||||
- @rocket.chat/models@1.8.0-rc.1
|
||||
- @rocket.chat/network-broker@0.2.21-rc.1
|
||||
</details>
|
||||
|
||||
## 0.0.6-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [8596daf01ac84864caa63dd937971e557933d401, 83642cbe499ba399329449fb8cd652a0405c0795, 7f1b834a55b1240c226afde77713262da47f45dc, 5c7e8ec1de894e7b8eeb6e57b0c8a43bd22d2d46, 65fbcbed9f64004b953dd9d4182b3fccb8147339, 035e0c7c387d6c99703513c7c00bf37e86538b71]:</summary>
|
||||
|
||||
- @rocket.chat/core-services@0.11.0-rc.0
|
||||
- @rocket.chat/models@1.8.0-rc.0
|
||||
- @rocket.chat/core-typings@7.13.0-rc.0
|
||||
- @rocket.chat/rest-typings@7.13.0-rc.0
|
||||
- @rocket.chat/network-broker@0.2.21-rc.0
|
||||
- @rocket.chat/license@1.1.2-rc.0
|
||||
- @rocket.chat/http-router@7.9.9-rc.0
|
||||
</details>
|
||||
|
||||
## 0.0.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.12.2
|
||||
- @rocket.chat/rest-typings@7.12.2
|
||||
- @rocket.chat/license@1.1.3
|
||||
- @rocket.chat/core-services@0.10.6
|
||||
- @rocket.chat/http-router@7.9.10
|
||||
- @rocket.chat/models@1.7.3
|
||||
- @rocket.chat/network-broker@0.2.22
|
||||
</details>
|
||||
|
||||
## 0.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [8cbd9bfd0566cbd7d86d8b40ea7d58d6ef382742]:</summary>
|
||||
|
||||
- @rocket.chat/models@1.7.2
|
||||
- @rocket.chat/core-services@0.10.5
|
||||
- @rocket.chat/core-typings@7.12.1
|
||||
- @rocket.chat/rest-typings@7.12.1
|
||||
- @rocket.chat/network-broker@0.2.21
|
||||
- @rocket.chat/license@1.1.2
|
||||
- @rocket.chat/http-router@7.9.9
|
||||
</details>
|
||||
|
||||
## 0.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [48d1442a07e860fefd6991c97e94d6a9de345afe, d166e2a1ffba4e59361d5f79e8c376fca5cbf12f, 4ab2b7fdb9314538d45abdf4474ea9d2409799be, fd4f9b2523dae23518561cdcfa8d931f87d8964d]:</summary>
|
||||
|
||||
- @rocket.chat/rest-typings@7.12.0
|
||||
- @rocket.chat/core-typings@7.12.0
|
||||
- @rocket.chat/core-services@0.10.4
|
||||
- @rocket.chat/http-router@7.9.8
|
||||
- @rocket.chat/models@1.7.1
|
||||
- @rocket.chat/license@1.1.1
|
||||
- @rocket.chat/network-broker@0.2.20
|
||||
</details>
|
||||
|
||||
## 0.0.3-rc.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.12.0-rc.4
|
||||
- @rocket.chat/rest-typings@7.12.0-rc.4
|
||||
- @rocket.chat/license@1.1.1-rc.4
|
||||
- @rocket.chat/core-services@0.10.4-rc.4
|
||||
- @rocket.chat/http-router@7.9.8-rc.4
|
||||
- @rocket.chat/models@1.7.1-rc.4
|
||||
- @rocket.chat/network-broker@0.2.20-rc.4
|
||||
</details>
|
||||
|
||||
## 0.0.3-rc.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.12.0-rc.3
|
||||
- @rocket.chat/rest-typings@7.12.0-rc.3
|
||||
- @rocket.chat/license@1.1.1-rc.3
|
||||
- @rocket.chat/core-services@0.10.4-rc.3
|
||||
- @rocket.chat/http-router@7.9.8-rc.3
|
||||
- @rocket.chat/models@1.7.1-rc.3
|
||||
- @rocket.chat/network-broker@0.2.20-rc.3
|
||||
</details>
|
||||
|
||||
## 0.0.3-rc.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.12.0-rc.2
|
||||
- @rocket.chat/rest-typings@7.12.0-rc.2
|
||||
- @rocket.chat/license@1.1.1-rc.2
|
||||
- @rocket.chat/core-services@0.10.4-rc.2
|
||||
- @rocket.chat/http-router@7.9.8-rc.2
|
||||
- @rocket.chat/models@1.7.1-rc.2
|
||||
- @rocket.chat/network-broker@0.2.20-rc.2
|
||||
</details>
|
||||
|
||||
## 0.0.3-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.12.0-rc.1
|
||||
- @rocket.chat/rest-typings@7.12.0-rc.1
|
||||
- @rocket.chat/license@1.1.1-rc.1
|
||||
- @rocket.chat/core-services@0.10.4-rc.1
|
||||
- @rocket.chat/http-router@7.9.8-rc.1
|
||||
- @rocket.chat/models@1.7.1-rc.1
|
||||
- @rocket.chat/network-broker@0.2.20-rc.1
|
||||
</details>
|
||||
|
||||
## 0.0.3-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [48d1442a07e860fefd6991c97e94d6a9de345afe, d166e2a1ffba4e59361d5f79e8c376fca5cbf12f, 4ab2b7fdb9314538d45abdf4474ea9d2409799be, fd4f9b2523dae23518561cdcfa8d931f87d8964d]:</summary>
|
||||
|
||||
- @rocket.chat/rest-typings@7.12.0-rc.0
|
||||
- @rocket.chat/core-typings@7.12.0-rc.0
|
||||
- @rocket.chat/core-services@0.10.4-rc.0
|
||||
- @rocket.chat/http-router@7.9.8-rc.0
|
||||
- @rocket.chat/models@1.7.1-rc.0
|
||||
- @rocket.chat/license@1.1.1-rc.0
|
||||
- @rocket.chat/network-broker@0.2.20-rc.0
|
||||
</details>
|
||||
|
||||
## 0.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [c102712222cc18c050f45f9279f8eafcff3e7e8a, d76a5578ed0e14fae2a041c0e09d565b28630d76, 8d2ad40c46334aaf35816e52a3b28dc2ac23ce2d, ba793542644d2b725924c4cef253e7d84bc2ff71, 3e177dbd0b65d70bce8587287ba73b30170eb0f7, c5ee569cc3120bb616001fd78687dbf623a6a504, 00611aceec7f158064863f2e829c5a6698687512, c15a47ee1d55118342d0f0ece7af77f6f41132b7, 3dfb557f723a395bc1e00f2647675968c46656bc, 276c270f34293729182e9ebab61e1ff2c35398fc, b0a4602a4461200b9872b2b073ec56fa55ecb466, 80fbb2eb421253e6066207949eaf2b41871e4428, 90a1759df57814306cbc859409fe041b33db7a39, b74ab6f18ff1343f4835975f663211b005ccc5a1]:</summary>
|
||||
|
||||
- @rocket.chat/models@1.7.0
|
||||
- @rocket.chat/rest-typings@7.11.0
|
||||
- @rocket.chat/core-services@0.10.3
|
||||
- @rocket.chat/core-typings@7.11.0
|
||||
- @rocket.chat/license@1.1.0
|
||||
- @rocket.chat/http-router@7.9.7
|
||||
- @rocket.chat/network-broker@0.2.19
|
||||
</details>
|
||||
|
||||
## 0.0.2-rc.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.11.0-rc.7
|
||||
- @rocket.chat/rest-typings@7.11.0-rc.7
|
||||
- @rocket.chat/license@1.1.0-rc.7
|
||||
- @rocket.chat/core-services@0.10.3-rc.7
|
||||
- @rocket.chat/http-router@7.9.7-rc.7
|
||||
- @rocket.chat/models@1.7.0-rc.7
|
||||
- @rocket.chat/network-broker@0.2.19-rc.7
|
||||
</details>
|
||||
|
||||
## 0.0.2-rc.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.11.0-rc.6
|
||||
- @rocket.chat/rest-typings@7.11.0-rc.6
|
||||
- @rocket.chat/license@1.1.0-rc.6
|
||||
- @rocket.chat/core-services@0.10.2-rc.6
|
||||
- @rocket.chat/http-router@7.9.6-rc.6
|
||||
- @rocket.chat/models@1.7.0-rc.6
|
||||
- @rocket.chat/network-broker@0.2.18-rc.6
|
||||
</details>
|
||||
|
||||
## 0.0.2-rc.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.11.0-rc.5
|
||||
- @rocket.chat/rest-typings@7.11.0-rc.5
|
||||
- @rocket.chat/license@1.1.0-rc.5
|
||||
- @rocket.chat/core-services@0.10.2-rc.5
|
||||
- @rocket.chat/http-router@7.9.6-rc.5
|
||||
- @rocket.chat/models@1.7.0-rc.5
|
||||
- @rocket.chat/network-broker@0.2.18-rc.5
|
||||
</details>
|
||||
|
||||
## 0.0.2-rc.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.11.0-rc.4
|
||||
- @rocket.chat/rest-typings@7.11.0-rc.4
|
||||
- @rocket.chat/license@1.1.0-rc.4
|
||||
- @rocket.chat/core-services@0.10.2-rc.4
|
||||
- @rocket.chat/http-router@7.9.6-rc.4
|
||||
- @rocket.chat/models@1.7.0-rc.4
|
||||
- @rocket.chat/network-broker@0.2.18-rc.4
|
||||
</details>
|
||||
|
||||
## 0.0.2-rc.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.11.0-rc.3
|
||||
- @rocket.chat/rest-typings@7.11.0-rc.3
|
||||
- @rocket.chat/license@1.1.0-rc.3
|
||||
- @rocket.chat/core-services@0.10.2-rc.3
|
||||
- @rocket.chat/http-router@7.9.6-rc.3
|
||||
- @rocket.chat/models@1.7.0-rc.3
|
||||
- @rocket.chat/network-broker@0.2.18-rc.3
|
||||
</details>
|
||||
|
||||
## 0.0.2-rc.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.11.0-rc.2
|
||||
- @rocket.chat/rest-typings@7.11.0-rc.2
|
||||
- @rocket.chat/license@1.1.0-rc.2
|
||||
- @rocket.chat/core-services@0.10.1-rc.2
|
||||
- @rocket.chat/http-router@7.9.5-rc.2
|
||||
- @rocket.chat/models@1.7.0-rc.2
|
||||
- @rocket.chat/network-broker@0.2.17-rc.2
|
||||
</details>
|
||||
|
||||
## 0.0.2-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.11.0-rc.1
|
||||
- @rocket.chat/rest-typings@7.11.0-rc.1
|
||||
- @rocket.chat/license@1.1.0-rc.1
|
||||
- @rocket.chat/core-services@0.10.1-rc.1
|
||||
- @rocket.chat/http-router@7.9.5-rc.1
|
||||
- @rocket.chat/models@1.7.0-rc.1
|
||||
- @rocket.chat/network-broker@0.2.17-rc.1
|
||||
</details>
|
||||
|
||||
## 0.0.2-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [c102712222cc18c050f45f9279f8eafcff3e7e8a, d76a5578ed0e14fae2a041c0e09d565b28630d76, 8d2ad40c46334aaf35816e52a3b28dc2ac23ce2d, ba793542644d2b725924c4cef253e7d84bc2ff71, 3e177dbd0b65d70bce8587287ba73b30170eb0f7, c5ee569cc3120bb616001fd78687dbf623a6a504, 00611aceec7f158064863f2e829c5a6698687512, c15a47ee1d55118342d0f0ece7af77f6f41132b7, 3dfb557f723a395bc1e00f2647675968c46656bc, 276c270f34293729182e9ebab61e1ff2c35398fc, b0a4602a4461200b9872b2b073ec56fa55ecb466, 80fbb2eb421253e6066207949eaf2b41871e4428, 90a1759df57814306cbc859409fe041b33db7a39, b74ab6f18ff1343f4835975f663211b005ccc5a1]:</summary>
|
||||
|
||||
- @rocket.chat/models@1.7.0-rc.0
|
||||
- @rocket.chat/rest-typings@7.11.0-rc.0
|
||||
- @rocket.chat/core-services@0.10.1-rc.0
|
||||
- @rocket.chat/core-typings@7.11.0-rc.0
|
||||
- @rocket.chat/license@1.1.0-rc.0
|
||||
- @rocket.chat/http-router@7.9.5-rc.0
|
||||
- @rocket.chat/network-broker@0.2.17-rc.0
|
||||
</details>
|
||||
76
ee/packages/federation-matrix/README.md
Normal file
76
ee/packages/federation-matrix/README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Federation Matrix
|
||||
|
||||
Rocket.Chat's Matrix federation integration package for cross-platform communication.
|
||||
|
||||
## Integration Tests
|
||||
|
||||
### Setup
|
||||
|
||||
Before running integration tests, add the following entries to your `/etc/hosts` file:
|
||||
|
||||
```
|
||||
127.0.0.1 element
|
||||
127.0.0.1 hs1
|
||||
127.0.0.1 rc1
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
The integration test script builds Rocket.Chat locally, starts federation services (Rocket.Chat, Synapse, MongoDB), waits for all services to be ready, then runs end-to-end tests. The script automatically handles cleanup unless you specify otherwise.
|
||||
|
||||
### Available Flags
|
||||
|
||||
- **No flags (default)**: Builds local code and runs tests
|
||||
- `--image [IMAGE]`: Uses a pre-built Docker image instead of building locally (defaults to `rocketchat/rocket.chat:latest` if no image specified)
|
||||
- `--keep-running`: Keeps containers running after tests complete for manual validation
|
||||
- `--element`: Includes Element web client in the test environment
|
||||
- `--no-test`: Starts containers and skips running tests (useful for manual testing or debugging)
|
||||
|
||||
### Usage Examples
|
||||
|
||||
**Basic local testing:**
|
||||
```bash
|
||||
yarn test:integration
|
||||
```
|
||||
|
||||
**Test with pre-built image:**
|
||||
```bash
|
||||
yarn test:integration --image
|
||||
```
|
||||
|
||||
**Test with specific pre-built image:**
|
||||
```bash
|
||||
yarn test:integration --image rocketchat/rocket.chat:latest
|
||||
```
|
||||
|
||||
**Keep services running for manual inspection:**
|
||||
```bash
|
||||
yarn test:integration --keep-running
|
||||
```
|
||||
|
||||
**Run with Element client:**
|
||||
```bash
|
||||
yarn test:integration --element
|
||||
```
|
||||
|
||||
**Start containers only (skip tests):**
|
||||
```bash
|
||||
yarn test:integration --no-test
|
||||
```
|
||||
|
||||
**Start containers with Element and keep them running (skip tests):**
|
||||
```bash
|
||||
yarn test:integration --keep-running --element --no-test
|
||||
```
|
||||
|
||||
**Combine flags:**
|
||||
```bash
|
||||
yarn test:integration --image rocketchat/rocket.chat:latest --keep-running --element
|
||||
```
|
||||
|
||||
### Service URLs (when using --keep-running or --no-test)
|
||||
|
||||
- **Rocket.Chat**: https://rc1
|
||||
- **Synapse**: https://hs1
|
||||
- **MongoDB**: localhost:27017
|
||||
- **Element**: https://element (when using --element flag)
|
||||
165
ee/packages/federation-matrix/docker-compose.test.yml
Normal file
165
ee/packages/federation-matrix/docker-compose.test.yml
Normal file
@@ -0,0 +1,165 @@
|
||||
networks:
|
||||
hs1-net:
|
||||
rc1-net:
|
||||
element-net:
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.6.6
|
||||
profiles:
|
||||
- test
|
||||
- element
|
||||
command:
|
||||
- '--api.insecure=true'
|
||||
# - "--log.level=DEBUG"
|
||||
- '--providers.docker=true'
|
||||
- '--providers.docker.exposedbydefault=false'
|
||||
- '--entrypoints.web.address=:80'
|
||||
- '--entrypoints.websecure.address=:443'
|
||||
ports:
|
||||
- '80:80'
|
||||
- '443:443'
|
||||
- '8080:8080'
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./docker-compose/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
||||
- ./docker-compose/traefik/dynamic_conf.yml:/etc/traefik/dynamic_conf.yml:ro
|
||||
- ./docker-compose/traefik/certs:/etc/traefik/certs:ro
|
||||
# - ./traefik-logs:/logs # Mount a volume for logs
|
||||
networks:
|
||||
# Defines and isolate one network per service to prevent inter service communication which
|
||||
# would not happen in real life. The name clashing between the host and service in the same
|
||||
# network (like rc1 service provided as rc1 host) is causing a bug on home server address
|
||||
# resolution which tries to communicate with the container directly some times and which does
|
||||
# not provide SSL neither the correct exposed ports.
|
||||
hs1-net:
|
||||
aliases: [hs1, rc1, rc.host]
|
||||
rc1-net:
|
||||
aliases: [hs1, rc1, rc.host]
|
||||
element-net:
|
||||
aliases: [hs1, rc1, rc.host, element]
|
||||
|
||||
# HomeServer 1 (synapse)
|
||||
hs1:
|
||||
image: matrixdotorg/synapse:v1.157.2
|
||||
profiles:
|
||||
- test
|
||||
- element
|
||||
entrypoint: |
|
||||
sh -c
|
||||
"update-ca-certificates &&
|
||||
mkdir -p /data/media_store &&
|
||||
chown -R 991:991 /data &&
|
||||
/start.py &
|
||||
until curl -sf http://localhost:8008/_matrix/client/versions; do
|
||||
echo '=====> Waiting for Synapse...';
|
||||
sleep 2;
|
||||
done;
|
||||
echo '';
|
||||
echo '=====> Running register_new_matrix_user...';
|
||||
register_new_matrix_user -u admin -p admin --admin http://localhost:8008 -c /data/homeserver.yaml | sed 's/^/=======> /';
|
||||
register_new_matrix_user -u alice -p alice --admin http://localhost:8008 -c /data/homeserver.yaml | sed 's/^/=======> /';
|
||||
register_new_matrix_user -u bob -p bob --admin http://localhost:8008 -c /data/homeserver.yaml | sed 's/^/=======> /';
|
||||
register_new_matrix_user -u cleiton -p cleiton --admin http://localhost:8008 -c /data/homeserver.yaml | sed 's/^/=======> /';
|
||||
echo '=====> Finished register_new_matrix_user.';
|
||||
wait"
|
||||
volumes:
|
||||
- ./docker-compose/hs1/homeserver.yaml:/data/homeserver.yaml
|
||||
- ./docker-compose/hs1/hs1.log.config:/data/hs1.log.config
|
||||
- ./docker-compose/hs1/hs1.signing.key:/data/hs1.signing.key
|
||||
- ./docker-compose/traefik/certs/ca:/usr/local/share/ca-certificates
|
||||
networks:
|
||||
- hs1-net
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.hs1.rule=Host(`hs1`)'
|
||||
- 'traefik.http.routers.hs1.entrypoints=websecure'
|
||||
- 'traefik.http.routers.hs1.tls=true'
|
||||
- 'traefik.http.services.hs1.loadbalancer.server.port=8008'
|
||||
|
||||
# Rocket.Chat rc1
|
||||
rc1:
|
||||
build:
|
||||
context: ${ROCKETCHAT_BUILD_CONTEXT:-./test/dist}
|
||||
dockerfile: ${ROCKETCHAT_DOCKERFILE:-../../../apps/meteor/.docker/Dockerfile.alpine}
|
||||
target: release-standard
|
||||
image: ${ROCKETCHAT_IMAGE:-rocketchat/rocket.chat:latest}
|
||||
profiles:
|
||||
- test
|
||||
- element
|
||||
environment:
|
||||
ROOT_URL: https://rc1
|
||||
PORT: 3000
|
||||
MONGO_URL: mongodb://mongo:27017/rc1?replicaSet=rs0
|
||||
NODE_EXTRA_CA_CERTS: /usr/local/share/ca-certificates/rootCA.pem
|
||||
LOG_LEVEL: debug
|
||||
ROCKETCHAT_LICENSE: ${ENTERPRISE_LICENSE_RC1}
|
||||
OVERWRITE_SETTING_Show_Setup_Wizard: completed
|
||||
OVERWRITE_SETTING_Federation_Service_Enabled: true
|
||||
OVERWRITE_SETTING_Federation_Service_Domain: rc1
|
||||
OVERWRITE_SETTING_Cloud_Workspace_Client_Id: temp_id
|
||||
OVERWRITE_SETTING_Cloud_Workspace_Client_Secret: temp_secret
|
||||
ADMIN_USERNAME: admin
|
||||
ADMIN_PASS: admin
|
||||
ADMIN_EMAIL: admin@admin.com
|
||||
TEST_MODE: api
|
||||
volumes:
|
||||
- ./docker-compose/traefik/certs/ca/rootCA.crt:/usr/local/share/ca-certificates/rootCA.pem
|
||||
networks:
|
||||
- rc1-net
|
||||
depends_on:
|
||||
- mongo
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.rc1.rule=Host(`rc1`)'
|
||||
- 'traefik.http.routers.rc1.entrypoints=websecure'
|
||||
- 'traefik.http.routers.rc1.tls=true'
|
||||
- 'traefik.http.services.rc1.loadbalancer.server.port=3000'
|
||||
# HTTPS Redirect
|
||||
- 'traefik.http.middlewares.rc1.redirectscheme.scheme=https'
|
||||
- 'traefik.http.routers.rc1-http.rule=Host(`rc1`)'
|
||||
- 'traefik.http.routers.rc1-http.middlewares=rc1'
|
||||
|
||||
mongo:
|
||||
image: mongo:${MONGODB_FEDERATION_VERSION:-8.0}
|
||||
profiles:
|
||||
- test
|
||||
- element
|
||||
restart: on-failure
|
||||
ports:
|
||||
- '27017:27017'
|
||||
entrypoint: |
|
||||
bash -c
|
||||
"mongod --replSet rs0 --bind_ip_all &
|
||||
sleep 2;
|
||||
until mongosh --eval \"db.adminCommand('ping')\"; do
|
||||
echo '=====> Waiting for Mongo...';
|
||||
sleep 1;
|
||||
done;
|
||||
echo '=====> Initiating ReplSet...';
|
||||
mongosh --eval \"rs.initiate({_id: 'rs0', members: [{ _id: 0, host: 'mongo:27017' }]})\";
|
||||
echo '=====> Initiating ReplSet done...';
|
||||
wait"
|
||||
networks:
|
||||
- rc1-net
|
||||
|
||||
element:
|
||||
image: vectorim/element-web
|
||||
profiles:
|
||||
- element
|
||||
# ports:
|
||||
# - "8080:80"
|
||||
volumes:
|
||||
- ./docker-compose/element/config.json:/app/config.json
|
||||
networks:
|
||||
- element-net
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.element.rule=Host(`element`)'
|
||||
- 'traefik.http.routers.element.entrypoints=websecure'
|
||||
- 'traefik.http.routers.element.tls=true'
|
||||
- 'traefik.http.services.element.loadbalancer.server.port=80'
|
||||
# HTTPS Redirect
|
||||
- 'traefik.http.middlewares.element.redirectscheme.scheme=https'
|
||||
- 'traefik.http.routers.element-http.rule=Host(`element`)'
|
||||
- 'traefik.http.routers.element-http.middlewares=element'
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"default_server_config": {
|
||||
"m.homeserver": {
|
||||
"base_url": "https://hs1",
|
||||
"server_name": "hs1"
|
||||
},
|
||||
"m.identity_server": {
|
||||
"base_url": "https://vector.im"
|
||||
}
|
||||
},
|
||||
"disable_password_rules": true,
|
||||
"disable_custom_urls": false,
|
||||
"disable_guests": false,
|
||||
"brand": "Element",
|
||||
"enable_presence_by_hs_url": {
|
||||
"https://hs1": false,
|
||||
"https://rc1": false
|
||||
}
|
||||
}
|
||||
154
ee/packages/federation-matrix/docker-compose/hs1/homeserver.yaml
Normal file
154
ee/packages/federation-matrix/docker-compose/hs1/homeserver.yaml
Normal file
@@ -0,0 +1,154 @@
|
||||
# Configuration file for Synapse.
|
||||
server_name: "hs1"
|
||||
pid_file: /data/hs1.pid
|
||||
|
||||
listeners:
|
||||
- port: 8008
|
||||
tls: false
|
||||
type: http
|
||||
x_forwarded: true
|
||||
resources:
|
||||
- names: [client, federation]
|
||||
|
||||
use_x_forwarded_for: true
|
||||
serve_server_wellknown: true
|
||||
|
||||
database:
|
||||
name: sqlite3
|
||||
args:
|
||||
database: /data/hs1.db
|
||||
|
||||
log_config: "/data/hs1.log.config"
|
||||
media_store_path: /data/media_store
|
||||
|
||||
registration_shared_secret: "3l5H.Y5urc5@gKwYMe2^abk@nf.U_M6iyMgP,j&OL6pcSGrUQE"
|
||||
report_stats: false
|
||||
macaroon_secret_key: "3II&OLx=,6RcC&E~iksWi:tUU4J.~reK:MdU&JAPhlaGIL*+IA"
|
||||
form_secret: "B2EuUMKfg&.BsU;#DZ9;E,baB^wc=lV--F_PwkRLZvk=LHk+U4"
|
||||
signing_key_path: "/data/hs1.signing.key"
|
||||
|
||||
trusted_key_servers: []
|
||||
# - server_name: "matrix.org"
|
||||
# accept_keys_insecurely: true
|
||||
|
||||
federation_client_minimum_tls_version: 1.2
|
||||
federation_verify_certificates: false
|
||||
suppress_key_server_warning: true
|
||||
|
||||
enable_registration: true
|
||||
enable_registration_without_verification: true
|
||||
allow_public_rooms_over_federation: true
|
||||
|
||||
password_config:
|
||||
enabled: true
|
||||
localdb_enabled: true
|
||||
policy:
|
||||
minimum_length: 1
|
||||
require_lowercase: false
|
||||
require_uppercase: false
|
||||
require_digit: false
|
||||
require_symbol: false
|
||||
|
||||
# Optionally, add your Docker network to the whitelist
|
||||
ip_range_whitelist:
|
||||
- '0.0.0.0/0'
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Development rate limits (set very high)
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
# Messaging
|
||||
rc_message:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# Registration
|
||||
rc_registration:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# Registration token validity checks
|
||||
rc_registration_token_validity:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# Room creation
|
||||
rc_room_creation:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# Login (IP, account, and failed-attempt buckets)
|
||||
rc_login:
|
||||
address:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
account:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
failed_attempts:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# Joins
|
||||
rc_joins:
|
||||
local:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
remote:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# Limit recent joins per room
|
||||
rc_joins_per_room:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# Invites
|
||||
rc_invites:
|
||||
per_room:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
per_user:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# 3PID validation (email/phone)
|
||||
rc_3pid_validation:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# To-device and key requests
|
||||
rc_to_device:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
rc_key_requests:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# Presence / typing / receipts / read markers
|
||||
rc_presence:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
rc_typing:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
rc_read_receipts:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
rc_read_markers:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# Room-admin redactions
|
||||
rc_admin_redaction:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# Federation (uses *different* keys, not per_second/burst_count)
|
||||
rc_federation:
|
||||
window_size: 10000 # ms
|
||||
sleep_limit: 10000
|
||||
sleep_delay: 0 # ms
|
||||
reject_limit: 10000
|
||||
concurrent: 10000
|
||||
# vim:ft=yaml
|
||||
@@ -0,0 +1,39 @@
|
||||
version: 1
|
||||
|
||||
formatters:
|
||||
precise:
|
||||
|
||||
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
|
||||
|
||||
|
||||
handlers:
|
||||
|
||||
|
||||
console:
|
||||
class: logging.StreamHandler
|
||||
formatter: precise
|
||||
|
||||
loggers:
|
||||
# This is just here so we can leave `loggers` in the config regardless of whether
|
||||
# we configure other loggers below (avoid empty yaml dict error).
|
||||
_placeholder:
|
||||
level: "INFO"
|
||||
|
||||
|
||||
|
||||
synapse.storage.SQL:
|
||||
# beware: increasing this to DEBUG will make synapse log sensitive
|
||||
# information such as access tokens.
|
||||
level: INFO
|
||||
|
||||
|
||||
|
||||
|
||||
root:
|
||||
level: INFO
|
||||
|
||||
|
||||
handlers: [console]
|
||||
|
||||
|
||||
disable_existing_loggers: false
|
||||
@@ -0,0 +1 @@
|
||||
ed25519 a_HDhg WntaJ4JP5WbZZjDShjeuwqCybQ5huaZAiowji7tnIEw
|
||||
18
ee/packages/federation-matrix/docker-compose/traefik/certs/.gitignore
vendored
Normal file
18
ee/packages/federation-matrix/docker-compose/traefik/certs/.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# Allow only the certificates needed for CI testing
|
||||
# Override the root .gitignore rule for *.pem
|
||||
|
||||
# Allow rc1 certificates
|
||||
!rc1.pem
|
||||
!rc1-key.pem
|
||||
|
||||
# Allow hs1 certificates
|
||||
!hs1.pem
|
||||
!hs1-key.pem
|
||||
|
||||
# Allow element certificates
|
||||
!element.pem
|
||||
!element-key.pem
|
||||
|
||||
# Keep ignoring all other .pem files
|
||||
*.pem
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFLDCCA5SgAwIBAgIRAMEZ3b7sw0tDnhAcUN+76PkwDQYJKoZIhvcNAQELBQAw
|
||||
ga0xHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTFBMD8GA1UECww4cm9j
|
||||
a2V0Y2hhdEBSb2NrZXRDaGF0cy1NYWNCb29rLVByby0yLmxvY2FsIChSb2NrZXQu
|
||||
Q2hhdCkxSDBGBgNVBAMMP21rY2VydCByb2NrZXRjaGF0QFJvY2tldENoYXRzLU1h
|
||||
Y0Jvb2stUHJvLTIubG9jYWwgKFJvY2tldC5DaGF0KTAeFw0yNDExMzAxMTQyMDVa
|
||||
Fw0zNDExMzAxMTQyMDVaMIGtMR4wHAYDVQQKExVta2NlcnQgZGV2ZWxvcG1lbnQg
|
||||
Q0ExQTA/BgNVBAsMOHJvY2tldGNoYXRAUm9ja2V0Q2hhdHMtTWFjQm9vay1Qcm8t
|
||||
Mi5sb2NhbCAoUm9ja2V0LkNoYXQpMUgwRgYDVQQDDD9ta2NlcnQgcm9ja2V0Y2hh
|
||||
dEBSb2NrZXRDaGF0cy1NYWNCb29rLVByby0yLmxvY2FsIChSb2NrZXQuQ2hhdCkw
|
||||
ggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQDUSzYDKSavpVKGEG0M2WQb
|
||||
LShnhwqx+TZRlncn85RNJNkVooovw0x4Q104Rmo3xOS2fwaKC5fgX1T+KNvTFFVz
|
||||
dyEhsGPYBSQLD27nQ1pgO5RDMsOC54dSoFLq6SbRZXpKBjiYGvB9sQ6mkSk+DWIX
|
||||
EpacbgPsmJma8pm5C03SZI8HSlUYhSKYdjF6ph/6gBeRMUu5emnx3GRYS5GMGYZJ
|
||||
XxUF2HbCYeGoubbc6UvLdtKyWmo5qL0gIVfoNTlJK/s61QzFDY9pA6ULoMBwocPh
|
||||
KW/1RFODSPYyjwVgckXGil3jcWHVpwLVdZXsOWu+2sbZpCfkXNGrasDm6NBjKJK+
|
||||
uxM8+2zz/7yZX+SOh0bVK0St8R1Khl+jLC/xwGA4tVYT04S2f+1OWT+dqfCWHNjq
|
||||
7GOcs1AQIvh8Yntx+1gwRIqcirGQCTtvzsF+zKWPmh1vip9fBkVL/6N4JK3XALiI
|
||||
c6DImZHKG/6Ezw3fCilTV/MTnXeRazRyNozigJoQd5sCAwEAAaNFMEMwDgYDVR0P
|
||||
AQH/BAQDAgIEMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFPMCCoABwDeC
|
||||
i5pWH8Fm1EKCREu1MA0GCSqGSIb3DQEBCwUAA4IBgQCzSvcM8hPFFT0bzd9VYAoF
|
||||
wKHDRJJfj3YgVY+ogfxiAvn8S/n2BBPHnPMLstGUTnNdd4Nr0QYot1H0H23QdS3m
|
||||
gHbodVmp3+Ylc51RepCOqjT4nL6nCYcXr4DrDbIExIT+YyLRlrGyChEwn0AfdvFO
|
||||
6+kfN6Jj0yT2uGTw0yz9x6Cnl0wJozhubcPEahc/lyhjxkYFA0YujNhS5ZPk33sH
|
||||
jVJC2k3vSLD/wS6vYq+4A2yUB4KlVLVLUxEakzK5fEoAkyh2wnFAKa8uVcwU2Fz5
|
||||
mEJv4KmW8SpBTQYXILCZFXLO1ON9Ge7yYFquLMAo5QVrnwVm5EFgN2+Lnw6n8h+M
|
||||
OhoPw4II46EEbMFN42aVV1jwIasIitO0eXG9XB+ZaPA64xcwOzRK2g65IhjPBrLi
|
||||
MiugUT/fILCBkogBwctcpfkm6+hbVM1xLkkcUOgX6+nGSg8KvpRPWqx7bn2uoa5E
|
||||
uT4+6tdAtAXFCdbIk8pMsfz3fFLjL5BBDtPfa+7mnyw=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,13 @@
|
||||
tls:
|
||||
certificates:
|
||||
# synapse1
|
||||
- certFile: "/etc/traefik/certs/hs1.pem"
|
||||
keyFile: "/etc/traefik/certs/hs1-key.pem"
|
||||
|
||||
# rocket.chat rc1 (container)
|
||||
- certFile: "/etc/traefik/certs/rc1.pem"
|
||||
keyFile: "/etc/traefik/certs/rc1-key.pem"
|
||||
|
||||
# element
|
||||
- certFile: "/etc/traefik/certs/element.pem"
|
||||
keyFile: "/etc/traefik/certs/element-key.pem"
|
||||
@@ -0,0 +1,20 @@
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
websecure:
|
||||
address: ":443"
|
||||
|
||||
providers:
|
||||
docker:
|
||||
exposedByDefault: false
|
||||
file:
|
||||
filename: "/etc/traefik/dynamic_conf.yml"
|
||||
|
||||
api:
|
||||
dashboard: false
|
||||
insecure: false
|
||||
|
||||
# accessLog:
|
||||
# filePath: "/logs/access.log"
|
||||
# bufferingSize: 100
|
||||
# format: json
|
||||
63
ee/packages/federation-matrix/jest.config.federation.ts
Normal file
63
ee/packages/federation-matrix/jest.config.federation.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Jest configuration for federation integration tests.
|
||||
*
|
||||
* Extends the base server preset with federation-specific settings including
|
||||
* extended timeouts for distributed system operations, proper module transformation
|
||||
* for Matrix SDK dependencies, and global teardown for resource cleanup.
|
||||
*/
|
||||
import server from '@rocket.chat/jest-presets/server';
|
||||
import type { Config } from 'jest';
|
||||
|
||||
function qaseRunTitle(): string {
|
||||
const title = ['Federation E2E Tests'];
|
||||
|
||||
if (process.env.PR_NUMBER) {
|
||||
title.push(`PR ${process.env.PR_NUMBER}`);
|
||||
}
|
||||
|
||||
if (process.env.GITHUB_RUN_ID) {
|
||||
title.push(`Run ${process.env.GITHUB_RUN_ID}`);
|
||||
}
|
||||
|
||||
title.push(new Date().toISOString());
|
||||
|
||||
return title.join(' - ');
|
||||
}
|
||||
|
||||
export default {
|
||||
preset: server.preset,
|
||||
transformIgnorePatterns: [
|
||||
'<rootDir>/node_modules/@babel',
|
||||
'<rootDir>/node_modules/@jest',
|
||||
'/node_modules/(?!marked|@testing-library|matrix-js-sdk|@vector-im)/',
|
||||
],
|
||||
// Federation-specific configuration
|
||||
testMatch: ['<rootDir>/tests/end-to-end/**/*.spec.ts'],
|
||||
testTimeout: 30000, // 30 seconds timeout for federation tests
|
||||
forceExit: true, // Force Jest to exit after tests complete
|
||||
detectOpenHandles: true, // Detect open handles that prevent Jest from exiting
|
||||
globalTeardown: '<rootDir>/tests/teardown.ts',
|
||||
verbose: false,
|
||||
silent: false,
|
||||
reporters: [
|
||||
'default',
|
||||
...(process.env.QASE_TESTOPS_JEST_API_TOKEN
|
||||
? [
|
||||
[
|
||||
'jest-qase-reporter',
|
||||
{
|
||||
mode: 'testops',
|
||||
testops: {
|
||||
api: { token: process.env.QASE_TESTOPS_JEST_API_TOKEN },
|
||||
project: process.env.QASE_PROJECT || 'RC',
|
||||
run: {
|
||||
title: qaseRunTitle(),
|
||||
complete: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
] as [string, { [x: string]: unknown }],
|
||||
]
|
||||
: []),
|
||||
] as Config['reporters'],
|
||||
} satisfies Config;
|
||||
14
ee/packages/federation-matrix/jest.config.ts
Normal file
14
ee/packages/federation-matrix/jest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import server from '@rocket.chat/jest-presets/server';
|
||||
import type { Config } from 'jest';
|
||||
|
||||
export default {
|
||||
preset: server.preset,
|
||||
transformIgnorePatterns: [
|
||||
'<rootDir>/node_modules/@babel',
|
||||
'<rootDir>/node_modules/@jest',
|
||||
// sanitize-html vendors its own (ESM-only) htmlparser2 + friends under a nested node_modules
|
||||
'/node_modules/(?!marked|@testing-library/|(dom-serializer|domelementtype|domhandler|domutils|entities|htmlparser2)/|sanitize-html/node_modules/)',
|
||||
],
|
||||
// Exclude integration/e2e tests from unit test runs
|
||||
testPathIgnorePatterns: ['<rootDir>/tests/end-to-end/'],
|
||||
} satisfies Config;
|
||||
58
ee/packages/federation-matrix/package.json
Normal file
58
ee/packages/federation-matrix/package.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@rocket.chat/federation-matrix",
|
||||
"version": "0.2.1",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"typings": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"/dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
||||
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"test": "jest",
|
||||
"test:integration": "./tests/scripts/run-integration-tests.sh",
|
||||
"test:federation": "jest --config jest.config.federation.ts",
|
||||
"testunit": "jest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rocket.chat/core-services": "workspace:^",
|
||||
"@rocket.chat/core-typings": "workspace:^",
|
||||
"@rocket.chat/emitter": "workspace:~",
|
||||
"@rocket.chat/federation-sdk": "0.7.0",
|
||||
"@rocket.chat/http-router": "workspace:^",
|
||||
"@rocket.chat/license": "workspace:^",
|
||||
"@rocket.chat/models": "workspace:^",
|
||||
"@rocket.chat/network-broker": "workspace:^",
|
||||
"@rocket.chat/rest-typings": "workspace:^",
|
||||
"emojibase": "^16.0.0",
|
||||
"emojibase-data": "^16.0.3",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"marked": "^16.1.2",
|
||||
"mem": "^8.1.1",
|
||||
"mongodb": "6.16.0",
|
||||
"pino": "10.3.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sanitize-html": "~2.17.7",
|
||||
"tsyringe": "^4.10.0",
|
||||
"tweetnacl": "^1.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rocket.chat/ddp-client": "workspace:^",
|
||||
"@types/lodash.debounce": "^4.0.9",
|
||||
"@types/node": "~24.13.2",
|
||||
"@types/sanitize-html": "~2.16.1",
|
||||
"eslint": "~9.39.5",
|
||||
"jest": "~30.2.0",
|
||||
"jest-qase-reporter": "^2.4.0",
|
||||
"matrix-js-sdk": "^38.4.0",
|
||||
"pino-pretty": "13.1.3",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"volta": {
|
||||
"extends": "../../../package.json"
|
||||
}
|
||||
}
|
||||
1216
ee/packages/federation-matrix/src/FederationMatrix.ts
Normal file
1216
ee/packages/federation-matrix/src/FederationMatrix.ts
Normal file
File diff suppressed because it is too large
Load Diff
48
ee/packages/federation-matrix/src/api/.well-known/server.ts
Normal file
48
ee/packages/federation-matrix/src/api/.well-known/server.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { Router } from '@rocket.chat/http-router';
|
||||
import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv';
|
||||
|
||||
const WellKnownServerResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
'm.server': {
|
||||
type: 'string',
|
||||
description: 'Matrix server address with port',
|
||||
},
|
||||
},
|
||||
required: ['m.server'],
|
||||
};
|
||||
|
||||
const isWellKnownServerResponseProps = ajv.compile(WellKnownServerResponseSchema);
|
||||
|
||||
// TODO: After changing the domain setting this route is still reporting the old domain until the server is restarted
|
||||
// TODO: this is wrong, is siteurl !== domain this path should return 404. this path is to discover the final address, domain being the "proxy" and siteurl the final destination, if domain is different, well-known should be served there, not here.
|
||||
export const getWellKnownRoutes = () => {
|
||||
// GET /.well-known/matrix/server
|
||||
// https://spec.matrix.org/v1.19/server-server-api/#getwell-knownmatrixserver
|
||||
return new Router('/matrix').get(
|
||||
'/server',
|
||||
{
|
||||
response: {
|
||||
200: isWellKnownServerResponseProps,
|
||||
},
|
||||
tags: ['Well-Known'],
|
||||
license: ['federation'],
|
||||
},
|
||||
async (c) => {
|
||||
const responseData = federationSDK.getWellKnownHostData();
|
||||
|
||||
const etag = createHash('sha256').update(JSON.stringify(responseData)).digest('hex');
|
||||
|
||||
c.header('ETag', etag);
|
||||
c.header('Content-Type', 'application/json');
|
||||
|
||||
return {
|
||||
body: responseData,
|
||||
statusCode: 200,
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
114
ee/packages/federation-matrix/src/api/_matrix/client/_shared.ts
Normal file
114
ee/packages/federation-matrix/src/api/_matrix/client/_shared.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { Router } from '@rocket.chat/http-router';
|
||||
import { ajv, ajvQuery } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { logger } from '../../logger';
|
||||
|
||||
export type ClientRouter = Router<'/client', any>;
|
||||
|
||||
// Logs an error and returns the matching Matrix 500 response. Use inside handler-local
|
||||
// catch blocks that swallow the error. The same message is used for both the log and
|
||||
// the response body, avoiding duplication.
|
||||
export const internalError = (msg: string, err?: unknown, context?: Record<string, unknown>) => {
|
||||
logger.error({ msg, err, ...context });
|
||||
return {
|
||||
statusCode: 500 as const,
|
||||
body: { errcode: 'M_UNKNOWN', error: msg },
|
||||
};
|
||||
};
|
||||
|
||||
// The federation SDK throws an Error with name 'UnknownRoomError' (message `Room <id> does not exist`)
|
||||
// when a room isn't known to this homeserver. The class isn't exported for `instanceof`, so we match on
|
||||
// `.name` — the same check the SDK uses internally.
|
||||
export const isUnknownRoomError = (err: unknown): err is Error => err instanceof Error && err.name === 'UnknownRoomError';
|
||||
|
||||
// Matrix response for a room this homeserver doesn't know about. Mirrors Synapse: it must be a 4xx (never
|
||||
// 5xx) so bridges like matrix-bifrost treat it as terminal and stop retrying, instead of hammering the
|
||||
// endpoint every 100ms as if it were a transient server fault.
|
||||
export const roomNotFound = () =>
|
||||
({
|
||||
statusCode: 403 as const,
|
||||
body: {
|
||||
errcode: 'M_FORBIDDEN',
|
||||
error: "You aren't a member of the room and weren't previously a member of the room.",
|
||||
},
|
||||
}) as const;
|
||||
|
||||
// Logs a warning and returns the matching Matrix 501 response. Use for endpoints/branches
|
||||
// that are deliberately not implemented yet, so hits on those paths stay visible in the logs.
|
||||
export const notImplemented = (msg: string, context?: Record<string, unknown>) => {
|
||||
logger.warn({ msg, ...context });
|
||||
return {
|
||||
statusCode: 501 as const,
|
||||
body: { errcode: 'M_UNRECOGNIZED', error: msg },
|
||||
};
|
||||
};
|
||||
|
||||
export const tags = ['Federation'];
|
||||
export const license: ['federation'] = ['federation'];
|
||||
|
||||
export const MATRIX_USER_ID_PATTERN = '^@[A-Za-z0-9_=\\/.+-]+:(.+)$';
|
||||
export const MATRIX_ROOM_ID_PATTERN = '^![A-Za-z0-9_=\\/.+-]+:(.+)$';
|
||||
|
||||
const MatrixErrorSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
errcode: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
},
|
||||
required: ['errcode', 'error'],
|
||||
};
|
||||
|
||||
export const isMatrixErrorProps = ajv.compile(MatrixErrorSchema);
|
||||
|
||||
// Matches only the literal `{}` these endpoints return per spec.
|
||||
const EmptyObjectResponseSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const isEmptyObjectResponseProps = ajv.compile(EmptyObjectResponseSchema);
|
||||
|
||||
const ImpersonationQuerySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: {
|
||||
type: 'string',
|
||||
pattern: MATRIX_USER_ID_PATTERN,
|
||||
description: 'Matrix user ID to impersonate; must be in the AS user namespace',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
};
|
||||
|
||||
export const isImpersonationQueryProps = ajvQuery.compile<{ user_id?: string }>(ImpersonationQuerySchema);
|
||||
|
||||
const RoomIdParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
|
||||
},
|
||||
required: ['roomId'],
|
||||
};
|
||||
|
||||
export const isRoomIdParamsProps = ajv.compile(RoomIdParamsSchema);
|
||||
|
||||
const UserIdParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
userId: { type: 'string', pattern: MATRIX_USER_ID_PATTERN },
|
||||
},
|
||||
required: ['userId'],
|
||||
};
|
||||
|
||||
export const isUserIdParamsProps = ajv.compile(UserIdParamsSchema);
|
||||
|
||||
const ProfileFieldParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
userId: { type: 'string', pattern: MATRIX_USER_ID_PATTERN },
|
||||
field: { type: 'string' },
|
||||
},
|
||||
required: ['userId', 'field'],
|
||||
};
|
||||
|
||||
export const isProfileFieldParamsProps = ajv.compile(ProfileFieldParamsSchema);
|
||||
218
ee/packages/federation-matrix/src/api/_matrix/client/account.ts
Normal file
218
ee/packages/federation-matrix/src/api/_matrix/client/account.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { ajv } from '@rocket.chat/rest-typings';
|
||||
|
||||
import type { ClientRouter } from './_shared';
|
||||
import { isMatrixErrorProps, license, tags } from './_shared';
|
||||
import { createOrUpdateFederatedUser } from '../../../helpers/createOrUpdateFederatedUser';
|
||||
import { decodeXmppUserId, isFullXmppUserId, parseXmppUserId } from '../../../helpers/parseXmppUserId';
|
||||
import { validateFederatedUsername } from '../../../helpers/validateFederatedUsername';
|
||||
import { logger } from '../../logger';
|
||||
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
|
||||
|
||||
const RegisterBodySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
username: { type: 'string' },
|
||||
},
|
||||
required: ['type', 'username'],
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const isRegisterBodyProps = ajv.compile(RegisterBodySchema);
|
||||
|
||||
const RegisterResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: { type: 'string' },
|
||||
home_server: { type: 'string' },
|
||||
access_token: { type: 'string' },
|
||||
},
|
||||
required: ['user_id'],
|
||||
};
|
||||
|
||||
const isRegisterResponseProps = ajv.compile(RegisterResponseSchema);
|
||||
|
||||
const WhoamiResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: { type: 'string' },
|
||||
device_id: { type: 'string' },
|
||||
is_guest: { type: 'boolean' },
|
||||
},
|
||||
required: ['user_id'],
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const isWhoamiResponseProps = ajv.compile(WhoamiResponseSchema);
|
||||
|
||||
export const addAccountRoutes = (router: ClientRouter) => {
|
||||
router
|
||||
// POST /_matrix/client/v3/register
|
||||
.post(
|
||||
'/v3/register',
|
||||
{
|
||||
body: isRegisterBodyProps,
|
||||
response: {
|
||||
200: isRegisterResponseProps,
|
||||
400: isMatrixErrorProps,
|
||||
401: isMatrixErrorProps,
|
||||
403: isMatrixErrorProps,
|
||||
501: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const body = await c.req.json();
|
||||
if (body.type !== 'm.login.application_service') {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
errcode: 'M_FORBIDDEN',
|
||||
error: 'AS registration requires auth.type=m.login.application_service',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const serverName = federationSDK.getConfig('serverName');
|
||||
|
||||
// An application service may only register users that no *other* bridge exclusively claims.
|
||||
// Registering within its own exclusive namespace is fine; another bridge's is M_EXCLUSIVE.
|
||||
const appService = c.get('appService') as ReturnType<typeof federationSDK.getRegistrationByAsToken>;
|
||||
|
||||
const isReservedByAnotherAppService = (candidate: string): boolean => {
|
||||
const mxid = candidate.startsWith('@') ? candidate : `@${candidate}:${serverName}`;
|
||||
const owner = federationSDK.isExclusiveNamespace('users', mxid);
|
||||
return Boolean(owner && owner.registration._id !== appService?.registration._id);
|
||||
};
|
||||
|
||||
const decoded = decodeXmppUserId(body.username);
|
||||
|
||||
if (!isFullXmppUserId(decoded)) {
|
||||
// The spec defines `username` as the desired localpart; normalize either form to the
|
||||
// fully-qualified MXID, which is what gets stored and what `user_id` must carry.
|
||||
const withSigil = body.username.startsWith('@') ? body.username : `@${body.username}`;
|
||||
const userId: string = withSigil.includes(':') ? withSigil : `${withSigil}:${serverName}`;
|
||||
|
||||
if (!validateFederatedUsername(userId)) {
|
||||
logger.warn({ msg: 'Malformed user id during AS registration', username: body.username });
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
errcode: 'M_INVALID_USERNAME',
|
||||
error: 'Could not derive a username from the provided user id',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isReservedByAnotherAppService(userId)) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
errcode: 'M_EXCLUSIVE',
|
||||
error: 'Username is in an exclusive namespace of another application service',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await createOrUpdateFederatedUser({
|
||||
username: userId,
|
||||
origin: serverName,
|
||||
asId: appService?.registration._id,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
user_id: userId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let decodedUsername;
|
||||
try {
|
||||
decodedUsername = parseXmppUserId(decoded);
|
||||
} catch (error) {
|
||||
logger.warn({ msg: 'Malformed XMPP user id during AS registration', username: body.username, err: error });
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
errcode: 'M_INVALID_USERNAME',
|
||||
error: 'Could not derive a username from the provided XMPP user id',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!decodedUsername.resource) {
|
||||
logger.warn({ msg: 'Could not derive resource from full XMPP user id during AS registration', username: body.username });
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
errcode: 'M_INVALID_USERNAME',
|
||||
error: 'Could not derive a username from the provided XMPP user id',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const username = `@${decodedUsername.resource}:${serverName}`;
|
||||
|
||||
if (!validateFederatedUsername(username)) {
|
||||
logger.warn({ msg: 'Malformed user id derived from XMPP user id during AS registration', username: body.username });
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
errcode: 'M_INVALID_USERNAME',
|
||||
error: 'Could not derive a username from the provided XMPP user id',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isReservedByAnotherAppService(username)) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
errcode: 'M_EXCLUSIVE',
|
||||
error: 'Username is in an exclusive namespace of another application service',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await createOrUpdateFederatedUser({
|
||||
username,
|
||||
origin: serverName,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
user_id: username,
|
||||
},
|
||||
};
|
||||
},
|
||||
)
|
||||
|
||||
// GET /_matrix/client/v3/account/whoami
|
||||
.get(
|
||||
'/v3/account/whoami',
|
||||
{
|
||||
response: {
|
||||
200: isWhoamiResponseProps,
|
||||
401: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const username = c.get('impersonatedUserId') as string;
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
user_id: username,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ajv } from '@rocket.chat/rest-typings';
|
||||
|
||||
import type { ClientRouter } from './_shared';
|
||||
import {
|
||||
MATRIX_ROOM_ID_PATTERN,
|
||||
notImplemented,
|
||||
isEmptyObjectResponseProps,
|
||||
isImpersonationQueryProps,
|
||||
isMatrixErrorProps,
|
||||
license,
|
||||
tags,
|
||||
} from './_shared';
|
||||
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
|
||||
|
||||
const RoomAliasParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomAlias: { type: 'string' },
|
||||
},
|
||||
required: ['roomAlias'],
|
||||
};
|
||||
|
||||
const isRoomAliasParamsProps = ajv.compile(RoomAliasParamsSchema);
|
||||
|
||||
const DirectoryPutBodySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
room_id: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
|
||||
},
|
||||
required: ['room_id'],
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const isDirectoryPutBodyProps = ajv.compile(DirectoryPutBodySchema);
|
||||
|
||||
export const addDirectoryRoutes = (router: ClientRouter) => {
|
||||
router
|
||||
// PUT /_matrix/client/v3/directory/room/:roomAlias
|
||||
.put(
|
||||
'/v3/directory/room/:roomAlias',
|
||||
{
|
||||
params: isRoomAliasParamsProps,
|
||||
query: isImpersonationQueryProps,
|
||||
body: isDirectoryPutBodyProps,
|
||||
response: {
|
||||
200: isEmptyObjectResponseProps,
|
||||
401: isMatrixErrorProps,
|
||||
403: isMatrixErrorProps,
|
||||
501: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async () => {
|
||||
// TODO(federation-sdk): createAlias(alias, roomId, sender)
|
||||
return notImplemented('Room alias creation not yet implemented');
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Router } from '@rocket.chat/http-router';
|
||||
|
||||
import { addAccountRoutes } from './account';
|
||||
import { addDirectoryRoutes } from './directory';
|
||||
import { addClientMediaRoutes } from './media';
|
||||
import { addPresenceRoutes } from './presence';
|
||||
import { addProfileRoutes } from './profile';
|
||||
import { addRoomsLifecycleRoutes } from './rooms-lifecycle';
|
||||
import { addRoomsMessagingRoutes } from './rooms-messaging';
|
||||
import { addRoomsStateRoutes } from './rooms-state';
|
||||
import { addUserRoutes } from './user';
|
||||
import { addVersionsRoutes } from './versions';
|
||||
|
||||
export const getClientRoutes = () => {
|
||||
const router = new Router('/client');
|
||||
|
||||
addVersionsRoutes(router);
|
||||
addAccountRoutes(router);
|
||||
addProfileRoutes(router);
|
||||
addPresenceRoutes(router);
|
||||
addDirectoryRoutes(router);
|
||||
addRoomsLifecycleRoutes(router);
|
||||
addRoomsStateRoutes(router);
|
||||
addRoomsMessagingRoutes(router);
|
||||
addUserRoutes(router);
|
||||
addClientMediaRoutes(router);
|
||||
|
||||
return router;
|
||||
};
|
||||
238
ee/packages/federation-matrix/src/api/_matrix/client/media.ts
Normal file
238
ee/packages/federation-matrix/src/api/_matrix/client/media.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { Upload } from '@rocket.chat/core-services';
|
||||
import { ajv, ajvQuery } from '@rocket.chat/rest-typings';
|
||||
|
||||
import type { ClientRouter } from './_shared';
|
||||
import { internalError, isMatrixErrorProps, license, tags } from './_shared';
|
||||
import { MatrixMediaService } from '../../../services/MatrixMediaService';
|
||||
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
|
||||
|
||||
const MediaParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
serverName: { type: 'string' },
|
||||
mediaId: { type: 'string' },
|
||||
},
|
||||
required: ['serverName', 'mediaId'],
|
||||
};
|
||||
|
||||
const isMediaParamsProps = ajv.compile(MediaParamsSchema);
|
||||
|
||||
const ThumbnailQuerySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
// union type lists rather than `oneOf`: ajvQuery coerces between number and string, so both
|
||||
// `oneOf` branches would match a numeric value and fail validation
|
||||
width: { type: ['number', 'string'] },
|
||||
height: { type: ['number', 'string'] },
|
||||
method: { type: 'string', enum: ['crop', 'scale'] },
|
||||
timeout_ms: { type: ['number', 'string'] },
|
||||
},
|
||||
};
|
||||
|
||||
const isThumbnailQueryProps = ajvQuery.compile<{
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
method?: 'crop' | 'scale';
|
||||
timeout_ms?: number | string;
|
||||
}>(ThumbnailQuerySchema);
|
||||
|
||||
const BufferResponseSchema = {
|
||||
type: 'object',
|
||||
description: 'multipart/mixed response',
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const isBufferResponseProps = ajv.compile(BufferResponseSchema);
|
||||
|
||||
const ConfigResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
'm.upload.size': { type: 'number' },
|
||||
},
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const isConfigResponseProps = ajv.compile(ConfigResponseSchema);
|
||||
|
||||
// The resize uses sharp with fit 'contain', which upscales past the original size,
|
||||
// so unbounded dimensions would let a client force arbitrarily large allocations.
|
||||
const MAX_THUMBNAIL_DIMENSION = 2048;
|
||||
|
||||
const SECURITY_HEADERS = {
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Content-Security-Policy': "default-src 'none'; img-src 'self'; media-src 'self'",
|
||||
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
|
||||
};
|
||||
|
||||
// Builds an RFC 5987-compliant Content-Disposition header. Always emits the
|
||||
// ASCII-safe `filename=` for legacy clients and additionally emits
|
||||
// `filename*=UTF-8''…` when the name contains non-ASCII characters.
|
||||
function contentDispositionHeader(disposition: 'inline' | 'attachment', fileName: string): string {
|
||||
const asciiFallback = fileName.replace(/[^\x20-\x7E]/g, '_').replace(/["\\]/g, '_');
|
||||
const isAscii = asciiFallback === fileName;
|
||||
if (isAscii) {
|
||||
return `${disposition}; filename="${asciiFallback}"`;
|
||||
}
|
||||
// RFC 5987 requires percent-encoding `*'()` too, which encodeURIComponent leaves raw;
|
||||
// a stray `'` is especially bad since it's the delimiter of the charset'lang'value syntax.
|
||||
const rfc5987Value = encodeURIComponent(fileName).replace(/[*'()]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
||||
return `${disposition}; filename="${asciiFallback}"; filename*=UTF-8''${rfc5987Value}`;
|
||||
}
|
||||
|
||||
// MSC3916 says authenticated media downloads should be multipart/mixed, but the
|
||||
// matrix-bot-sdk used by appservice bridges (e.g. matrix-bifrost) doesn't parse
|
||||
// that envelope — it just streams the response body straight through to the
|
||||
// downstream client, which then sees raw multipart text. To stay compatible
|
||||
// with those bridges, we serve raw bytes here, same as the legacy
|
||||
// /_matrix/media/v3/download endpoint.
|
||||
export const addClientMediaRoutes = (router: ClientRouter) => {
|
||||
router
|
||||
// GET /_matrix/client/v1/media/download/:serverName/:mediaId
|
||||
.get(
|
||||
'/v1/media/download/:serverName/:mediaId',
|
||||
{
|
||||
params: isMediaParamsProps,
|
||||
response: {
|
||||
200: isBufferResponseProps,
|
||||
401: isMatrixErrorProps,
|
||||
404: isMatrixErrorProps,
|
||||
500: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
try {
|
||||
const serverName = c.req.param('serverName') as string;
|
||||
const mediaId = c.req.param('mediaId') as string;
|
||||
|
||||
const file = await MatrixMediaService.getLocalFileForMatrixNode(mediaId, serverName);
|
||||
if (!file) {
|
||||
return {
|
||||
statusCode: 404,
|
||||
body: { errcode: 'M_NOT_FOUND', error: 'Media not found' },
|
||||
};
|
||||
}
|
||||
|
||||
const buffer = await MatrixMediaService.getLocalFileBuffer(file);
|
||||
const mimeType = file.type || 'application/octet-stream';
|
||||
const fileName = file.name || mediaId;
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: {
|
||||
...SECURITY_HEADERS,
|
||||
'content-type': mimeType,
|
||||
'content-length': String(buffer.length),
|
||||
'content-disposition': contentDispositionHeader('attachment', fileName),
|
||||
},
|
||||
body: buffer,
|
||||
};
|
||||
} catch (error) {
|
||||
return internalError('Failed to download media', error);
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// GET /_matrix/client/v1/media/thumbnail/:serverName/:mediaId
|
||||
.get(
|
||||
'/v1/media/thumbnail/:serverName/:mediaId',
|
||||
{
|
||||
params: isMediaParamsProps,
|
||||
query: isThumbnailQueryProps,
|
||||
response: {
|
||||
200: isBufferResponseProps,
|
||||
400: isMatrixErrorProps,
|
||||
401: isMatrixErrorProps,
|
||||
404: isMatrixErrorProps,
|
||||
500: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
try {
|
||||
const serverName = c.req.param('serverName') as string;
|
||||
const mediaId = c.req.param('mediaId') as string;
|
||||
const width = Number(c.req.query('width'));
|
||||
const height = Number(c.req.query('height'));
|
||||
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: { errcode: 'M_BAD_REQUEST', error: 'Invalid width or height' },
|
||||
};
|
||||
}
|
||||
|
||||
if (width > MAX_THUMBNAIL_DIMENSION || height > MAX_THUMBNAIL_DIMENSION) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: { errcode: 'M_BAD_REQUEST', error: 'Requested dimensions exceed maximum allowed' },
|
||||
};
|
||||
}
|
||||
|
||||
const file = await MatrixMediaService.getLocalFileForMatrixNode(mediaId, serverName);
|
||||
if (!file) {
|
||||
return {
|
||||
statusCode: 404,
|
||||
body: { errcode: 'M_NOT_FOUND', error: 'Media not found' },
|
||||
};
|
||||
}
|
||||
|
||||
if (!file.type?.startsWith('image/')) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: { errcode: 'M_BAD_REQUEST', error: 'Thumbnails are only supported for images' },
|
||||
};
|
||||
}
|
||||
|
||||
// `method` is validated but not honored: thumbnails are always scaled (the spec
|
||||
// default), never cropped. Spec-wise thumbnailing is best-effort, so serving a
|
||||
// scaled image for a `crop` request is acceptable.
|
||||
const stream = await Upload.streamUploadedFile({ file, imageResizeOpts: { width, height } });
|
||||
|
||||
const mimeType = file.type || 'image/jpeg';
|
||||
const fileName = file.name || mediaId;
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: {
|
||||
...SECURITY_HEADERS,
|
||||
'content-type': mimeType,
|
||||
'content-disposition': contentDispositionHeader('inline', fileName),
|
||||
},
|
||||
body: Readable.toWeb(stream),
|
||||
};
|
||||
} catch (error) {
|
||||
return internalError('Failed to generate media thumbnail', error);
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// GET /_matrix/client/v1/media/config
|
||||
.get(
|
||||
'/v1/media/config',
|
||||
{
|
||||
response: {
|
||||
200: isConfigResponseProps,
|
||||
401: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async () => {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
'm.upload.size': 50 * 1024 * 1024,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ajv } from '@rocket.chat/rest-typings';
|
||||
|
||||
import type { ClientRouter } from './_shared';
|
||||
import { isMatrixErrorProps, isUserIdParamsProps, license, tags } from './_shared';
|
||||
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
|
||||
|
||||
const PresenceResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
presence: { type: 'string', enum: ['online', 'offline', 'unavailable'] },
|
||||
last_active_ago: { type: 'number' },
|
||||
status_msg: { type: 'string' },
|
||||
currently_active: { type: 'boolean' },
|
||||
},
|
||||
required: ['presence'],
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const isPresenceResponseProps = ajv.compile(PresenceResponseSchema);
|
||||
|
||||
export const addPresenceRoutes = (router: ClientRouter) => {
|
||||
router.get(
|
||||
'/v3/presence/:userId/status',
|
||||
{
|
||||
params: isUserIdParamsProps,
|
||||
response: {
|
||||
200: isPresenceResponseProps,
|
||||
401: isMatrixErrorProps,
|
||||
501: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async () => {
|
||||
// TODO(federation-sdk): expose presence service via federationSDK.getPresence(userId)
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
presence: 'offline',
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
238
ee/packages/federation-matrix/src/api/_matrix/client/profile.ts
Normal file
238
ee/packages/federation-matrix/src/api/_matrix/client/profile.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import { ajv } from '@rocket.chat/rest-typings';
|
||||
|
||||
import type { ClientRouter } from './_shared';
|
||||
import {
|
||||
internalError,
|
||||
notImplemented,
|
||||
isEmptyObjectResponseProps,
|
||||
isImpersonationQueryProps,
|
||||
isMatrixErrorProps,
|
||||
isProfileFieldParamsProps,
|
||||
isUserIdParamsProps,
|
||||
license,
|
||||
tags,
|
||||
} from './_shared';
|
||||
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
|
||||
|
||||
const ProfileGetResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
displayname: { type: 'string', nullable: true },
|
||||
avatar_url: { type: 'string', nullable: true },
|
||||
},
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const isProfileGetResponseProps = ajv.compile(ProfileGetResponseSchema);
|
||||
|
||||
const DisplaynameBodySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
displayname: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['displayname'],
|
||||
};
|
||||
|
||||
const isDisplaynameBodyProps = ajv.compile(DisplaynameBodySchema);
|
||||
|
||||
const AvatarUrlBodySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
avatar_url: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['avatar_url'],
|
||||
};
|
||||
|
||||
const isAvatarUrlBodyProps = ajv.compile(AvatarUrlBodySchema);
|
||||
|
||||
const ALLOWED_PROFILE_FIELDS = ['displayname', 'avatar_url'];
|
||||
|
||||
export const addProfileRoutes = (router: ClientRouter) => {
|
||||
router
|
||||
// GET /_matrix/client/v3/profile/:userId
|
||||
.get(
|
||||
'/v3/profile/:userId',
|
||||
{
|
||||
params: isUserIdParamsProps,
|
||||
response: {
|
||||
200: isProfileGetResponseProps,
|
||||
401: isMatrixErrorProps,
|
||||
404: isMatrixErrorProps,
|
||||
500: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const userId = c.req.param('userId');
|
||||
try {
|
||||
// TODO maybe this can be a query to our models instead of going through the federation-sdk
|
||||
const profile = await federationSDK.queryProfile(userId);
|
||||
if (!profile) {
|
||||
return {
|
||||
statusCode: 404,
|
||||
body: {
|
||||
errcode: 'M_NOT_FOUND',
|
||||
error: 'Profile not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
displayname: profile.displayname,
|
||||
...(profile.avatar_url ? { avatar_url: profile.avatar_url } : {}),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return internalError('Failed to fetch profile', error, { userId });
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// GET /_matrix/client/v3/profile/:userId/:field
|
||||
.get(
|
||||
'/v3/profile/:userId/:field',
|
||||
{
|
||||
params: isProfileFieldParamsProps,
|
||||
response: {
|
||||
200: isProfileGetResponseProps,
|
||||
400: isMatrixErrorProps,
|
||||
401: isMatrixErrorProps,
|
||||
404: isMatrixErrorProps,
|
||||
500: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const userId = c.req.param('userId');
|
||||
const field = c.req.param('field');
|
||||
|
||||
if (!field || !ALLOWED_PROFILE_FIELDS.includes(field)) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: { errcode: 'M_INVALID_PARAM', error: 'Unknown profile field' },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO maybe this can be a query to our models instead of going through the federation-sdk
|
||||
const profile = await federationSDK.queryProfile(userId);
|
||||
if (!profile) {
|
||||
return {
|
||||
statusCode: 404,
|
||||
body: {
|
||||
errcode: 'M_NOT_FOUND',
|
||||
error: 'Profile not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
[field]: profile[field as keyof typeof profile],
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return internalError('Failed to fetch profile', error, { userId, field });
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// PUT /_matrix/client/v3/profile/:userId/displayname
|
||||
.put(
|
||||
'/v3/profile/:userId/displayname',
|
||||
{
|
||||
params: isUserIdParamsProps,
|
||||
query: isImpersonationQueryProps,
|
||||
body: isDisplaynameBodyProps,
|
||||
response: {
|
||||
200: isEmptyObjectResponseProps,
|
||||
401: isMatrixErrorProps,
|
||||
403: isMatrixErrorProps,
|
||||
404: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const userId = c.req.param('userId');
|
||||
const username = c.get('impersonatedUserId') as string;
|
||||
|
||||
// A bridge always targets the user it impersonates, so user_id mirrors the path
|
||||
// param (both in packed form for XMPP puppets). The exception is the bot setting
|
||||
// its own displayname, where user_id is omitted and impersonatedUserId holds the
|
||||
// bot's MXID verbatim.
|
||||
if (userId !== (c.req.query('user_id') ?? username)) {
|
||||
return {
|
||||
statusCode: 403,
|
||||
body: { errcode: 'M_FORBIDDEN', error: 'Cannot set the displayname of another user' },
|
||||
};
|
||||
}
|
||||
|
||||
const body = await c.req.json();
|
||||
|
||||
const user = await Users.findOneByUsername(username);
|
||||
if (!user) {
|
||||
return {
|
||||
statusCode: 404,
|
||||
body: {
|
||||
errcode: 'M_NOT_FOUND',
|
||||
error: 'User not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await Users.setName(user._id, body.displayname);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {},
|
||||
};
|
||||
},
|
||||
)
|
||||
|
||||
// PUT /_matrix/client/v3/profile/:userId/avatar_url
|
||||
.put(
|
||||
'/v3/profile/:userId/avatar_url',
|
||||
{
|
||||
params: isUserIdParamsProps,
|
||||
query: isImpersonationQueryProps,
|
||||
body: isAvatarUrlBodyProps,
|
||||
response: {
|
||||
200: isEmptyObjectResponseProps,
|
||||
401: isMatrixErrorProps,
|
||||
403: isMatrixErrorProps,
|
||||
404: isMatrixErrorProps,
|
||||
501: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const userId = c.req.param('userId');
|
||||
const username = c.get('impersonatedUserId') as string;
|
||||
|
||||
const user = await Users.findOneByUsername(username);
|
||||
if (!user) {
|
||||
return {
|
||||
statusCode: 404,
|
||||
body: {
|
||||
errcode: 'M_NOT_FOUND',
|
||||
error: 'User not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(federation-sdk): setUserProfile(userId, {displayname?, avatar_url?}) — global, propagates to rooms
|
||||
return notImplemented('Global profile update not yet implemented', { userId });
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,378 @@
|
||||
import { Room } from '@rocket.chat/core-services';
|
||||
import type { IRoomNativeFederated } from '@rocket.chat/core-typings';
|
||||
import type { RoomID, UserID } from '@rocket.chat/federation-sdk';
|
||||
import { federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { Rooms, Users } from '@rocket.chat/models';
|
||||
import { ajv } from '@rocket.chat/rest-typings';
|
||||
|
||||
import type { ClientRouter } from './_shared';
|
||||
import {
|
||||
MATRIX_ROOM_ID_PATTERN,
|
||||
MATRIX_USER_ID_PATTERN,
|
||||
internalError,
|
||||
isEmptyObjectResponseProps,
|
||||
isImpersonationQueryProps,
|
||||
isMatrixErrorProps,
|
||||
isRoomIdParamsProps,
|
||||
license,
|
||||
notImplemented,
|
||||
tags,
|
||||
} from './_shared';
|
||||
import { getFederatedRoomName } from '../../../helpers/getFederatedRoomName';
|
||||
import { logger } from '../../logger';
|
||||
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
|
||||
|
||||
const CreateRoomBodySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
room_alias_name: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
topic: { type: 'string' },
|
||||
visibility: { type: 'string', enum: ['public', 'private'] },
|
||||
preset: { type: 'string', enum: ['private_chat', 'trusted_private_chat', 'public_chat'] },
|
||||
invite: {
|
||||
type: 'array',
|
||||
items: { type: 'string', pattern: MATRIX_USER_ID_PATTERN },
|
||||
},
|
||||
is_direct: { type: 'boolean' },
|
||||
initial_state: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
content: { type: 'object' },
|
||||
},
|
||||
required: ['type', 'content'],
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const isCreateRoomBodyProps = ajv.compile(CreateRoomBodySchema);
|
||||
|
||||
const CreateRoomResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
room_id: { type: 'string' },
|
||||
room_alias: { type: 'string' },
|
||||
},
|
||||
required: ['room_id'],
|
||||
};
|
||||
|
||||
const isCreateRoomResponseProps = ajv.compile(CreateRoomResponseSchema);
|
||||
|
||||
const JoinParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomIdOrAlias: { type: 'string' },
|
||||
},
|
||||
required: ['roomIdOrAlias'],
|
||||
};
|
||||
|
||||
const isJoinParamsProps = ajv.compile(JoinParamsSchema);
|
||||
|
||||
const JoinResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
room_id: { type: 'string' },
|
||||
},
|
||||
required: ['room_id'],
|
||||
};
|
||||
|
||||
const isJoinResponseProps = ajv.compile(JoinResponseSchema);
|
||||
|
||||
const RoomLeaveBodySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
reason: { type: 'string' },
|
||||
},
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const isRoomLeaveBodyProps = ajv.compile(RoomLeaveBodySchema);
|
||||
|
||||
const InviteBodySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: { type: 'string', pattern: MATRIX_USER_ID_PATTERN },
|
||||
reason: { type: 'string' },
|
||||
},
|
||||
required: ['user_id'],
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const isInviteBodyProps = ajv.compile(InviteBodySchema);
|
||||
|
||||
const KickBodySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: { type: 'string', pattern: MATRIX_USER_ID_PATTERN },
|
||||
reason: { type: 'string' },
|
||||
},
|
||||
required: ['user_id'],
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const isKickBodyProps = ajv.compile(KickBodySchema);
|
||||
|
||||
const RoomLeaveParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
|
||||
},
|
||||
required: ['roomId'],
|
||||
};
|
||||
|
||||
const isRoomLeaveParamsProps = ajv.compile(RoomLeaveParamsSchema);
|
||||
|
||||
export const addRoomsLifecycleRoutes = (router: ClientRouter) => {
|
||||
router
|
||||
// POST /_matrix/client/v3/createRoom
|
||||
.post(
|
||||
'/v3/createRoom',
|
||||
{
|
||||
query: isImpersonationQueryProps,
|
||||
body: isCreateRoomBodyProps,
|
||||
response: {
|
||||
200: isCreateRoomResponseProps,
|
||||
401: isMatrixErrorProps,
|
||||
403: isMatrixErrorProps,
|
||||
500: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const senderUsername = c.get('impersonatedUserId') as UserID;
|
||||
const body = await c.req.json();
|
||||
|
||||
const serverName = federationSDK.getConfig('serverName');
|
||||
|
||||
const user = await Users.findOneByUsername(senderUsername, { projection: { _id: 1 } });
|
||||
if (!user) {
|
||||
// Mirrors Synapse: 4xx so bridges treat it as terminal (register the user and retry)
|
||||
// instead of hammering a 5xx as a transient fault.
|
||||
return {
|
||||
statusCode: 403,
|
||||
body: { errcode: 'M_FORBIDDEN', error: 'Application service has not registered this user' },
|
||||
};
|
||||
}
|
||||
|
||||
// The human-facing name supplied by the Matrix client, which may be empty or
|
||||
// contain characters RC does not allow in a room slug.
|
||||
const displayName = body.name || body.room_alias_name || '';
|
||||
|
||||
// get join room from initial_state (for now since this is what bifrost sends)
|
||||
const joinRule =
|
||||
body.initial_state?.find((e: any) => e.type === 'm.room.join_rules')?.content?.join_rule === 'public' ? 'public' : 'invite';
|
||||
|
||||
try {
|
||||
const result = await federationSDK.createRoomV2({
|
||||
name: displayName,
|
||||
alias: body.room_alias_name,
|
||||
owner: senderUsername,
|
||||
joinRule,
|
||||
});
|
||||
|
||||
// TODO after creating the federated room we must create the room for rocket.chat as well
|
||||
const room = await Rooms.findOne({ 'federation.mrid': result.room_id });
|
||||
if (!room) {
|
||||
// Derive the RC name (slug) from the Matrix room id rather than the supplied
|
||||
// name, which may be empty or contain characters RC rejects. Mirrors the invite
|
||||
// flow in events/member.ts and keeps the human-facing name in `fname`.
|
||||
const name = getFederatedRoomName(result.room_id);
|
||||
|
||||
await Room.create<IRoomNativeFederated>(user._id, {
|
||||
type: joinRule === 'public' ? 'c' : 'p',
|
||||
name,
|
||||
members: [senderUsername],
|
||||
options: {
|
||||
forceNew: true, // an invite means the room does not exist yet
|
||||
creator: user._id,
|
||||
},
|
||||
extraData: {
|
||||
federated: true,
|
||||
federation: {
|
||||
version: 1,
|
||||
mrid: result.room_id,
|
||||
origin: serverName,
|
||||
},
|
||||
fname: displayName || name,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const invitee of (body.invite ?? []) as string[]) {
|
||||
try {
|
||||
await federationSDK.inviteUserToRoom(invitee as UserID, result.room_id, senderUsername, body.is_direct);
|
||||
} catch (err) {
|
||||
// The room already exists at this point; failing the whole request would make
|
||||
// the caller believe creation failed and retry, duplicating the room.
|
||||
logger.error({ msg: 'Failed to invite user to newly created room', invitee, roomId: result.room_id, err });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
room_id: result.room_id,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return internalError('Failed to create room', error);
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// POST /_matrix/client/v3/join/:roomIdOrAlias
|
||||
.post(
|
||||
'/v3/join/:roomIdOrAlias',
|
||||
{
|
||||
params: isJoinParamsProps,
|
||||
query: isImpersonationQueryProps,
|
||||
response: {
|
||||
200: isJoinResponseProps,
|
||||
401: isMatrixErrorProps,
|
||||
403: isMatrixErrorProps,
|
||||
501: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const roomIdOrAlias = c.req.param('roomIdOrAlias') as string;
|
||||
|
||||
// TODO(federation-sdk): expose alias resolution so this endpoint can also accept room aliases
|
||||
if (!roomIdOrAlias.startsWith('!')) {
|
||||
return notImplemented('Joining a room by alias is not yet implemented', { roomIdOrAlias });
|
||||
}
|
||||
|
||||
const roomId = roomIdOrAlias as RoomID;
|
||||
|
||||
await federationSDK.joinUser(roomId, c.get('impersonatedUserId'));
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
room_id: roomId,
|
||||
},
|
||||
};
|
||||
},
|
||||
)
|
||||
|
||||
// POST /_matrix/client/v3/rooms/:roomId/leave
|
||||
.post(
|
||||
'/v3/rooms/:roomId/leave',
|
||||
{
|
||||
params: isRoomLeaveParamsProps,
|
||||
query: isImpersonationQueryProps,
|
||||
body: isRoomLeaveBodyProps,
|
||||
response: {
|
||||
200: isEmptyObjectResponseProps,
|
||||
401: isMatrixErrorProps,
|
||||
403: isMatrixErrorProps,
|
||||
404: isMatrixErrorProps,
|
||||
500: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const roomId = c.req.param('roomId') as RoomID;
|
||||
const senderUsername = c.get('impersonatedUserId') as UserID;
|
||||
|
||||
try {
|
||||
await federationSDK.leaveRoom(roomId, senderUsername);
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {},
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error?.message?.toLowerCase?.().includes('not found')) {
|
||||
return {
|
||||
statusCode: 404,
|
||||
body: {
|
||||
errcode: 'M_NOT_FOUND',
|
||||
error: 'Room not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
return internalError('Failed to leave room', error, { roomId, senderId: senderUsername });
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// POST /_matrix/client/v3/rooms/:roomId/invite
|
||||
.post(
|
||||
'/v3/rooms/:roomId/invite',
|
||||
{
|
||||
params: isRoomIdParamsProps,
|
||||
query: isImpersonationQueryProps,
|
||||
body: isInviteBodyProps,
|
||||
response: {
|
||||
200: isEmptyObjectResponseProps,
|
||||
401: isMatrixErrorProps,
|
||||
403: isMatrixErrorProps,
|
||||
500: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const roomId = c.req.param('roomId') as RoomID;
|
||||
const senderUsername = c.get('impersonatedUserId') as UserID;
|
||||
const body = await c.req.json();
|
||||
|
||||
try {
|
||||
await federationSDK.inviteUserToRoom(body.user_id as UserID, roomId, senderUsername);
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {},
|
||||
};
|
||||
} catch (error) {
|
||||
return internalError('Failed to invite user', error, { roomId, senderUsername });
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// POST /_matrix/client/v3/rooms/:roomId/kick
|
||||
.post(
|
||||
'/v3/rooms/:roomId/kick',
|
||||
{
|
||||
params: isRoomIdParamsProps,
|
||||
query: isImpersonationQueryProps,
|
||||
body: isKickBodyProps,
|
||||
response: {
|
||||
200: isEmptyObjectResponseProps,
|
||||
401: isMatrixErrorProps,
|
||||
403: isMatrixErrorProps,
|
||||
500: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const roomId = c.req.param('roomId') as RoomID;
|
||||
const senderUsername = c.get('impersonatedUserId') as UserID;
|
||||
const body = await c.req.json();
|
||||
|
||||
try {
|
||||
await federationSDK.kickUser(roomId, body.user_id as UserID, senderUsername, body.reason);
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {},
|
||||
};
|
||||
} catch (error) {
|
||||
return internalError('Failed to kick user', error, { roomId, senderUsername });
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,418 @@
|
||||
import { api, FederationMatrix, Room } from '@rocket.chat/core-services';
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { isUserNativeFederated } from '@rocket.chat/core-typings';
|
||||
import type { EventID, FileMessageContent, FileMessageType, PduForType, RoomID, UserID } from '@rocket.chat/federation-sdk';
|
||||
import { federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { Rooms, Users } from '@rocket.chat/models';
|
||||
import { ajv, ajvQuery } from '@rocket.chat/rest-typings';
|
||||
|
||||
import type { ClientRouter } from './_shared';
|
||||
import {
|
||||
MATRIX_ROOM_ID_PATTERN,
|
||||
MATRIX_USER_ID_PATTERN,
|
||||
internalError,
|
||||
notImplemented,
|
||||
isEmptyObjectResponseProps,
|
||||
isImpersonationQueryProps,
|
||||
isMatrixErrorProps,
|
||||
isRoomIdParamsProps,
|
||||
license,
|
||||
tags,
|
||||
} from './_shared';
|
||||
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
|
||||
|
||||
const SendEventParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
|
||||
eventType: { type: 'string' },
|
||||
txnId: { type: 'string' },
|
||||
},
|
||||
required: ['roomId', 'eventType', 'txnId'],
|
||||
};
|
||||
|
||||
const isSendEventParamsProps = ajv.compile(SendEventParamsSchema);
|
||||
|
||||
const SendEventBodySchema = {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const isSendEventBodyProps = ajv.compile(SendEventBodySchema);
|
||||
|
||||
const SendEventResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
event_id: { type: 'string' },
|
||||
},
|
||||
required: ['event_id'],
|
||||
};
|
||||
|
||||
const isSendEventResponseProps = ajv.compile(SendEventResponseSchema);
|
||||
|
||||
const MessagesQuerySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: { type: 'string' },
|
||||
from: { type: 'string' },
|
||||
to: { type: 'string' },
|
||||
dir: { type: 'string', enum: ['b', 'f'] },
|
||||
// a union type list rather than `oneOf`: ajvQuery coerces between number and string, so both
|
||||
// `oneOf` branches would match a numeric value and fail validation
|
||||
limit: { type: ['number', 'string'] },
|
||||
filter: { type: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
const isMessagesQueryProps = ajvQuery.compile<{
|
||||
user_id?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
dir?: 'b' | 'f';
|
||||
limit?: number | string;
|
||||
filter?: string;
|
||||
}>(MessagesQuerySchema);
|
||||
|
||||
const MessagesResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
chunk: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
||||
start: { type: 'string' },
|
||||
end: { type: 'string' },
|
||||
},
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const isMessagesResponseProps = ajv.compile(MessagesResponseSchema);
|
||||
|
||||
const TypingParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
|
||||
userId: { type: 'string', pattern: MATRIX_USER_ID_PATTERN },
|
||||
},
|
||||
required: ['roomId', 'userId'],
|
||||
};
|
||||
|
||||
const isTypingParamsProps = ajv.compile(TypingParamsSchema);
|
||||
|
||||
const TypingBodySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
typing: { type: 'boolean' },
|
||||
timeout: { type: 'number' },
|
||||
},
|
||||
required: ['typing'],
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const isTypingBodyProps = ajv.compile(TypingBodySchema);
|
||||
|
||||
const ReceiptParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
|
||||
eventId: { type: 'string' },
|
||||
},
|
||||
required: ['roomId', 'eventId'],
|
||||
};
|
||||
|
||||
const isReceiptParamsProps = ajv.compile(ReceiptParamsSchema);
|
||||
|
||||
const ReceiptBodySchema = {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const isReceiptBodyProps = ajv.compile(ReceiptBodySchema);
|
||||
|
||||
export const addRoomsMessagingRoutes = (router: ClientRouter) => {
|
||||
router
|
||||
// PUT /_matrix/client/v3/rooms/:roomId/send/:eventType/:txnId
|
||||
.put(
|
||||
'/v3/rooms/:roomId/send/:eventType/:txnId',
|
||||
{
|
||||
params: isSendEventParamsProps,
|
||||
query: isImpersonationQueryProps,
|
||||
body: isSendEventBodyProps,
|
||||
response: {
|
||||
200: isSendEventResponseProps,
|
||||
400: isMatrixErrorProps,
|
||||
401: isMatrixErrorProps,
|
||||
403: isMatrixErrorProps,
|
||||
500: isMatrixErrorProps,
|
||||
501: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const roomId = c.req.param('roomId') as RoomID;
|
||||
const eventType = c.req.param('eventType');
|
||||
const senderUsername = c.get('impersonatedUserId') as UserID;
|
||||
const body = await c.req.json();
|
||||
|
||||
if (eventType === 'org.matrix.bridge.ping') {
|
||||
try {
|
||||
const event = await federationSDK.sendCustomEvent(roomId, eventType, body, senderUsername);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
event_id: event.eventId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return internalError('Failed to send ping event', error, { roomId, senderUsername });
|
||||
}
|
||||
}
|
||||
|
||||
if (eventType !== 'm.room.message') {
|
||||
// TODO: support additional event types (m.reaction, m.room.redaction, etc.)
|
||||
return notImplemented('Only m.room.message is supported in v1', { eventType });
|
||||
}
|
||||
|
||||
if (typeof body.body !== 'string' || typeof body.msgtype !== 'string') {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
errcode: 'M_BAD_JSON',
|
||||
error: 'm.room.message requires string fields body and msgtype',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fileMsgtypes: FileMessageType[] = ['m.image', 'm.file', 'm.audio', 'm.video'];
|
||||
const isFileMessage = fileMsgtypes.includes(body.msgtype);
|
||||
|
||||
if (isFileMessage && typeof body.url !== 'string') {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
errcode: 'M_BAD_JSON',
|
||||
error: `${body.msgtype} requires a string url field`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: deduplicate by txnId to handle bridge retries
|
||||
try {
|
||||
if (isFileMessage) {
|
||||
const fileContent: FileMessageContent = {
|
||||
body: body.body,
|
||||
msgtype: body.msgtype,
|
||||
url: body.url,
|
||||
info: body.info,
|
||||
};
|
||||
const event = await federationSDK.sendFileMessage(roomId, fileContent, senderUsername);
|
||||
|
||||
await FederationMatrix.saveFederationMessage({
|
||||
event: event.event as PduForType<'m.room.message'>,
|
||||
event_id: event.eventId,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
event_id: event.eventId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const event = await federationSDK.sendMessage(roomId, body.body, body.formatted_body ?? body.body, senderUsername);
|
||||
|
||||
await FederationMatrix.saveFederationMessage({ event: event.event as PduForType<'m.room.message'>, event_id: event.eventId });
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
event_id: event.eventId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return internalError('Failed to send message', error, { roomId });
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// GET /_matrix/client/v3/rooms/:roomId/messages
|
||||
.get(
|
||||
'/v3/rooms/:roomId/messages',
|
||||
{
|
||||
params: isRoomIdParamsProps,
|
||||
query: isMessagesQueryProps,
|
||||
response: {
|
||||
200: isMessagesResponseProps,
|
||||
401: isMatrixErrorProps,
|
||||
500: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const roomId = c.req.param('roomId') as RoomID;
|
||||
const fromParam = c.req.query('from');
|
||||
const limitParam = c.req.query('limit');
|
||||
const limit = limitParam ? Number(limitParam) || 10 : 10;
|
||||
|
||||
try {
|
||||
if (!fromParam || limit <= 0) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
chunk: [],
|
||||
start: '',
|
||||
end: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
const result = await federationSDK.getBackfillEvents(roomId, [fromParam] as EventID[], limit);
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
chunk: result.pdus,
|
||||
start: fromParam,
|
||||
end: '',
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return internalError('Failed to fetch messages', error, { roomId });
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// PUT /_matrix/client/v3/rooms/:roomId/typing/:userId
|
||||
.put(
|
||||
'/v3/rooms/:roomId/typing/:userId',
|
||||
{
|
||||
params: isTypingParamsProps,
|
||||
query: isImpersonationQueryProps,
|
||||
body: isTypingBodyProps,
|
||||
response: {
|
||||
200: isEmptyObjectResponseProps,
|
||||
400: isMatrixErrorProps,
|
||||
401: isMatrixErrorProps,
|
||||
403: isMatrixErrorProps,
|
||||
404: isMatrixErrorProps,
|
||||
500: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const roomId = c.req.param('roomId') as RoomID;
|
||||
const username = c.get('impersonatedUserId');
|
||||
const body = await c.req.json();
|
||||
|
||||
if (!username) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
errcode: 'M_BAD_REQUEST',
|
||||
error: 'Missing userId parameter',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const matrixRoom = await Rooms.findOne({ 'federation.mrid': roomId }, { projection: { _id: 1 } });
|
||||
if (!matrixRoom) {
|
||||
return {
|
||||
statusCode: 404,
|
||||
body: {
|
||||
errcode: 'M_NOT_FOUND',
|
||||
error: 'Room not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const user = await Users.findOneByUsername<Pick<IUser, 'name' | 'username' | 'federated' | 'federation'>>(username, {
|
||||
projection: { name: 1, username: 1, federated: 1, federation: 1 },
|
||||
});
|
||||
if (!user || !isUserNativeFederated(user)) {
|
||||
return {
|
||||
statusCode: 404,
|
||||
body: {
|
||||
errcode: 'M_NOT_FOUND',
|
||||
error: 'User not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
void api.broadcast('user.activity', {
|
||||
user: user.name || user.username,
|
||||
isTyping: body.typing,
|
||||
roomId: matrixRoom._id,
|
||||
});
|
||||
|
||||
await federationSDK.sendTypingNotification(roomId, username, body.typing === true);
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {},
|
||||
};
|
||||
} catch (error) {
|
||||
return internalError('Failed to send typing notification', error, { roomId, userId: username });
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// POST /_matrix/client/v3/rooms/:roomId/receipt/m.read/:eventId
|
||||
.post(
|
||||
'/v3/rooms/:roomId/receipt/m.read/:eventId',
|
||||
{
|
||||
params: isReceiptParamsProps,
|
||||
query: isImpersonationQueryProps,
|
||||
body: isReceiptBodyProps,
|
||||
response: {
|
||||
200: isEmptyObjectResponseProps,
|
||||
401: isMatrixErrorProps,
|
||||
403: isMatrixErrorProps,
|
||||
404: isMatrixErrorProps,
|
||||
500: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const roomId = c.req.param('roomId') as RoomID;
|
||||
const senderUsername = c.get('impersonatedUserId') as string;
|
||||
|
||||
try {
|
||||
const matrixUser = await Users.findOneByUsername(senderUsername);
|
||||
if (!matrixUser) {
|
||||
return {
|
||||
statusCode: 404,
|
||||
body: {
|
||||
errcode: 'M_NOT_FOUND',
|
||||
error: 'User not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const matrixRoom = await Rooms.findOne({ 'federation.mrid': roomId });
|
||||
if (!matrixRoom) {
|
||||
return {
|
||||
statusCode: 404,
|
||||
body: {
|
||||
errcode: 'M_NOT_FOUND',
|
||||
error: 'Room not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await Room.markAsRead(matrixRoom, matrixUser._id);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {},
|
||||
};
|
||||
} catch (error) {
|
||||
return internalError('Failed to send read receipt', error, { roomId, senderUsername });
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,348 @@
|
||||
import type { PersistentEventBase, RoomID, UserID } from '@rocket.chat/federation-sdk';
|
||||
import { federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { ajv } from '@rocket.chat/rest-typings';
|
||||
|
||||
import type { ClientRouter } from './_shared';
|
||||
import {
|
||||
MATRIX_ROOM_ID_PATTERN,
|
||||
internalError,
|
||||
isUnknownRoomError,
|
||||
notImplemented,
|
||||
roomNotFound,
|
||||
isImpersonationQueryProps,
|
||||
isMatrixErrorProps,
|
||||
isRoomIdParamsProps,
|
||||
license,
|
||||
tags,
|
||||
} from './_shared';
|
||||
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
|
||||
|
||||
const JoinedMembersResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
joined: {
|
||||
type: 'object',
|
||||
additionalProperties: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
display_name: { type: 'string', nullable: true },
|
||||
avatar_url: { type: 'string', nullable: true },
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['joined'],
|
||||
};
|
||||
|
||||
const isJoinedMembersResponseProps = ajv.compile(JoinedMembersResponseSchema);
|
||||
|
||||
const StateArrayResponseSchema = {
|
||||
type: 'array',
|
||||
items: { type: 'object', additionalProperties: true },
|
||||
};
|
||||
|
||||
const isStateArrayResponseProps = ajv.compile(StateArrayResponseSchema);
|
||||
|
||||
const StateEventParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
|
||||
eventType: { type: 'string' },
|
||||
stateKey: { type: 'string' },
|
||||
},
|
||||
required: ['roomId', 'eventType'],
|
||||
};
|
||||
|
||||
const isStateEventParamsProps = ajv.compile(StateEventParamsSchema);
|
||||
|
||||
const StateContentResponseSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const isStateContentResponseProps = ajv.compile(StateContentResponseSchema);
|
||||
|
||||
const PutStateBodySchema = {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const isPutStateBodyProps = ajv.compile(PutStateBodySchema);
|
||||
|
||||
const PutStateResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
event_id: { type: 'string' },
|
||||
},
|
||||
required: ['event_id'],
|
||||
};
|
||||
|
||||
const isPutStateResponseProps = ajv.compile(PutStateResponseSchema);
|
||||
|
||||
const getRoomStateEvent = async (roomId: RoomID, eventType: string, stateKey = '') => {
|
||||
try {
|
||||
const state = await federationSDK.getLatestRoomState(roomId);
|
||||
|
||||
const key = `${eventType}:${stateKey}`;
|
||||
|
||||
let pe: PersistentEventBase | undefined;
|
||||
for (const [k, v] of state) {
|
||||
if (k === key) {
|
||||
pe = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!pe) {
|
||||
return {
|
||||
statusCode: 404 as const,
|
||||
body: {
|
||||
errcode: 'M_NOT_FOUND',
|
||||
error: 'State event not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
statusCode: 200 as const,
|
||||
body: pe.getContent(),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isUnknownRoomError(error)) {
|
||||
return roomNotFound();
|
||||
}
|
||||
return internalError('Failed to fetch state event', error);
|
||||
}
|
||||
};
|
||||
|
||||
const putRoomStateEvent = async (
|
||||
roomId: RoomID,
|
||||
eventType: string,
|
||||
senderUsername: UserID,
|
||||
body: Record<string, unknown>,
|
||||
stateKey = '',
|
||||
) => {
|
||||
try {
|
||||
// The supported event types are all empty-state-key events per spec; writing them in
|
||||
// response to a keyed request would silently target the wrong state.
|
||||
if (stateKey) {
|
||||
return notImplemented('State events with a non-empty state key are not yet implemented', { roomId, eventType, stateKey });
|
||||
}
|
||||
if (eventType === 'm.room.name' && typeof body.name === 'string') {
|
||||
const event = await federationSDK.updateRoomName(roomId, body.name, senderUsername);
|
||||
return {
|
||||
statusCode: 200 as const,
|
||||
body: { event_id: event.eventId },
|
||||
};
|
||||
}
|
||||
if (eventType === 'm.room.topic' && typeof body.topic === 'string') {
|
||||
const event = await federationSDK.setRoomTopic(roomId, senderUsername, body.topic);
|
||||
|
||||
return {
|
||||
statusCode: 200 as const,
|
||||
body: { event_id: event.eventId },
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: extend SDK to send arbitrary state events
|
||||
return notImplemented(`State event type ${eventType} not yet implemented`, { roomId, eventType, senderUsername });
|
||||
} catch (error) {
|
||||
return internalError('Failed to send state event', error, { roomId, eventType, senderUsername });
|
||||
}
|
||||
};
|
||||
|
||||
export const addRoomsStateRoutes = (router: ClientRouter) => {
|
||||
router
|
||||
// GET /_matrix/client/v3/rooms/:roomId/joined_members
|
||||
.get(
|
||||
'/v3/rooms/:roomId/joined_members',
|
||||
{
|
||||
params: isRoomIdParamsProps,
|
||||
response: {
|
||||
200: isJoinedMembersResponseProps,
|
||||
401: isMatrixErrorProps,
|
||||
403: isMatrixErrorProps,
|
||||
500: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const roomId = c.req.param('roomId') as RoomID;
|
||||
|
||||
try {
|
||||
const state = await federationSDK.getLatestRoomState(roomId);
|
||||
const joined: Record<string, { display_name?: string; avatar_url?: string }> = {};
|
||||
for (const [key, pe] of state) {
|
||||
if (!key.startsWith('m.room.member:')) continue;
|
||||
const content = pe.getContent() as { membership?: string; displayname?: string; avatar_url?: string };
|
||||
if (content?.membership !== 'join') continue;
|
||||
const userId = pe.stateKey;
|
||||
if (!userId) continue;
|
||||
joined[userId] = {
|
||||
...(content.displayname ? { display_name: content.displayname } : {}),
|
||||
...(content.avatar_url ? { avatar_url: content.avatar_url } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { joined },
|
||||
};
|
||||
} catch (error) {
|
||||
if (isUnknownRoomError(error)) {
|
||||
return roomNotFound();
|
||||
}
|
||||
return internalError('Failed to fetch joined members', error, { roomId });
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// GET /_matrix/client/v3/rooms/:roomId/state
|
||||
.get(
|
||||
'/v3/rooms/:roomId/state',
|
||||
{
|
||||
params: isRoomIdParamsProps,
|
||||
response: {
|
||||
200: isStateArrayResponseProps,
|
||||
401: isMatrixErrorProps,
|
||||
403: isMatrixErrorProps,
|
||||
500: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const roomId = c.req.param('roomId') as RoomID;
|
||||
try {
|
||||
const state = await federationSDK.getLatestRoomState(roomId);
|
||||
const events: unknown[] = [];
|
||||
for (const pe of state.values()) {
|
||||
events.push(pe.event);
|
||||
}
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: events,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isUnknownRoomError(error)) {
|
||||
return roomNotFound();
|
||||
}
|
||||
return internalError('Failed to fetch room state', error, { roomId });
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// GET /_matrix/client/v3/rooms/:roomId/state/:eventType/
|
||||
// The optional-param route below (`:stateKey?`) matches `/state/:eventType` and
|
||||
// `/state/:eventType/:stateKey`, but NOT the trailing-slash form `/state/:eventType/`
|
||||
// (an empty final segment). Bridges request that trailing-slash URL for the
|
||||
// default (empty) state key, so this explicit route handles it with stateKey=''.
|
||||
.get(
|
||||
'/v3/rooms/:roomId/state/:eventType/',
|
||||
{
|
||||
params: isStateEventParamsProps,
|
||||
response: {
|
||||
200: isStateContentResponseProps,
|
||||
401: isMatrixErrorProps,
|
||||
403: isMatrixErrorProps,
|
||||
404: isMatrixErrorProps,
|
||||
500: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const roomId = c.req.param('roomId') as RoomID;
|
||||
const eventType = c.req.param('eventType') as string;
|
||||
|
||||
return getRoomStateEvent(roomId, eventType);
|
||||
},
|
||||
)
|
||||
|
||||
// GET /_matrix/client/v3/rooms/:roomId/state/:eventType/:stateKey?
|
||||
.get(
|
||||
'/v3/rooms/:roomId/state/:eventType/:stateKey?',
|
||||
{
|
||||
params: isStateEventParamsProps,
|
||||
response: {
|
||||
200: isStateContentResponseProps,
|
||||
401: isMatrixErrorProps,
|
||||
403: isMatrixErrorProps,
|
||||
404: isMatrixErrorProps,
|
||||
500: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const roomId = c.req.param('roomId') as RoomID;
|
||||
const eventType = c.req.param('eventType') as string;
|
||||
const stateKey = c.req.param('stateKey') ?? '';
|
||||
|
||||
return getRoomStateEvent(roomId, eventType, stateKey);
|
||||
},
|
||||
)
|
||||
|
||||
// PUT /_matrix/client/v3/rooms/:roomId/state/:eventType/
|
||||
// Same trailing-slash note as the GET above: the optional-param route (`:stateKey?`)
|
||||
// doesn't match an empty final segment, so this explicit route handles it with stateKey=''.
|
||||
.put(
|
||||
'/v3/rooms/:roomId/state/:eventType/',
|
||||
{
|
||||
params: isStateEventParamsProps,
|
||||
query: isImpersonationQueryProps,
|
||||
body: isPutStateBodyProps,
|
||||
response: {
|
||||
200: isPutStateResponseProps,
|
||||
401: isMatrixErrorProps,
|
||||
403: isMatrixErrorProps,
|
||||
500: isMatrixErrorProps,
|
||||
501: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const roomId = c.req.param('roomId') as RoomID;
|
||||
const eventType = c.req.param('eventType') as string;
|
||||
const senderUsername = c.get('impersonatedUserId') as UserID;
|
||||
const body = await c.req.json();
|
||||
|
||||
return putRoomStateEvent(roomId, eventType, senderUsername, body);
|
||||
},
|
||||
)
|
||||
|
||||
// PUT /_matrix/client/v3/rooms/:roomId/state/:eventType/:stateKey?
|
||||
// The state key is optional per spec: `PUT /state/:eventType` targets the empty state key.
|
||||
.put(
|
||||
'/v3/rooms/:roomId/state/:eventType/:stateKey?',
|
||||
{
|
||||
params: isStateEventParamsProps,
|
||||
query: isImpersonationQueryProps,
|
||||
body: isPutStateBodyProps,
|
||||
response: {
|
||||
200: isPutStateResponseProps,
|
||||
401: isMatrixErrorProps,
|
||||
403: isMatrixErrorProps,
|
||||
500: isMatrixErrorProps,
|
||||
501: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const roomId = c.req.param('roomId') as RoomID;
|
||||
const eventType = c.req.param('eventType') as string;
|
||||
const stateKey = c.req.param('stateKey') ?? '';
|
||||
const senderUsername = c.get('impersonatedUserId') as UserID;
|
||||
const body = await c.req.json();
|
||||
|
||||
return putRoomStateEvent(roomId, eventType, senderUsername, body, stateKey);
|
||||
},
|
||||
);
|
||||
};
|
||||
87
ee/packages/federation-matrix/src/api/_matrix/client/user.ts
Normal file
87
ee/packages/federation-matrix/src/api/_matrix/client/user.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { RoomID } from '@rocket.chat/federation-sdk';
|
||||
import { federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import { ajv } from '@rocket.chat/rest-typings';
|
||||
|
||||
import type { ClientRouter } from './_shared';
|
||||
import {
|
||||
MATRIX_ROOM_ID_PATTERN,
|
||||
MATRIX_USER_ID_PATTERN,
|
||||
internalError,
|
||||
isEmptyObjectResponseProps,
|
||||
isImpersonationQueryProps,
|
||||
isMatrixErrorProps,
|
||||
license,
|
||||
tags,
|
||||
} from './_shared';
|
||||
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
|
||||
|
||||
const AccountDataDisplaynameParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
userId: { type: 'string', pattern: MATRIX_USER_ID_PATTERN },
|
||||
roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
|
||||
},
|
||||
required: ['userId', 'roomId'],
|
||||
};
|
||||
|
||||
const isAccountDataDisplaynameParamsProps = ajv.compile(AccountDataDisplaynameParamsSchema);
|
||||
|
||||
const AccountDataDisplaynameBodySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
displayname: { type: 'string', nullable: true },
|
||||
},
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const isAccountDataDisplaynameBodyProps = ajv.compile(AccountDataDisplaynameBodySchema);
|
||||
|
||||
export const addUserRoutes = (router: ClientRouter) => {
|
||||
router.put(
|
||||
'/v3/user/:userId/rooms/:roomId/account_data/m.room.displayname',
|
||||
{
|
||||
params: isAccountDataDisplaynameParamsProps,
|
||||
query: isImpersonationQueryProps,
|
||||
body: isAccountDataDisplaynameBodyProps,
|
||||
response: {
|
||||
200: isEmptyObjectResponseProps,
|
||||
401: isMatrixErrorProps,
|
||||
403: isMatrixErrorProps,
|
||||
404: isMatrixErrorProps,
|
||||
500: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const roomId = c.req.param('roomId') as RoomID;
|
||||
const senderUsername = c.get('impersonatedUserId') as string;
|
||||
const body = await c.req.json();
|
||||
|
||||
const user = await Users.findOneByUsername(senderUsername);
|
||||
if (!user) {
|
||||
return {
|
||||
statusCode: 404,
|
||||
body: {
|
||||
errcode: 'M_NOT_FOUND',
|
||||
error: 'User not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await federationSDK.updateUserProfile(roomId, senderUsername, {
|
||||
displayname: body.displayname ?? undefined,
|
||||
});
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {},
|
||||
};
|
||||
} catch (error) {
|
||||
return internalError('Failed to update per-room displayname', error, { roomId, senderUsername });
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ajv } from '@rocket.chat/rest-typings';
|
||||
|
||||
import type { ClientRouter } from './_shared';
|
||||
import { license, tags } from './_shared';
|
||||
|
||||
const VersionsResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
versions: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
unstable_features: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
required: ['versions'],
|
||||
};
|
||||
|
||||
const isVersionsResponseProps = ajv.compile(VersionsResponseSchema);
|
||||
|
||||
export const addVersionsRoutes = (router: ClientRouter) => {
|
||||
router.get(
|
||||
'/versions',
|
||||
{
|
||||
response: { 200: isVersionsResponseProps },
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
async () => ({
|
||||
body: {
|
||||
versions: ['v1.4'],
|
||||
},
|
||||
statusCode: 200,
|
||||
}),
|
||||
);
|
||||
};
|
||||
144
ee/packages/federation-matrix/src/api/_matrix/invite.spec.ts
Normal file
144
ee/packages/federation-matrix/src/api/_matrix/invite.spec.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { FederationMatrix } from '@rocket.chat/core-services';
|
||||
import { federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
|
||||
import { getMatrixInviteRoutes } from './invite';
|
||||
|
||||
jest.mock('@rocket.chat/core-services', () => ({
|
||||
FederationMatrix: {
|
||||
canUserAccessFederation: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@rocket.chat/models', () => ({
|
||||
Users: {
|
||||
findOneByUsername: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@rocket.chat/federation-sdk', () => ({
|
||||
federationSDK: {
|
||||
verifyRequestSignature: jest.fn(),
|
||||
getConfig: jest.fn(),
|
||||
processInvite: jest.fn(),
|
||||
},
|
||||
NotAllowedError: class NotAllowedError extends Error {},
|
||||
errCodes: {
|
||||
M_UNAUTHORIZED: { errcode: 'M_UNAUTHORIZED', error: 'Unauthorized', status: 401 },
|
||||
M_UNKNOWN: { errcode: 'M_UNKNOWN', error: 'Unknown error' },
|
||||
},
|
||||
}));
|
||||
|
||||
const mockVerifyRequestSignature = federationSDK.verifyRequestSignature as jest.MockedFunction<typeof federationSDK.verifyRequestSignature>;
|
||||
const mockGetConfig = federationSDK.getConfig as jest.MockedFunction<typeof federationSDK.getConfig>;
|
||||
const mockProcessInvite = federationSDK.processInvite as jest.MockedFunction<typeof federationSDK.processInvite>;
|
||||
const mockFindOneByUsername = Users.findOneByUsername as jest.MockedFunction<typeof Users.findOneByUsername>;
|
||||
const mockCanUserAccessFederation = FederationMatrix.canUserAccessFederation as jest.MockedFunction<
|
||||
typeof FederationMatrix.canUserAccessFederation
|
||||
>;
|
||||
|
||||
const OUR_SERVER_NAME = 'rocketchat.local';
|
||||
|
||||
const buildInviteEvent = (stateKey: string) => ({
|
||||
type: 'm.room.member',
|
||||
state_key: stateKey,
|
||||
sender: '@attacker:attacker.com',
|
||||
room_id: '!room:attacker.com',
|
||||
origin_server_ts: 1600000000000,
|
||||
depth: 1,
|
||||
prev_events: [],
|
||||
auth_events: [],
|
||||
content: { membership: 'invite' },
|
||||
});
|
||||
|
||||
const sendInvite = async (event: unknown) =>
|
||||
getMatrixInviteRoutes()
|
||||
.getHonoRouter()
|
||||
.request('/v2/invite/!room:attacker.com/$event', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': 'X-Matrix origin="attacker.com"',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
room_version: '10',
|
||||
event,
|
||||
invite_room_state: [{ type: 'm.room.create', state_key: '', content: { creator: '@attacker:attacker.com' } }],
|
||||
}),
|
||||
});
|
||||
|
||||
describe('PUT /_matrix/federation/v2/invite/:roomId/:eventId', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockVerifyRequestSignature.mockResolvedValue({ origin: 'attacker.com' } as any);
|
||||
mockGetConfig.mockImplementation((key) => (key === 'serverName' ? OUR_SERVER_NAME : undefined) as any);
|
||||
mockFindOneByUsername.mockResolvedValue({ _id: 'victimId', username: 'victim' } as any);
|
||||
mockCanUserAccessFederation.mockResolvedValue(true);
|
||||
mockProcessInvite.mockImplementation(async (event: any) => ({ event }) as any);
|
||||
});
|
||||
|
||||
it('should reject an invite whose state_key belongs to another homeserver', async () => {
|
||||
const response = await sendInvite(buildInviteEvent(`@victim:attacker.com`));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({
|
||||
errcode: 'M_UNKNOWN',
|
||||
error: 'The invite event must be for a user of this server',
|
||||
});
|
||||
expect(mockFindOneByUsername).not.toHaveBeenCalled();
|
||||
expect(mockProcessInvite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject an invite whose state_key is not a valid user ID', async () => {
|
||||
const response = await sendInvite(buildInviteEvent('victim'));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({
|
||||
errcode: 'M_UNKNOWN',
|
||||
error: 'The invite event state_key is not a valid user ID',
|
||||
});
|
||||
expect(mockFindOneByUsername).not.toHaveBeenCalled();
|
||||
expect(mockProcessInvite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject an invite whose state_key has an empty localpart', async () => {
|
||||
const response = await sendInvite(buildInviteEvent(`@:${OUR_SERVER_NAME}`));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({
|
||||
errcode: 'M_UNKNOWN',
|
||||
error: 'The invite event state_key is not a valid user ID',
|
||||
});
|
||||
expect(mockFindOneByUsername).not.toHaveBeenCalled();
|
||||
expect(mockProcessInvite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject an invite addressed to a user that does not exist on this server', async () => {
|
||||
mockFindOneByUsername.mockResolvedValue(null);
|
||||
|
||||
const response = await sendInvite(buildInviteEvent(`@ghost:${OUR_SERVER_NAME}`));
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({
|
||||
errcode: 'M_FORBIDDEN',
|
||||
error: 'User does not have permission to access federation',
|
||||
});
|
||||
expect(mockProcessInvite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept a state_key whose localpart contains characters the spec allows', async () => {
|
||||
const response = await sendInvite(buildInviteEvent(`@victim+1/2:${OUR_SERVER_NAME}`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockFindOneByUsername).toHaveBeenCalledWith('victim+1/2');
|
||||
});
|
||||
|
||||
it('should process an invite addressed to a user of this server', async () => {
|
||||
const response = await sendInvite(buildInviteEvent(`@victim:${OUR_SERVER_NAME}`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockFindOneByUsername).toHaveBeenCalledWith('victim');
|
||||
expect(mockProcessInvite).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
279
ee/packages/federation-matrix/src/api/_matrix/invite.ts
Normal file
279
ee/packages/federation-matrix/src/api/_matrix/invite.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { FederationMatrix } from '@rocket.chat/core-services';
|
||||
import { NotAllowedError, federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { Router } from '@rocket.chat/http-router';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv';
|
||||
|
||||
import { getUsernameServername } from '../../helpers/getUsernameServername';
|
||||
import { logger } from '../logger';
|
||||
import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated';
|
||||
|
||||
const EventBaseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
description: 'Event type',
|
||||
},
|
||||
content: {
|
||||
type: 'object',
|
||||
description: 'Event content',
|
||||
},
|
||||
sender: {
|
||||
type: 'string',
|
||||
},
|
||||
room_id: {
|
||||
type: 'string',
|
||||
},
|
||||
origin_server_ts: {
|
||||
type: 'number',
|
||||
},
|
||||
depth: {
|
||||
type: 'number',
|
||||
},
|
||||
prev_events: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
description: 'Previous events in the room',
|
||||
},
|
||||
auth_events: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
description: 'Authorization events',
|
||||
},
|
||||
origin: {
|
||||
type: 'string',
|
||||
description: 'Origin server',
|
||||
},
|
||||
hashes: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
},
|
||||
signatures: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
},
|
||||
unsigned: {
|
||||
type: 'object',
|
||||
description: 'Unsigned data',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
required: ['type', 'content', 'sender', 'room_id', 'origin_server_ts', 'depth', 'prev_events', 'auth_events'],
|
||||
};
|
||||
|
||||
const MembershipEventContentSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
membership: {
|
||||
type: 'string',
|
||||
},
|
||||
displayname: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
avatar_url: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
required: ['membership'],
|
||||
};
|
||||
|
||||
const RoomMemberEventSchema = {
|
||||
type: 'object',
|
||||
allOf: [
|
||||
EventBaseSchema,
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
const: 'm.room.member',
|
||||
},
|
||||
content: MembershipEventContentSchema,
|
||||
state_key: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['type', 'content', 'state_key'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const ProcessInviteParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomId: {
|
||||
type: 'string',
|
||||
},
|
||||
eventId: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['roomId', 'eventId'],
|
||||
};
|
||||
|
||||
const isProcessInviteParamsProps = ajv.compile(ProcessInviteParamsSchema);
|
||||
|
||||
const ProcessInviteResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
event: RoomMemberEventSchema,
|
||||
},
|
||||
required: ['event'],
|
||||
};
|
||||
|
||||
const isProcessInviteResponseProps = ajv.compile(ProcessInviteResponseSchema);
|
||||
|
||||
export const getMatrixInviteRoutes = () => {
|
||||
// PUT /_matrix/federation/v2/invite/{roomId}/{eventId}
|
||||
// https://spec.matrix.org/v1.19/server-server-api/#put_matrixfederationv2inviteroomideventid
|
||||
return new Router('/federation').put(
|
||||
'/v2/invite/:roomId/:eventId',
|
||||
{
|
||||
// TODO: add schema from room package. `event` is a PDU whose format varies by room
|
||||
// version, so it stays unconstrained here; room_version and event are required per spec.
|
||||
body: ajv.compile({
|
||||
type: 'object',
|
||||
properties: {
|
||||
room_version: { type: 'string' },
|
||||
event: { type: 'object' },
|
||||
invite_room_state: {
|
||||
type: 'array',
|
||||
items: { type: 'object' },
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
required: ['room_version', 'event'],
|
||||
}),
|
||||
params: isProcessInviteParamsProps,
|
||||
response: {
|
||||
200: isProcessInviteResponseProps,
|
||||
},
|
||||
tags: ['Federation'],
|
||||
license: ['federation'],
|
||||
},
|
||||
isAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const { roomId, eventId } = c.req.param();
|
||||
const { event, room_version: roomVersion, invite_room_state: strippedStateEvents } = await c.req.json();
|
||||
|
||||
const userToCheck = event.state_key;
|
||||
|
||||
// matches Synapse: the PDU itself stays unvalidated, but an event that is not an invite
|
||||
// membership event cannot be processed, so reject it instead of failing later
|
||||
if (typeof userToCheck !== 'string' || !userToCheck || event.type !== 'm.room.member' || event.content?.membership !== 'invite') {
|
||||
return {
|
||||
body: {
|
||||
errcode: 'M_UNKNOWN',
|
||||
error: 'The event was not an m.room.member invite event',
|
||||
},
|
||||
statusCode: 400,
|
||||
};
|
||||
}
|
||||
|
||||
// spec: servers SHOULD return M_INVALID_PARAM if m.room.create is missing from invite_room_state
|
||||
if (!strippedStateEvents?.some((e: any) => e.type === 'm.room.create')) {
|
||||
return {
|
||||
body: {
|
||||
errcode: 'M_INVALID_PARAM',
|
||||
error: 'Missing invite_room_state: m.room.create event is required',
|
||||
},
|
||||
statusCode: 400,
|
||||
};
|
||||
}
|
||||
|
||||
// spec grammar is `@localpart:server_name`, where localpart is non-empty. deliberately
|
||||
// not `validateFederatedUsername`, which is stricter than the spec and would reject
|
||||
// legal localparts containing `/` or `+`
|
||||
if (!/^@[A-Za-z0-9_=/.+-]+:.+$/.test(userToCheck)) {
|
||||
return {
|
||||
body: {
|
||||
errcode: 'M_UNKNOWN',
|
||||
error: 'The invite event state_key is not a valid user ID',
|
||||
},
|
||||
statusCode: 400,
|
||||
};
|
||||
}
|
||||
|
||||
// an invite addressed to a user of another homeserver would create a local subscription
|
||||
// that can never be accepted, since the remote server never invited our copy of that user
|
||||
const [username, , isLocalUser] = getUsernameServername(userToCheck, federationSDK.getConfig('serverName'));
|
||||
|
||||
if (!isLocalUser) {
|
||||
return {
|
||||
body: {
|
||||
errcode: 'M_UNKNOWN',
|
||||
error: 'The invite event must be for a user of this server',
|
||||
},
|
||||
statusCode: 400,
|
||||
};
|
||||
}
|
||||
|
||||
const ourUser = await Users.findOneByUsername(username);
|
||||
|
||||
// same response as the federation permission check below, so an unauthorized remote
|
||||
// server cannot use the invite endpoint to probe which local users exist
|
||||
if (!ourUser) {
|
||||
logger.info({ msg: 'Invite for unknown local user, rejecting invite to room', userId: userToCheck, roomId });
|
||||
|
||||
return {
|
||||
body: {
|
||||
errcode: 'M_FORBIDDEN',
|
||||
error: 'User does not have permission to access federation',
|
||||
},
|
||||
statusCode: 403,
|
||||
};
|
||||
}
|
||||
|
||||
// check federation permission before processing the invite
|
||||
if (!(await FederationMatrix.canUserAccessFederation(ourUser))) {
|
||||
logger.info({ msg: 'User denied federation access, rejecting invite to room', userId: userToCheck, roomId });
|
||||
|
||||
return {
|
||||
body: {
|
||||
errcode: 'M_FORBIDDEN',
|
||||
error: 'User does not have permission to access federation',
|
||||
},
|
||||
statusCode: 403,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const inviteEvent = await federationSDK.processInvite(event, eventId, roomVersion, strippedStateEvents);
|
||||
|
||||
return {
|
||||
body: {
|
||||
event: inviteEvent.event,
|
||||
},
|
||||
statusCode: 200,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof NotAllowedError) {
|
||||
return {
|
||||
body: {
|
||||
errcode: 'M_FORBIDDEN',
|
||||
error: 'This server does not allow joining this type of room based on federation settings.',
|
||||
},
|
||||
statusCode: 403,
|
||||
};
|
||||
}
|
||||
|
||||
logger.error({ msg: 'Error processing invite', err: error });
|
||||
|
||||
return {
|
||||
body: {
|
||||
errcode: 'M_UNKNOWN',
|
||||
error: error instanceof Error ? error.message : 'Internal server error while processing request',
|
||||
},
|
||||
statusCode: 500,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
56
ee/packages/federation-matrix/src/api/_matrix/key/server.ts
Normal file
56
ee/packages/federation-matrix/src/api/_matrix/key/server.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { Router } from '@rocket.chat/http-router';
|
||||
import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv';
|
||||
|
||||
const ServerKeyResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
old_verify_keys: {
|
||||
type: 'object',
|
||||
description: 'Old verification keys',
|
||||
},
|
||||
server_name: {
|
||||
type: 'string',
|
||||
description: 'Matrix server name',
|
||||
},
|
||||
signatures: {
|
||||
type: 'object',
|
||||
description: 'Server signatures',
|
||||
},
|
||||
valid_until_ts: {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
description: 'Unix timestamp in milliseconds',
|
||||
},
|
||||
verify_keys: {
|
||||
type: 'object',
|
||||
description: 'Current verification keys',
|
||||
},
|
||||
},
|
||||
required: ['old_verify_keys', 'server_name', 'signatures', 'valid_until_ts', 'verify_keys'],
|
||||
};
|
||||
|
||||
const isServerKeyResponseProps = ajv.compile(ServerKeyResponseSchema);
|
||||
|
||||
export const getKeyServerRoutes = () => {
|
||||
// GET /_matrix/key/v2/server
|
||||
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixkeyv2server
|
||||
return new Router('/key').get(
|
||||
'/v2/server',
|
||||
{
|
||||
response: {
|
||||
200: isServerKeyResponseProps,
|
||||
},
|
||||
tags: ['Key'],
|
||||
license: ['federation'],
|
||||
},
|
||||
async () => {
|
||||
const response = await federationSDK.getSignedServerKey();
|
||||
|
||||
return {
|
||||
body: response,
|
||||
statusCode: 200,
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
106
ee/packages/federation-matrix/src/api/_matrix/make-leave.ts
Normal file
106
ee/packages/federation-matrix/src/api/_matrix/make-leave.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { NotAllowedError, federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { Router } from '@rocket.chat/http-router';
|
||||
import { ajv } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { logger } from '../logger';
|
||||
import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated';
|
||||
|
||||
const isMakeLeaveParamsProps = ajv.compile({
|
||||
type: 'object',
|
||||
properties: { roomId: { type: 'string' }, userId: { type: 'string' } },
|
||||
required: ['roomId', 'userId'],
|
||||
});
|
||||
const isMakeLeaveSuccessResponseProps = ajv.compile({
|
||||
type: 'object',
|
||||
properties: {
|
||||
event: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
content: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
membership: {
|
||||
type: 'string',
|
||||
const: 'leave',
|
||||
},
|
||||
},
|
||||
},
|
||||
origin: {
|
||||
type: 'string',
|
||||
},
|
||||
origin_server_ts: {
|
||||
type: 'number',
|
||||
},
|
||||
sender: {
|
||||
type: 'string',
|
||||
},
|
||||
state_key: {
|
||||
type: 'string',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
const: 'm.room.member',
|
||||
},
|
||||
},
|
||||
},
|
||||
room_version: { type: 'string' },
|
||||
},
|
||||
});
|
||||
const isMakeLeaveForbiddenResponseProps = ajv.compile({
|
||||
type: 'object',
|
||||
properties: { errcode: { type: 'string', const: 'M_FORBIDDEN' }, error: { type: 'string' } },
|
||||
});
|
||||
const isMakeLeaveErrorResponseProps = ajv.compile({
|
||||
type: 'object',
|
||||
properties: { errcode: { type: 'string', const: 'M_UNKNOWN' }, error: { type: 'string' } },
|
||||
});
|
||||
|
||||
export const getMatrixMakeLeaveRoutes = () => {
|
||||
// GET /_matrix/federation/v1/make_leave/{roomId}/{userId}
|
||||
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1make_leaveroomiduserid
|
||||
return new Router('/federation').get(
|
||||
'/v1/make_leave/:roomId/:userId',
|
||||
{
|
||||
params: isMakeLeaveParamsProps,
|
||||
response: {
|
||||
200: isMakeLeaveSuccessResponseProps,
|
||||
403: isMakeLeaveForbiddenResponseProps,
|
||||
500: isMakeLeaveErrorResponseProps,
|
||||
},
|
||||
tags: ['Federation'],
|
||||
license: ['federation'],
|
||||
},
|
||||
isAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const { roomId, userId } = c.req.param();
|
||||
try {
|
||||
// TODO: Remove out of spec attributes being returned
|
||||
const makeLeaveResponse = await federationSDK.makeLeave(roomId, userId);
|
||||
return {
|
||||
body: makeLeaveResponse,
|
||||
statusCode: 200,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof NotAllowedError) {
|
||||
return {
|
||||
body: {
|
||||
errcode: 'M_FORBIDDEN',
|
||||
error: 'This server does not allow leaving this room based on federation settings.',
|
||||
},
|
||||
statusCode: 403,
|
||||
};
|
||||
}
|
||||
|
||||
logger.error({ msg: 'Error making leave', err: error });
|
||||
|
||||
return {
|
||||
body: {
|
||||
errcode: 'M_UNKNOWN',
|
||||
error: error instanceof Error ? error.message : 'Internal server error while processing request',
|
||||
},
|
||||
statusCode: 500,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
178
ee/packages/federation-matrix/src/api/_matrix/media-bridge.ts
Normal file
178
ee/packages/federation-matrix/src/api/_matrix/media-bridge.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { Router } from '@rocket.chat/http-router';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import { ajv, ajvQuery } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { MatrixMediaService } from '../../services/MatrixMediaService';
|
||||
import { isAppServiceAuthenticatedMiddleware } from '../middlewares/isAppServiceAuthenticated';
|
||||
|
||||
const MatrixErrorSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
errcode: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
},
|
||||
required: ['errcode', 'error'],
|
||||
};
|
||||
|
||||
const isMatrixErrorProps = ajv.compile(MatrixErrorSchema);
|
||||
|
||||
const UploadResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
content_uri: { type: 'string' },
|
||||
},
|
||||
required: ['content_uri'],
|
||||
};
|
||||
|
||||
const isUploadResponseProps = ajv.compile(UploadResponseSchema);
|
||||
|
||||
const UploadQuerySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
filename: { type: 'string' },
|
||||
user_id: { type: 'string' },
|
||||
access_token: { type: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
const isUploadQueryProps = ajvQuery.compile<{
|
||||
filename?: string;
|
||||
user_id?: string;
|
||||
access_token?: string;
|
||||
}>(UploadQuerySchema);
|
||||
|
||||
const ConfigResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
'm.upload.size': { type: 'number' },
|
||||
},
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const isConfigResponseProps = ajv.compile(ConfigResponseSchema);
|
||||
|
||||
const tags = ['Federation', 'Media'];
|
||||
const license: ['federation'] = ['federation'];
|
||||
|
||||
// Advertised to bridges as m.upload.size in the /r0/config route below.
|
||||
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024;
|
||||
|
||||
export const getMatrixMediaBridgeRoutes = () => {
|
||||
return (
|
||||
new Router('/media')
|
||||
|
||||
// POST /_matrix/media/v3/upload
|
||||
.post(
|
||||
'/v3/upload',
|
||||
{
|
||||
query: isUploadQueryProps,
|
||||
response: {
|
||||
200: isUploadResponseProps,
|
||||
400: isMatrixErrorProps,
|
||||
401: isMatrixErrorProps,
|
||||
413: isMatrixErrorProps,
|
||||
500: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
try {
|
||||
const senderUsername = c.get('impersonatedUserId') as string;
|
||||
const fileName = c.req.query('filename') || `upload-${Date.now()}`;
|
||||
const mimeType = c.req.header('content-type') || 'application/octet-stream';
|
||||
|
||||
const user = await Users.findOneByUsername(senderUsername, { projection: { _id: 1 } });
|
||||
if (!user) {
|
||||
return {
|
||||
statusCode: 401,
|
||||
body: {
|
||||
errcode: 'M_UNKNOWN_TOKEN',
|
||||
error: 'Impersonated user not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Reject on the declared size before buffering; the byteLength check below
|
||||
// still catches bodies without (or lying about) content-length.
|
||||
const contentLength = Number(c.req.header('content-length'));
|
||||
if (contentLength > MAX_UPLOAD_SIZE) {
|
||||
return {
|
||||
statusCode: 413,
|
||||
body: {
|
||||
errcode: 'M_TOO_LARGE',
|
||||
error: 'Upload exceeds the maximum allowed size',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const arrayBuffer = await c.req.raw.arrayBuffer();
|
||||
if (!arrayBuffer.byteLength) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
errcode: 'M_BAD_REQUEST',
|
||||
error: 'Empty upload body',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (arrayBuffer.byteLength > MAX_UPLOAD_SIZE) {
|
||||
return {
|
||||
statusCode: 413,
|
||||
body: {
|
||||
errcode: 'M_TOO_LARGE',
|
||||
error: 'Upload exceeds the maximum allowed size',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const { mxcUri } = await MatrixMediaService.uploadFromAppService({
|
||||
buffer,
|
||||
fileName,
|
||||
mimeType,
|
||||
userId: user._id,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { content_uri: mxcUri },
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
statusCode: 500,
|
||||
body: {
|
||||
errcode: 'M_UNKNOWN',
|
||||
error: 'Failed to upload media',
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// GET /_matrix/media/r0/config (literal r0; matrix-bot-sdk hardcodes this path)
|
||||
.get(
|
||||
'/r0/config',
|
||||
{
|
||||
response: {
|
||||
200: isConfigResponseProps,
|
||||
401: isMatrixErrorProps,
|
||||
},
|
||||
tags,
|
||||
license,
|
||||
},
|
||||
isAppServiceAuthenticatedMiddleware(),
|
||||
async () => {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
'm.upload.size': MAX_UPLOAD_SIZE,
|
||||
},
|
||||
};
|
||||
},
|
||||
)
|
||||
);
|
||||
};
|
||||
155
ee/packages/federation-matrix/src/api/_matrix/media.ts
Normal file
155
ee/packages/federation-matrix/src/api/_matrix/media.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type { IUpload } from '@rocket.chat/core-typings';
|
||||
import { federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { Router } from '@rocket.chat/http-router';
|
||||
import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv';
|
||||
|
||||
import { MatrixMediaService } from '../../services/MatrixMediaService';
|
||||
import { canAccessResourceMiddleware } from '../middlewares/canAccessResource';
|
||||
|
||||
const MediaDownloadParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mediaId: { type: 'string' },
|
||||
},
|
||||
required: ['mediaId'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const ErrorResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
errcode: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
},
|
||||
required: ['errcode', 'error'],
|
||||
};
|
||||
|
||||
const BufferResponseSchema = {
|
||||
type: 'object',
|
||||
description: 'Raw file buffer or multipart response',
|
||||
};
|
||||
|
||||
const isMediaDownloadParamsProps = ajv.compile(MediaDownloadParamsSchema);
|
||||
const isErrorResponseProps = ajv.compile(ErrorResponseSchema);
|
||||
const isBufferResponseProps = ajv.compile(BufferResponseSchema);
|
||||
|
||||
const SECURITY_HEADERS = {
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Content-Security-Policy': "default-src 'none'; img-src 'self'; media-src 'self'",
|
||||
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
|
||||
};
|
||||
|
||||
function createMultipartResponse(
|
||||
buffer: Buffer,
|
||||
mimeType: string,
|
||||
fileName: string,
|
||||
metadata: Record<string, any> = {},
|
||||
): { body: Buffer; contentType: string } {
|
||||
const boundary = crypto.randomBytes(16).toString('hex');
|
||||
const parts: string[] = [];
|
||||
|
||||
parts.push(`--${boundary}`, 'Content-Type: application/json', '', JSON.stringify(metadata));
|
||||
parts.push(`--${boundary}`, `Content-Type: ${mimeType}`, `Content-Disposition: attachment; filename="${fileName}"`, '');
|
||||
|
||||
const headerBuffer = Buffer.from(`${parts.join('\r\n')}\r\n`);
|
||||
const endBoundary = Buffer.from(`\r\n--${boundary}--\r\n`);
|
||||
|
||||
return {
|
||||
body: Buffer.concat([headerBuffer, buffer, endBoundary]),
|
||||
contentType: `multipart/mixed; boundary=${boundary}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function getMediaFile(mediaId: string, serverName: string): Promise<{ file: IUpload; buffer: Buffer } | null> {
|
||||
const file = await MatrixMediaService.getLocalFileForMatrixNode(mediaId, serverName);
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buffer = await MatrixMediaService.getLocalFileBuffer(file);
|
||||
return { file, buffer };
|
||||
}
|
||||
|
||||
export const getMatrixMediaRoutes = () => {
|
||||
return (
|
||||
new Router('/federation')
|
||||
// GET /_matrix/federation/v1/media/download/{mediaId}
|
||||
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1mediadownloadmediaid
|
||||
.get(
|
||||
'/v1/media/download/:mediaId',
|
||||
{
|
||||
params: isMediaDownloadParamsProps,
|
||||
response: {
|
||||
200: isBufferResponseProps,
|
||||
401: isErrorResponseProps,
|
||||
403: isErrorResponseProps,
|
||||
404: isErrorResponseProps,
|
||||
429: isErrorResponseProps,
|
||||
500: isErrorResponseProps,
|
||||
},
|
||||
tags: ['Federation', 'Media'],
|
||||
},
|
||||
canAccessResourceMiddleware('media'),
|
||||
async (c) => {
|
||||
try {
|
||||
const { mediaId } = c.req.param();
|
||||
const serverName = federationSDK.getConfig('serverName');
|
||||
|
||||
// TODO: Add file streaming support
|
||||
const result = await getMediaFile(mediaId, serverName);
|
||||
if (!result) {
|
||||
return {
|
||||
statusCode: 404,
|
||||
body: { errcode: 'M_NOT_FOUND', error: 'Media not found' },
|
||||
};
|
||||
}
|
||||
|
||||
const { file, buffer } = result;
|
||||
|
||||
const mimeType = file.type || 'application/octet-stream';
|
||||
const fileName = file.name || mediaId;
|
||||
|
||||
const multipartResponse = createMultipartResponse(buffer, mimeType, fileName);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: {
|
||||
...SECURITY_HEADERS,
|
||||
'content-type': multipartResponse.contentType,
|
||||
'content-length': String(multipartResponse.body.length),
|
||||
},
|
||||
body: multipartResponse.body,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
statusCode: 500,
|
||||
body: { errcode: 'M_UNKNOWN', error: 'Internal server error' },
|
||||
};
|
||||
}
|
||||
},
|
||||
)
|
||||
// GET /_matrix/federation/v1/media/thumbnail/{mediaId}
|
||||
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1mediathumbnailmediaid
|
||||
.get(
|
||||
'/v1/media/thumbnail/:mediaId',
|
||||
{
|
||||
params: isMediaDownloadParamsProps,
|
||||
response: {
|
||||
404: isErrorResponseProps,
|
||||
},
|
||||
tags: ['Federation', 'Media'],
|
||||
},
|
||||
canAccessResourceMiddleware('media'),
|
||||
async (_c) => ({
|
||||
statusCode: 404,
|
||||
body: {
|
||||
errcode: 'M_UNRECOGNIZED',
|
||||
error: 'This endpoint is not implemented on the homeserver side',
|
||||
},
|
||||
}),
|
||||
)
|
||||
);
|
||||
};
|
||||
583
ee/packages/federation-matrix/src/api/_matrix/profiles.ts
Normal file
583
ee/packages/federation-matrix/src/api/_matrix/profiles.ts
Normal file
@@ -0,0 +1,583 @@
|
||||
import { eventIdSchema, roomIdSchema, userIdSchema, federationSDK, type RoomVersion } from '@rocket.chat/federation-sdk';
|
||||
import { Router } from '@rocket.chat/http-router';
|
||||
import { ajv, ajvQuery } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { canAccessResourceMiddleware } from '../middlewares/canAccessResource';
|
||||
import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated';
|
||||
|
||||
const UsernameSchema = {
|
||||
type: 'string',
|
||||
pattern: '^@[A-Za-z0-9_=\\/.+-]+:(.+)$',
|
||||
description: 'Matrix user ID in format @user:server.com',
|
||||
};
|
||||
|
||||
const RoomIdSchema = {
|
||||
type: 'string',
|
||||
pattern: '^![A-Za-z0-9_=\\/.+-]+:(.+)$',
|
||||
description: 'Matrix room ID in format !room:server.com',
|
||||
};
|
||||
|
||||
const TimestampSchema = {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
description: 'Unix timestamp in milliseconds',
|
||||
};
|
||||
|
||||
const ServerNameSchema = {
|
||||
type: 'string',
|
||||
description: 'Matrix server name',
|
||||
};
|
||||
|
||||
const QueryProfileQuerySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: UsernameSchema,
|
||||
field: {
|
||||
// open string, not an enum: the spec defines displayname, avatar_url and m.tz,
|
||||
// and servers MAY allow arbitrary additional profile fields
|
||||
type: 'string',
|
||||
description: 'Profile field to query',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
required: ['user_id'],
|
||||
};
|
||||
|
||||
const isQueryProfileQueryProps = ajvQuery.compile(QueryProfileQuerySchema);
|
||||
|
||||
const QueryProfileResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
displayname: {
|
||||
type: 'string',
|
||||
description: 'User display name',
|
||||
nullable: true,
|
||||
},
|
||||
avatar_url: {
|
||||
type: 'string',
|
||||
description: 'User avatar URL',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const isQueryProfileResponseProps = ajv.compile(QueryProfileResponseSchema);
|
||||
|
||||
const QueryKeysBodySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
device_keys: {
|
||||
type: 'object',
|
||||
description: 'Device keys to query',
|
||||
},
|
||||
},
|
||||
required: ['device_keys'],
|
||||
};
|
||||
|
||||
const isQueryKeysBodyProps = ajv.compile(QueryKeysBodySchema);
|
||||
|
||||
const QueryKeysResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
device_keys: {
|
||||
type: 'object',
|
||||
description: 'Device keys for the requested users',
|
||||
},
|
||||
},
|
||||
required: ['device_keys'],
|
||||
};
|
||||
|
||||
const isQueryKeysResponseProps = ajv.compile(QueryKeysResponseSchema);
|
||||
|
||||
const GetDevicesParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
userId: UsernameSchema,
|
||||
},
|
||||
required: ['userId'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const isGetDevicesParamsProps = ajv.compile(GetDevicesParamsSchema);
|
||||
|
||||
const GetDevicesResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: UsernameSchema,
|
||||
stream_id: {
|
||||
type: 'number',
|
||||
description: 'Device list stream ID',
|
||||
},
|
||||
devices: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
device_id: {
|
||||
type: 'string',
|
||||
description: 'Device ID',
|
||||
},
|
||||
display_name: {
|
||||
type: 'string',
|
||||
description: 'Device display name',
|
||||
nullable: true,
|
||||
},
|
||||
last_seen_ip: {
|
||||
type: 'string',
|
||||
description: 'Last seen IP address',
|
||||
nullable: true,
|
||||
},
|
||||
last_seen_ts: {
|
||||
...TimestampSchema,
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
required: ['device_id'],
|
||||
},
|
||||
description: 'List of devices for the user',
|
||||
},
|
||||
},
|
||||
required: ['user_id', 'stream_id', 'devices'],
|
||||
};
|
||||
|
||||
const isGetDevicesResponseProps = ajv.compile(GetDevicesResponseSchema);
|
||||
|
||||
const MakeJoinParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomId: RoomIdSchema,
|
||||
userId: UsernameSchema,
|
||||
},
|
||||
required: ['roomId', 'userId'],
|
||||
};
|
||||
|
||||
const isMakeJoinParamsProps = ajv.compile(MakeJoinParamsSchema);
|
||||
|
||||
const MakeJoinQuerySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ver: {
|
||||
// a string branch here would be redundant: ajvQuery coerces a single `?ver=` into a
|
||||
// one-element array, and in a `oneOf` both branches would match and fail validation
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
description: 'Room versions supported by the sending server',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const isMakeJoinQueryProps = ajvQuery.compile(MakeJoinQuerySchema);
|
||||
|
||||
const MakeJoinResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
room_version: {
|
||||
type: 'string',
|
||||
description: 'Room version',
|
||||
},
|
||||
event: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
content: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
membership: {
|
||||
type: 'string',
|
||||
const: 'join',
|
||||
},
|
||||
join_authorised_via_users_server: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
required: ['membership'],
|
||||
},
|
||||
room_id: RoomIdSchema,
|
||||
sender: UsernameSchema,
|
||||
state_key: UsernameSchema,
|
||||
type: {
|
||||
type: 'string',
|
||||
const: 'm.room.member',
|
||||
},
|
||||
origin_server_ts: TimestampSchema,
|
||||
origin: ServerNameSchema,
|
||||
depth: {
|
||||
type: 'number',
|
||||
description: 'Depth of the event in the DAG',
|
||||
nullable: true,
|
||||
},
|
||||
prev_events: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
description: 'Previous events in the room',
|
||||
nullable: true,
|
||||
},
|
||||
auth_events: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
description: 'Authorization events',
|
||||
nullable: true,
|
||||
},
|
||||
hashes: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sha256: {
|
||||
type: 'string',
|
||||
description: 'SHA256 hash of the event',
|
||||
},
|
||||
},
|
||||
required: ['sha256'],
|
||||
nullable: true,
|
||||
},
|
||||
signatures: {
|
||||
type: 'object',
|
||||
description: 'Event signatures by server and key ID',
|
||||
nullable: true,
|
||||
},
|
||||
unsigned: {
|
||||
type: 'object',
|
||||
description: 'Unsigned data',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
required: ['content', 'room_id', 'sender', 'state_key', 'type', 'origin_server_ts', 'origin'],
|
||||
},
|
||||
},
|
||||
required: ['room_version', 'event'],
|
||||
};
|
||||
|
||||
const isMakeJoinResponseProps = ajv.compile(MakeJoinResponseSchema);
|
||||
|
||||
const MakeJoinIncompatibleVersionResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
errcode: {
|
||||
type: 'string',
|
||||
const: 'M_INCOMPATIBLE_ROOM_VERSION',
|
||||
},
|
||||
error: {
|
||||
type: 'string',
|
||||
},
|
||||
room_version: {
|
||||
type: 'string',
|
||||
description: 'The version of the room',
|
||||
},
|
||||
},
|
||||
required: ['errcode', 'error', 'room_version'],
|
||||
};
|
||||
|
||||
const isMakeJoinIncompatibleVersionResponseProps = ajv.compile(MakeJoinIncompatibleVersionResponseSchema);
|
||||
|
||||
const GetMissingEventsParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomId: RoomIdSchema,
|
||||
},
|
||||
required: ['roomId'],
|
||||
};
|
||||
|
||||
const isGetMissingEventsParamsProps = ajv.compile(GetMissingEventsParamsSchema);
|
||||
|
||||
const GetMissingEventsBodySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
earliest_events: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
description: 'Earliest events',
|
||||
},
|
||||
latest_events: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
description: 'Latest events',
|
||||
},
|
||||
limit: {
|
||||
// optional per spec (defaults to 10) and unbounded; the handler applies the default and a cap
|
||||
type: 'integer',
|
||||
description: 'Maximum number of events to return',
|
||||
nullable: true,
|
||||
},
|
||||
min_depth: {
|
||||
type: 'integer',
|
||||
description: 'Minimum depth of events to retrieve',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
required: ['earliest_events', 'latest_events'],
|
||||
};
|
||||
|
||||
const isGetMissingEventsBodyProps = ajv.compile(GetMissingEventsBodySchema);
|
||||
|
||||
const GetMissingEventsResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
events: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
},
|
||||
description: 'Missing events',
|
||||
},
|
||||
},
|
||||
required: ['events'],
|
||||
};
|
||||
|
||||
const isGetMissingEventsResponseProps = ajv.compile(GetMissingEventsResponseSchema);
|
||||
|
||||
const EventAuthParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomId: RoomIdSchema,
|
||||
eventId: {
|
||||
type: 'string',
|
||||
description: 'Event ID',
|
||||
},
|
||||
},
|
||||
required: ['roomId', 'eventId'],
|
||||
};
|
||||
|
||||
const isEventAuthParamsProps = ajv.compile(EventAuthParamsSchema);
|
||||
|
||||
const EventAuthResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
auth_chain: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
},
|
||||
description: 'Authorization chain for the event',
|
||||
},
|
||||
},
|
||||
required: ['auth_chain'],
|
||||
};
|
||||
|
||||
const isEventAuthResponseProps = ajv.compile(EventAuthResponseSchema);
|
||||
|
||||
export const getMatrixProfilesRoutes = () => {
|
||||
return (
|
||||
new Router('/federation')
|
||||
.use(isAuthenticatedMiddleware())
|
||||
// GET /_matrix/federation/v1/query/profile
|
||||
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1queryprofile
|
||||
.get(
|
||||
'/v1/query/profile',
|
||||
{
|
||||
query: isQueryProfileQueryProps,
|
||||
response: {
|
||||
200: isQueryProfileResponseProps,
|
||||
},
|
||||
tags: ['Federation'],
|
||||
license: ['federation'],
|
||||
},
|
||||
async (c) => {
|
||||
const { user_id: userId, field } = c.req.query();
|
||||
|
||||
const response = await federationSDK.queryProfile(userId);
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
body: {
|
||||
errcode: 'M_NOT_FOUND',
|
||||
error: `User ${userId} not found`,
|
||||
},
|
||||
statusCode: 404,
|
||||
};
|
||||
}
|
||||
|
||||
if (field) {
|
||||
return {
|
||||
body: {
|
||||
[field]: response[field as 'displayname' | 'avatar_url'] || null,
|
||||
},
|
||||
statusCode: 200,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
body: {
|
||||
displayname: response.displayname,
|
||||
avatar_url: response.avatar_url,
|
||||
},
|
||||
statusCode: 200,
|
||||
};
|
||||
},
|
||||
)
|
||||
// POST /_matrix/federation/v1/user/keys/query
|
||||
// https://spec.matrix.org/v1.19/server-server-api/#post_matrixfederationv1userkeysquery
|
||||
.post(
|
||||
'/v1/user/keys/query',
|
||||
{
|
||||
body: isQueryKeysBodyProps,
|
||||
response: {
|
||||
200: isQueryKeysResponseProps,
|
||||
},
|
||||
tags: ['Federation'],
|
||||
license: ['federation'],
|
||||
},
|
||||
async (c) => {
|
||||
const body = await c.req.json();
|
||||
|
||||
const response = await federationSDK.queryKeys(body.device_keys);
|
||||
|
||||
return {
|
||||
body: response,
|
||||
statusCode: 200,
|
||||
};
|
||||
},
|
||||
)
|
||||
// GET /_matrix/federation/v1/user/devices/{userId}
|
||||
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1userdevicesuserid
|
||||
.get(
|
||||
'/v1/user/devices/:userId',
|
||||
{
|
||||
params: isGetDevicesParamsProps,
|
||||
response: {
|
||||
200: isGetDevicesResponseProps,
|
||||
},
|
||||
tags: ['Federation'],
|
||||
license: ['federation'],
|
||||
},
|
||||
async (c) => {
|
||||
return {
|
||||
body: {
|
||||
devices: [],
|
||||
stream_id: 0,
|
||||
user_id: c.req.param('userId'),
|
||||
},
|
||||
statusCode: 200,
|
||||
};
|
||||
},
|
||||
)
|
||||
// GET /_matrix/federation/v1/make_join/{roomId}/{userId}
|
||||
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1make_joinroomiduserid
|
||||
.get(
|
||||
'/v1/make_join/:roomId/:userId',
|
||||
{
|
||||
params: isMakeJoinParamsProps,
|
||||
query: isMakeJoinQueryProps,
|
||||
response: {
|
||||
200: isMakeJoinResponseProps,
|
||||
400: isMakeJoinIncompatibleVersionResponseProps,
|
||||
},
|
||||
tags: ['Federation'],
|
||||
license: ['federation'],
|
||||
},
|
||||
canAccessResourceMiddleware('room'),
|
||||
async (c) => {
|
||||
const { roomId, userId } = c.req.param();
|
||||
const url = new URL(c.req.url);
|
||||
const verParams = url.searchParams.getAll('ver');
|
||||
|
||||
try {
|
||||
const response = await federationSDK.makeJoin(
|
||||
roomIdSchema.parse(roomId),
|
||||
userIdSchema.parse(userId),
|
||||
// spec: "The room versions the sending server has support for. Defaults to [1]."
|
||||
verParams.length > 0 ? (verParams as RoomVersion[]) : ['1'],
|
||||
);
|
||||
|
||||
return {
|
||||
body: {
|
||||
room_version: response.room_version,
|
||||
event: response.event,
|
||||
},
|
||||
statusCode: 200,
|
||||
};
|
||||
} catch (error) {
|
||||
// the SDK throws when the room's version is not in the requested `ver` list
|
||||
const incompatibleVersion = error instanceof Error && error.message.match(/^Unsupported room version: (.+)$/);
|
||||
if (incompatibleVersion) {
|
||||
return {
|
||||
body: {
|
||||
errcode: 'M_INCOMPATIBLE_ROOM_VERSION',
|
||||
error: 'Your homeserver does not support the features required to join this room',
|
||||
room_version: incompatibleVersion[1],
|
||||
},
|
||||
statusCode: 400,
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
)
|
||||
// POST /_matrix/federation/v1/get_missing_events/{roomId}
|
||||
// https://spec.matrix.org/v1.19/server-server-api/#post_matrixfederationv1get_missing_eventsroomid
|
||||
.post(
|
||||
'/v1/get_missing_events/:roomId',
|
||||
{
|
||||
params: isGetMissingEventsParamsProps,
|
||||
body: isGetMissingEventsBodyProps,
|
||||
response: {
|
||||
200: isGetMissingEventsResponseProps,
|
||||
},
|
||||
tags: ['Federation'],
|
||||
license: ['federation'],
|
||||
},
|
||||
canAccessResourceMiddleware('room'),
|
||||
async (c) => {
|
||||
const { roomId } = c.req.param();
|
||||
const body = await c.req.json();
|
||||
|
||||
const limit = Math.min(body.limit ?? 10, 100);
|
||||
const minDepth = body.min_depth ?? 0;
|
||||
|
||||
// this will be handled by the federation-sdk on next versions so this can be removed
|
||||
if (limit < 1) {
|
||||
return {
|
||||
body: { events: [] },
|
||||
statusCode: 200,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await federationSDK.getMissingEvents(
|
||||
roomIdSchema.parse(roomId),
|
||||
body.earliest_events,
|
||||
body.latest_events,
|
||||
limit,
|
||||
minDepth,
|
||||
);
|
||||
|
||||
return {
|
||||
body: response,
|
||||
statusCode: 200,
|
||||
};
|
||||
},
|
||||
)
|
||||
// GET /_matrix/federation/v1/event_auth/{roomId}/{eventId}
|
||||
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1event_authroomideventid
|
||||
.get(
|
||||
'/v1/event_auth/:roomId/:eventId',
|
||||
{
|
||||
params: isEventAuthParamsProps,
|
||||
response: {
|
||||
200: isEventAuthResponseProps,
|
||||
},
|
||||
tags: ['Federation'],
|
||||
license: ['federation'],
|
||||
},
|
||||
canAccessResourceMiddleware('room'),
|
||||
async (c) => {
|
||||
const { roomId, eventId } = c.req.param();
|
||||
|
||||
const response = await federationSDK.eventAuth(roomIdSchema.parse(roomId), eventIdSchema.parse(eventId));
|
||||
|
||||
return {
|
||||
body: response,
|
||||
statusCode: 200,
|
||||
};
|
||||
},
|
||||
)
|
||||
);
|
||||
};
|
||||
236
ee/packages/federation-matrix/src/api/_matrix/rooms.ts
Normal file
236
ee/packages/federation-matrix/src/api/_matrix/rooms.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { Router } from '@rocket.chat/http-router';
|
||||
import { ajv, ajvQuery } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated';
|
||||
|
||||
// All query params are optional per spec.
|
||||
const PublicRoomsQuerySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: {
|
||||
// spec default is 0, meaning no limit; negatives are meaningless and Synapse rejects them
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Maximum number of rooms to return',
|
||||
},
|
||||
since: {
|
||||
type: 'string',
|
||||
description: 'Pagination token from a previous call',
|
||||
},
|
||||
include_all_networks: {
|
||||
type: 'boolean',
|
||||
description: 'Include all networks (ignored)',
|
||||
},
|
||||
third_party_instance_id: {
|
||||
type: 'string',
|
||||
description: 'Specific third-party network to request (ignored)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const isPublicRoomsQueryProps = ajvQuery.compile(PublicRoomsQuerySchema);
|
||||
|
||||
const RoomObjectSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
avatar_url: {
|
||||
type: 'string',
|
||||
description: 'Room avatar URL',
|
||||
},
|
||||
canonical_alias: {
|
||||
type: 'string',
|
||||
description: 'Room canonical alias',
|
||||
nullable: true,
|
||||
},
|
||||
guest_can_join: {
|
||||
type: 'boolean',
|
||||
description: 'Whether guests can join the room',
|
||||
},
|
||||
join_rule: {
|
||||
type: 'string',
|
||||
description: 'Room join rule',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Room name',
|
||||
},
|
||||
num_joined_members: {
|
||||
type: 'number',
|
||||
description: 'Number of joined members',
|
||||
nullable: true,
|
||||
},
|
||||
room_id: {
|
||||
type: 'string',
|
||||
description: 'Room ID',
|
||||
},
|
||||
room_type: {
|
||||
type: 'string',
|
||||
description: 'Room type',
|
||||
nullable: true,
|
||||
},
|
||||
topic: {
|
||||
type: 'string',
|
||||
description: 'Room topic',
|
||||
nullable: true,
|
||||
},
|
||||
world_readable: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the room is world readable',
|
||||
},
|
||||
},
|
||||
required: ['avatar_url', 'guest_can_join', 'join_rule', 'name', 'room_id', 'world_readable'],
|
||||
};
|
||||
|
||||
const PublicRoomsResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
chunk: {
|
||||
type: 'array',
|
||||
items: RoomObjectSchema,
|
||||
description: 'Array of public rooms',
|
||||
},
|
||||
},
|
||||
required: ['chunk'],
|
||||
};
|
||||
|
||||
const isPublicRoomsResponseProps = ajv.compile(PublicRoomsResponseSchema);
|
||||
|
||||
// All body fields are optional per spec: "Options for which rooms to return, or empty object to use defaults."
|
||||
const PublicRoomsPostBodySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Maximum number of rooms to return',
|
||||
nullable: true,
|
||||
},
|
||||
since: {
|
||||
type: 'string',
|
||||
description: 'Pagination token from a previous request',
|
||||
nullable: true,
|
||||
},
|
||||
include_all_networks: {
|
||||
type: 'boolean',
|
||||
description: 'Include all networks (ignored)',
|
||||
nullable: true,
|
||||
},
|
||||
third_party_instance_id: {
|
||||
type: 'string',
|
||||
description: 'Specific third-party network to request (ignored)',
|
||||
nullable: true,
|
||||
},
|
||||
filter: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
generic_search_term: {
|
||||
type: 'string',
|
||||
description: 'Generic search term for filtering rooms',
|
||||
nullable: true,
|
||||
},
|
||||
room_types: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: ['string', 'null'],
|
||||
},
|
||||
description: 'Array of room types to filter by',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const isPublicRoomsPostBodyProps = ajv.compile(PublicRoomsPostBodySchema);
|
||||
|
||||
export const getMatrixRoomsRoutes = () => {
|
||||
return (
|
||||
new Router('/federation')
|
||||
.use(isAuthenticatedMiddleware())
|
||||
// GET /_matrix/federation/v1/publicRooms
|
||||
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1publicrooms
|
||||
.get(
|
||||
'/v1/publicRooms',
|
||||
{
|
||||
query: isPublicRoomsQueryProps,
|
||||
response: {
|
||||
200: isPublicRoomsResponseProps,
|
||||
},
|
||||
tags: ['Federation'],
|
||||
license: ['federation'],
|
||||
},
|
||||
async () => {
|
||||
const defaultObj = {
|
||||
join_rule: 'public',
|
||||
guest_can_join: false, // trying to reduce required endpoint hits
|
||||
world_readable: false, // ^^^
|
||||
avatar_url: '', // ?? don't have any yet
|
||||
};
|
||||
|
||||
const publicRooms = await federationSDK.getAllPublicRoomIdsAndNames();
|
||||
|
||||
return {
|
||||
body: {
|
||||
chunk: publicRooms.map((room) => ({
|
||||
...defaultObj,
|
||||
...room,
|
||||
})),
|
||||
},
|
||||
statusCode: 200,
|
||||
};
|
||||
},
|
||||
)
|
||||
// POST /_matrix/federation/v1/publicRooms
|
||||
// https://spec.matrix.org/v1.19/server-server-api/#post_matrixfederationv1publicrooms
|
||||
.post(
|
||||
'/v1/publicRooms',
|
||||
{
|
||||
body: isPublicRoomsPostBodyProps,
|
||||
response: {
|
||||
200: isPublicRoomsResponseProps,
|
||||
},
|
||||
tags: ['Federation'],
|
||||
license: ['federation'],
|
||||
},
|
||||
async (c) => {
|
||||
const body = await c.req.json();
|
||||
|
||||
const defaultObj = {
|
||||
join_rule: 'public',
|
||||
guest_can_join: false, // trying to reduce required endpoint hits
|
||||
world_readable: false, // ^^^
|
||||
avatar_url: '', // ?? don't have any yet
|
||||
};
|
||||
|
||||
const { filter } = body;
|
||||
|
||||
const publicRooms = await federationSDK.getAllPublicRoomIdsAndNames();
|
||||
|
||||
return {
|
||||
body: {
|
||||
chunk: publicRooms
|
||||
.filter((r) => {
|
||||
if (filter?.generic_search_term) {
|
||||
return r.name.toLowerCase().includes(filter.generic_search_term.toLowerCase());
|
||||
}
|
||||
|
||||
// Today only one room type is supported (https://spec.matrix.org/v1.15/client-server-api/#types)
|
||||
// TODO: https://rocketchat.atlassian.net/browse/FDR-152 -> Implement logic to handle custom room types
|
||||
// if (filter.room_types) {
|
||||
// }
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((room) => ({
|
||||
...defaultObj,
|
||||
...room,
|
||||
})),
|
||||
},
|
||||
statusCode: 200,
|
||||
};
|
||||
},
|
||||
)
|
||||
);
|
||||
};
|
||||
146
ee/packages/federation-matrix/src/api/_matrix/send-join.ts
Normal file
146
ee/packages/federation-matrix/src/api/_matrix/send-join.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { Router } from '@rocket.chat/http-router';
|
||||
import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv';
|
||||
|
||||
import { canAccessResourceMiddleware } from '../middlewares/canAccessResource';
|
||||
|
||||
const UsernameSchema = {
|
||||
type: 'string',
|
||||
pattern: '^@[A-Za-z0-9_=\\/.+-]+:(.+)$',
|
||||
description: 'Matrix user ID in format @user:server.com',
|
||||
};
|
||||
|
||||
const RoomIdSchema = {
|
||||
type: 'string',
|
||||
pattern: '^![A-Za-z0-9_=\\/.+-]+:(.+)$',
|
||||
description: 'Matrix room ID in format !room:server.com',
|
||||
};
|
||||
|
||||
const EventIdSchema = {
|
||||
type: 'string',
|
||||
pattern: '^\\$[A-Za-z0-9_=\\/.+-]+(:(.+))?$',
|
||||
description: 'Matrix event ID in format $event',
|
||||
};
|
||||
|
||||
const TimestampSchema = {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
description: 'Unix timestamp in milliseconds',
|
||||
};
|
||||
|
||||
const ServerNameSchema = {
|
||||
type: 'string',
|
||||
description: 'Matrix server name',
|
||||
};
|
||||
|
||||
const SendJoinParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomId: RoomIdSchema,
|
||||
stateKey: EventIdSchema,
|
||||
},
|
||||
required: ['roomId', 'stateKey'],
|
||||
};
|
||||
|
||||
const isSendJoinParamsProps = ajv.compile(SendJoinParamsSchema);
|
||||
|
||||
const SendJoinEventSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
const: 'm.room.member',
|
||||
},
|
||||
state_key: {
|
||||
...UsernameSchema,
|
||||
description: 'Matrix user ID of the joining member',
|
||||
},
|
||||
sender: {
|
||||
...UsernameSchema,
|
||||
description: 'Matrix user ID of the joining member',
|
||||
},
|
||||
origin: {
|
||||
...ServerNameSchema,
|
||||
description: 'The name of the joining homeserver',
|
||||
},
|
||||
origin_server_ts: TimestampSchema,
|
||||
content: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
membership: {
|
||||
type: 'string',
|
||||
const: 'join',
|
||||
},
|
||||
join_authorised_via_users_server: {
|
||||
...UsernameSchema,
|
||||
description: 'User ID of a resident server member authorizing the join into a restricted room',
|
||||
},
|
||||
},
|
||||
required: ['membership'],
|
||||
},
|
||||
},
|
||||
required: ['type', 'state_key', 'sender', 'origin', 'origin_server_ts', 'content'],
|
||||
};
|
||||
|
||||
const isSendJoinEventProps = ajv.compile(SendJoinEventSchema);
|
||||
|
||||
const SendJoinResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
event: {
|
||||
type: 'object',
|
||||
description: 'The processed join event',
|
||||
},
|
||||
state: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
},
|
||||
description: 'Current state events in the room',
|
||||
},
|
||||
auth_chain: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
},
|
||||
description: 'Authorization chain for the event',
|
||||
},
|
||||
members_omitted: {
|
||||
type: 'boolean',
|
||||
description: 'Whether member events were omitted',
|
||||
},
|
||||
origin: ServerNameSchema,
|
||||
},
|
||||
required: ['event', 'state', 'auth_chain', 'members_omitted', 'origin'],
|
||||
};
|
||||
|
||||
const isSendJoinResponseProps = ajv.compile(SendJoinResponseSchema);
|
||||
|
||||
export const getMatrixSendJoinRoutes = () => {
|
||||
// PUT /_matrix/federation/v2/send_join/{roomId}/{eventId}
|
||||
// https://spec.matrix.org/v1.19/server-server-api/#put_matrixfederationv2send_joinroomideventid
|
||||
return new Router('/federation').put(
|
||||
'/v2/send_join/:roomId/:stateKey',
|
||||
{
|
||||
params: isSendJoinParamsProps,
|
||||
body: isSendJoinEventProps,
|
||||
response: {
|
||||
200: isSendJoinResponseProps,
|
||||
},
|
||||
tags: ['Federation'],
|
||||
license: ['federation'],
|
||||
},
|
||||
canAccessResourceMiddleware('room'),
|
||||
async (c) => {
|
||||
const { roomId, stateKey } = c.req.param();
|
||||
const body = await c.req.json();
|
||||
|
||||
const response = await federationSDK.sendJoin(roomId, stateKey, body);
|
||||
|
||||
return {
|
||||
body: response,
|
||||
statusCode: 200,
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
109
ee/packages/federation-matrix/src/api/_matrix/send-leave.ts
Normal file
109
ee/packages/federation-matrix/src/api/_matrix/send-leave.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { NotAllowedError, federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { Router } from '@rocket.chat/http-router';
|
||||
import { ajv } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { logger } from '../logger';
|
||||
import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated';
|
||||
|
||||
const isSendLeaveParamsProps = ajv.compile({
|
||||
type: 'object',
|
||||
properties: { roomId: { type: 'string' }, eventId: { type: 'string' } },
|
||||
required: ['roomId', 'eventId'],
|
||||
});
|
||||
const isSendLeaveBodyProps = ajv.compile({
|
||||
type: 'object',
|
||||
properties: {
|
||||
content: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
membership: {
|
||||
type: 'string',
|
||||
const: 'leave',
|
||||
},
|
||||
},
|
||||
},
|
||||
depth: {
|
||||
type: 'number',
|
||||
},
|
||||
origin: {
|
||||
type: 'string',
|
||||
},
|
||||
origin_server_ts: {
|
||||
type: 'number',
|
||||
},
|
||||
sender: {
|
||||
type: 'string',
|
||||
},
|
||||
state_key: {
|
||||
type: 'string',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
const: 'm.room.member',
|
||||
},
|
||||
},
|
||||
required: ['content', 'depth', 'origin', 'origin_server_ts', 'sender', 'state_key', 'type'],
|
||||
});
|
||||
const isSendLeaveSuccessResponseProps = ajv.compile({
|
||||
type: 'object',
|
||||
properties: {},
|
||||
});
|
||||
const isSendLeaveForbiddenResponseProps = ajv.compile({
|
||||
type: 'object',
|
||||
properties: { errcode: { type: 'string', const: 'M_FORBIDDEN' }, error: { type: 'string' } },
|
||||
});
|
||||
const isSendLeaveErrorResponseProps = ajv.compile({
|
||||
type: 'object',
|
||||
properties: { errcode: { type: 'string', const: 'M_UNKNOWN' }, error: { type: 'string' } },
|
||||
});
|
||||
|
||||
export const getMatrixSendLeaveRoutes = () => {
|
||||
// PUT /_matrix/federation/v2/send_leave/{roomId}/{eventId}
|
||||
// https://spec.matrix.org/v1.19/server-server-api/#put_matrixfederationv2send_leaveroomideventid
|
||||
return new Router('/federation').put(
|
||||
'/v2/send_leave/:roomId/:eventId',
|
||||
{
|
||||
params: isSendLeaveParamsProps,
|
||||
body: isSendLeaveBodyProps,
|
||||
response: {
|
||||
200: isSendLeaveSuccessResponseProps,
|
||||
403: isSendLeaveForbiddenResponseProps,
|
||||
500: isSendLeaveErrorResponseProps,
|
||||
},
|
||||
tags: ['Federation'],
|
||||
license: ['federation'],
|
||||
},
|
||||
isAuthenticatedMiddleware(),
|
||||
async (c) => {
|
||||
const { roomId, eventId } = c.req.param();
|
||||
const body = await c.req.json();
|
||||
try {
|
||||
await federationSDK.sendLeave(roomId, eventId, body);
|
||||
return {
|
||||
body: {},
|
||||
statusCode: 200,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof NotAllowedError) {
|
||||
return {
|
||||
body: {
|
||||
errcode: 'M_FORBIDDEN',
|
||||
error: 'This server does not allow leaving this room based on federation settings.',
|
||||
},
|
||||
statusCode: 403,
|
||||
};
|
||||
}
|
||||
|
||||
logger.error({ msg: 'Error sending leave', err: error });
|
||||
|
||||
return {
|
||||
body: {
|
||||
errcode: 'M_UNKNOWN',
|
||||
error: error instanceof Error ? error.message : 'Internal server error while processing request',
|
||||
},
|
||||
statusCode: 500,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
494
ee/packages/federation-matrix/src/api/_matrix/transactions.ts
Normal file
494
ee/packages/federation-matrix/src/api/_matrix/transactions.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
import type { EventID } from '@rocket.chat/federation-sdk';
|
||||
import { federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { Router } from '@rocket.chat/http-router';
|
||||
import { ajv, ajvQuery } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { logger } from '../logger';
|
||||
import { canAccessResourceMiddleware } from '../middlewares/canAccessResource';
|
||||
import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated';
|
||||
|
||||
const SendTransactionParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
txnId: {
|
||||
type: 'string',
|
||||
description: 'Transaction ID',
|
||||
},
|
||||
},
|
||||
required: ['txnId'],
|
||||
};
|
||||
|
||||
const isSendTransactionParamsProps = ajv.compile(SendTransactionParamsSchema);
|
||||
|
||||
const GetEventParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
eventId: {
|
||||
type: 'string',
|
||||
description: 'Event ID',
|
||||
},
|
||||
},
|
||||
required: ['eventId'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const isGetEventParamsProps = ajv.compile(GetEventParamsSchema);
|
||||
|
||||
const GetEventResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
origin_server_ts: {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
description: 'Unix timestamp in milliseconds',
|
||||
},
|
||||
origin: {
|
||||
type: 'string',
|
||||
description: 'Origin server',
|
||||
},
|
||||
pdus: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
},
|
||||
description: 'Persistent data units (PDUs)',
|
||||
},
|
||||
},
|
||||
required: ['origin_server_ts', 'origin', 'pdus'],
|
||||
};
|
||||
|
||||
const isGetEventResponseProps = ajv.compile(GetEventResponseSchema);
|
||||
|
||||
const SendTransactionBodySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
origin: {
|
||||
type: 'string',
|
||||
description: 'Origin server',
|
||||
},
|
||||
origin_server_ts: {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
description: 'Unix timestamp in milliseconds',
|
||||
},
|
||||
pdus: {
|
||||
type: 'array',
|
||||
items: {
|
||||
// deliberately unconstrained, matching the spec: the PDU format varies by room
|
||||
// version, and a malformed PDU must be reported per-PDU in the 200 response's
|
||||
// `pdus` map instead of failing the whole transaction with a 400
|
||||
type: 'object',
|
||||
},
|
||||
description: 'Persistent data units (PDUs) to process',
|
||||
default: [],
|
||||
},
|
||||
edus: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
},
|
||||
description: 'Ephemeral data units (EDUs)',
|
||||
default: [],
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
required: ['origin', 'origin_server_ts', 'pdus'],
|
||||
};
|
||||
|
||||
const isSendTransactionBodyProps = ajv.compile(SendTransactionBodySchema);
|
||||
|
||||
const SendTransactionResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pdus: {
|
||||
type: 'object',
|
||||
description: 'Processing results for each PDU',
|
||||
},
|
||||
},
|
||||
required: ['pdus'],
|
||||
};
|
||||
|
||||
const isSendTransactionResponseProps = ajv.compile(SendTransactionResponseSchema);
|
||||
|
||||
const ErrorResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
errcode: {
|
||||
type: 'string',
|
||||
},
|
||||
error: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['errcode', 'error'],
|
||||
};
|
||||
|
||||
const isErrorResponseProps = ajv.compile(ErrorResponseSchema);
|
||||
|
||||
const GetStateIdsParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
event_id: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['event_id'],
|
||||
};
|
||||
|
||||
const isGetStateIdsParamsProps = ajv.compile(GetStateIdsParamsSchema);
|
||||
|
||||
const GetStateIdsResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
auth_chain_ids: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
description: 'Auth chain event IDs, recursively',
|
||||
},
|
||||
pdu_ids: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
description: 'Event IDs of the fully resolved room state at the given event',
|
||||
},
|
||||
},
|
||||
required: ['auth_chain_ids', 'pdu_ids'],
|
||||
};
|
||||
|
||||
const isGetStateIdsResponseProps = ajv.compile(GetStateIdsResponseSchema);
|
||||
const GetStateParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
event_id: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
};
|
||||
const isGetStateParamsProps = ajv.compile<{
|
||||
event_id: string;
|
||||
}>(GetStateParamsSchema);
|
||||
|
||||
const GetStateResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
auth_chain: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
},
|
||||
description: 'Auth chain events, recursively',
|
||||
},
|
||||
pdus: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
},
|
||||
description: 'The fully resolved room state at the given event',
|
||||
},
|
||||
},
|
||||
required: ['auth_chain', 'pdus'],
|
||||
};
|
||||
|
||||
const isGetStateResponseProps = ajv.compile(GetStateResponseSchema);
|
||||
|
||||
const BackfillParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomId: {
|
||||
type: 'string',
|
||||
pattern: '^![A-Za-z0-9_=\\/.+-]+:(.+)$',
|
||||
description: 'Matrix room ID',
|
||||
},
|
||||
},
|
||||
required: ['roomId'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const isBackfillParamsProps = ajv.compile(BackfillParamsSchema);
|
||||
|
||||
const BackfillQuerySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: {
|
||||
// unbounded above per spec; the handler caps it. Synapse rejects 0 and negatives
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
description: 'Maximum number of events to retrieve',
|
||||
},
|
||||
v: {
|
||||
// a string branch here would be redundant: ajvQuery coerces a single `?v=` into a
|
||||
// one-element array, and in a `oneOf` both branches would match and fail validation
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Event ID(s) to backfill from',
|
||||
},
|
||||
},
|
||||
required: ['limit', 'v'],
|
||||
};
|
||||
|
||||
const isBackfillQueryProps = ajvQuery.compile<{
|
||||
limit: number;
|
||||
v: string[];
|
||||
}>(BackfillQuerySchema);
|
||||
|
||||
const BackfillResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
origin: {
|
||||
type: 'string',
|
||||
description: 'Origin server',
|
||||
},
|
||||
origin_server_ts: {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
description: 'Unix timestamp in milliseconds',
|
||||
},
|
||||
pdus: {
|
||||
type: 'array',
|
||||
items: {
|
||||
// spec: backfill responses "MUST NOT be validated" against PDU restrictions
|
||||
type: 'object',
|
||||
},
|
||||
description: 'Events in reverse chronological order',
|
||||
},
|
||||
},
|
||||
required: ['origin', 'origin_server_ts', 'pdus'],
|
||||
};
|
||||
|
||||
const isBackfillResponseProps = ajv.compile(BackfillResponseSchema);
|
||||
|
||||
export const getMatrixTransactionsRoutes = () => {
|
||||
return (
|
||||
new Router('/federation')
|
||||
.use(isAuthenticatedMiddleware())
|
||||
// PUT /_matrix/federation/v1/send/{txnId}
|
||||
// https://spec.matrix.org/v1.19/server-server-api/#put_matrixfederationv1sendtxnid
|
||||
.put(
|
||||
'/v1/send/:txnId',
|
||||
{
|
||||
params: isSendTransactionParamsProps,
|
||||
body: isSendTransactionBodyProps,
|
||||
response: {
|
||||
200: isSendTransactionResponseProps,
|
||||
400: isErrorResponseProps,
|
||||
429: isErrorResponseProps,
|
||||
500: isErrorResponseProps,
|
||||
},
|
||||
tags: ['Federation'],
|
||||
license: ['federation'],
|
||||
},
|
||||
async (c) => {
|
||||
const body = await c.req.json();
|
||||
|
||||
try {
|
||||
await federationSDK.processIncomingTransaction(body);
|
||||
} catch (error: any) {
|
||||
// TODO custom error types?
|
||||
if (error.message === 'too-many-concurrent-transactions') {
|
||||
return {
|
||||
statusCode: 429,
|
||||
body: {
|
||||
errcode: 'M_UNKNOWN',
|
||||
error: 'Too many concurrent transactions',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (error.message === 'too-many-events') {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
errcode: 'M_UNKNOWN',
|
||||
error: 'Too many PDUs or EDUs',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// a 200 tells the origin the transaction was delivered and it will never resend
|
||||
// these PDUs. Per-PDU failures are already handled by the SDK, so anything
|
||||
// reaching here is a server-side failure and must be retryable - Synapse only
|
||||
// backs off and redelivers on 5xx
|
||||
logger.error({ msg: 'Error processing incoming transaction', err: error });
|
||||
|
||||
return {
|
||||
statusCode: 500,
|
||||
body: {
|
||||
errcode: 'M_UNKNOWN',
|
||||
error: 'Failed to process transaction',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
body: {
|
||||
pdus: {},
|
||||
},
|
||||
statusCode: 200,
|
||||
};
|
||||
},
|
||||
)
|
||||
|
||||
// GET /_matrix/federation/v1/state_ids/{roomId}
|
||||
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1state_idsroomid
|
||||
.get(
|
||||
'/v1/state_ids/:roomId',
|
||||
{
|
||||
params: isGetStateIdsParamsProps,
|
||||
response: {
|
||||
200: isGetStateIdsResponseProps,
|
||||
},
|
||||
},
|
||||
canAccessResourceMiddleware('room'),
|
||||
async (c) => {
|
||||
const roomId = c.req.param('roomId');
|
||||
const eventId = c.req.query('event_id');
|
||||
|
||||
if (!eventId) {
|
||||
return {
|
||||
body: {
|
||||
errcode: 'M_NOT_FOUND',
|
||||
error: 'Event not found',
|
||||
},
|
||||
statusCode: 404,
|
||||
};
|
||||
}
|
||||
|
||||
const stateIds = await federationSDK.getStateIds(roomId, eventId as EventID);
|
||||
|
||||
return {
|
||||
body: stateIds,
|
||||
statusCode: 200,
|
||||
};
|
||||
},
|
||||
)
|
||||
// GET /_matrix/federation/v1/state/{roomId}
|
||||
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1stateroomid
|
||||
.get(
|
||||
'/v1/state/:roomId',
|
||||
{
|
||||
params: isGetStateParamsProps,
|
||||
response: {
|
||||
200: isGetStateResponseProps,
|
||||
},
|
||||
},
|
||||
canAccessResourceMiddleware('room'),
|
||||
async (c) => {
|
||||
const roomId = c.req.param('roomId');
|
||||
const eventId = c.req.query('event_id');
|
||||
|
||||
if (!eventId) {
|
||||
return {
|
||||
body: {
|
||||
errcode: 'M_NOT_FOUND',
|
||||
error: 'Event not found',
|
||||
},
|
||||
statusCode: 404,
|
||||
};
|
||||
}
|
||||
const state = await federationSDK.getState(roomId, eventId as EventID);
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: state,
|
||||
};
|
||||
},
|
||||
)
|
||||
// GET /_matrix/federation/v1/event/{eventId}
|
||||
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1eventeventid
|
||||
.get(
|
||||
'/v1/event/:eventId',
|
||||
{
|
||||
params: isGetEventParamsProps,
|
||||
response: {
|
||||
200: isGetEventResponseProps,
|
||||
},
|
||||
tags: ['Federation'],
|
||||
license: ['federation'],
|
||||
},
|
||||
canAccessResourceMiddleware('event'),
|
||||
async (c) => {
|
||||
const eventData = await federationSDK.getEventById(c.req.param('eventId') as EventID);
|
||||
if (!eventData) {
|
||||
return {
|
||||
body: {
|
||||
errcode: 'M_NOT_FOUND',
|
||||
error: 'Event not found',
|
||||
},
|
||||
statusCode: 404,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
body: {
|
||||
origin_server_ts: eventData.event.origin_server_ts,
|
||||
origin: eventData.origin,
|
||||
pdus: [eventData.event],
|
||||
},
|
||||
statusCode: 200,
|
||||
};
|
||||
},
|
||||
)
|
||||
// GET /_matrix/federation/v1/backfill/{roomId}
|
||||
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1backfillroomid
|
||||
.get(
|
||||
'/v1/backfill/:roomId',
|
||||
{
|
||||
params: isBackfillParamsProps,
|
||||
query: isBackfillQueryProps,
|
||||
response: {
|
||||
200: isBackfillResponseProps,
|
||||
},
|
||||
tags: ['Federation'],
|
||||
license: ['federation'],
|
||||
},
|
||||
canAccessResourceMiddleware('room'),
|
||||
async (c) => {
|
||||
const roomId = c.req.param('roomId');
|
||||
const limit = Math.min(Number(c.req.query('limit') || 100), 100);
|
||||
|
||||
// this will be handled by the federation-sdk on next versions so this can be removed
|
||||
if (limit < 1) {
|
||||
return {
|
||||
body: {
|
||||
origin: federationSDK.getConfig('serverName'),
|
||||
origin_server_ts: Date.now(),
|
||||
pdus: [],
|
||||
},
|
||||
statusCode: 200,
|
||||
};
|
||||
}
|
||||
|
||||
const eventIds = c.req.queries('v');
|
||||
if (!eventIds?.length) {
|
||||
return {
|
||||
body: {
|
||||
errcode: 'M_BAD_REQUEST',
|
||||
error: 'Event ID must be provided in v query parameter',
|
||||
},
|
||||
statusCode: 400,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await federationSDK.getBackfillEvents(roomId, eventIds as EventID[], limit);
|
||||
|
||||
return {
|
||||
body: result,
|
||||
statusCode: 200,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
body: {
|
||||
errcode: 'M_UNKNOWN',
|
||||
error: 'Failed to get backfill events',
|
||||
},
|
||||
statusCode: 500,
|
||||
};
|
||||
}
|
||||
},
|
||||
)
|
||||
);
|
||||
};
|
||||
53
ee/packages/federation-matrix/src/api/_matrix/versions.ts
Normal file
53
ee/packages/federation-matrix/src/api/_matrix/versions.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Router } from '@rocket.chat/http-router';
|
||||
import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv';
|
||||
|
||||
const GetVersionsResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
server: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Server software name',
|
||||
},
|
||||
version: {
|
||||
type: 'string',
|
||||
description: 'Server software version',
|
||||
},
|
||||
},
|
||||
required: ['name', 'version'],
|
||||
},
|
||||
},
|
||||
required: ['server'],
|
||||
};
|
||||
|
||||
const isGetVersionsResponseProps = ajv.compile(GetVersionsResponseSchema);
|
||||
|
||||
export const getFederationVersionsRoutes = (version: string) => {
|
||||
// GET /_matrix/federation/v1/version
|
||||
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1version
|
||||
return new Router('/federation').get(
|
||||
'/v1/version',
|
||||
{
|
||||
response: {
|
||||
200: isGetVersionsResponseProps,
|
||||
},
|
||||
tags: ['Federation'],
|
||||
license: ['federation'],
|
||||
},
|
||||
async () => {
|
||||
const response = {
|
||||
server: {
|
||||
name: 'Rocket.Chat',
|
||||
version,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
body: response,
|
||||
statusCode: 200,
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
3
ee/packages/federation-matrix/src/api/logger.ts
Normal file
3
ee/packages/federation-matrix/src/api/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
|
||||
export const logger = new Logger('FederationMatrixAPI');
|
||||
@@ -0,0 +1,56 @@
|
||||
import { errCodes, federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { every } from 'hono/combine';
|
||||
import { createMiddleware } from 'hono/factory';
|
||||
|
||||
import { isAuthenticatedMiddleware } from './isAuthenticated';
|
||||
|
||||
function extractEntityId(
|
||||
params: { eventId?: string; mediaId?: string; roomId?: string },
|
||||
entityType: 'event' | 'media' | 'room',
|
||||
): string | null {
|
||||
if (entityType === 'room') {
|
||||
return params.roomId ?? null;
|
||||
}
|
||||
|
||||
if (entityType === 'media') {
|
||||
return params.mediaId ?? null;
|
||||
}
|
||||
|
||||
if (entityType === 'event') {
|
||||
return params.eventId ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const canAccessResource = (entityType: 'event' | 'media' | 'room') =>
|
||||
createMiddleware(async (c, next) => {
|
||||
try {
|
||||
const mediaId = c.req.param('mediaId');
|
||||
const eventId = c.req.param('eventId');
|
||||
const roomId = c.req.param('roomId');
|
||||
|
||||
const resourceId = extractEntityId({ mediaId, eventId, roomId }, entityType);
|
||||
if (!resourceId) {
|
||||
return c.json({ errcode: 'M_INVALID_PARAM', error: `Missing required ${entityType} identifier` }, 400);
|
||||
}
|
||||
|
||||
const resourceAccess = await federationSDK.canAccessResource(entityType, resourceId, c.get('authenticatedServer'));
|
||||
if (!resourceAccess) {
|
||||
return c.json(
|
||||
{
|
||||
errcode: 'M_FORBIDDEN',
|
||||
error: 'Access denied to resource',
|
||||
},
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
return c.json(errCodes.M_UNKNOWN, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export const canAccessResourceMiddleware = (entityType: 'event' | 'media' | 'room') =>
|
||||
every(isAuthenticatedMiddleware(), canAccessResource(entityType));
|
||||
@@ -0,0 +1,101 @@
|
||||
import { errCodes, federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import type { Context } from 'hono';
|
||||
import { createMiddleware } from 'hono/factory';
|
||||
|
||||
import { decodeXmppUserId, isFullXmppUserId, parseXmppUserId } from '../../helpers/parseXmppUserId';
|
||||
|
||||
export const isAppServiceAuthenticatedMiddleware = () =>
|
||||
createMiddleware(async (c: Context, next) => {
|
||||
try {
|
||||
const authHeader = c.req.header('Authorization') || '';
|
||||
const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||
const token = bearerMatch?.[1] ?? c.req.query('access_token');
|
||||
|
||||
if (!token) {
|
||||
return c.json(
|
||||
{
|
||||
errcode: 'M_MISSING_TOKEN',
|
||||
error: 'Missing access token',
|
||||
},
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
const appService = federationSDK.getRegistrationByAsToken(token);
|
||||
if (!appService) {
|
||||
return c.json(
|
||||
{
|
||||
errcode: 'M_UNKNOWN_TOKEN',
|
||||
error: 'Invalid application service token',
|
||||
},
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
c.set('appService', appService);
|
||||
|
||||
const appUserId = `@${appService.registration.senderLocalpart}:${federationSDK.getConfig('serverName')}`;
|
||||
const userId = c.req.query('user_id');
|
||||
|
||||
if (!userId) {
|
||||
c.set('impersonatedUserId', appUserId);
|
||||
return next();
|
||||
}
|
||||
|
||||
if (userId === appUserId) {
|
||||
c.set('impersonatedUserId', userId);
|
||||
return next();
|
||||
}
|
||||
|
||||
const inNamespace = federationSDK.isUserInAppServiceNamespace(userId, appService.registration._id);
|
||||
if (!inNamespace) {
|
||||
return c.json(
|
||||
{
|
||||
errcode: 'M_FORBIDDEN',
|
||||
error: 'Application service cannot masquerade as this user',
|
||||
},
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
const serverName = federationSDK.getConfig('serverName');
|
||||
|
||||
const decoded = decodeXmppUserId(userId);
|
||||
|
||||
// Not a packed XMPP JID: the registration flow stores these users under the
|
||||
// full MXID as-is, so impersonate that.
|
||||
if (!isFullXmppUserId(decoded)) {
|
||||
c.set('impersonatedUserId', userId);
|
||||
return next();
|
||||
}
|
||||
|
||||
let decodedUsername;
|
||||
try {
|
||||
decodedUsername = parseXmppUserId(decoded);
|
||||
} catch {
|
||||
return c.json(
|
||||
{
|
||||
errcode: 'M_INVALID_USER_ID',
|
||||
error: 'Invalid user id',
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (!decodedUsername.resource) {
|
||||
return c.json(
|
||||
{
|
||||
errcode: 'M_INVALID_USER_ID',
|
||||
error: 'Invalid user id',
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
c.set('impersonatedUserId', `${decodedUsername.resource}:${serverName}`);
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
return c.json(errCodes.M_UNKNOWN, 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { errCodes, federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import type { Context } from 'hono';
|
||||
import { createMiddleware } from 'hono/factory';
|
||||
|
||||
export const isAuthenticatedMiddleware = () =>
|
||||
createMiddleware(async (c: Context, next) => {
|
||||
try {
|
||||
const { method } = c.req;
|
||||
const body = c.req.raw.body ? await c.req.raw.clone().json() : undefined;
|
||||
const url = new URL(c.req.url);
|
||||
const path = url.pathname + url.search;
|
||||
const authHeader = c.req.header('Authorization') || '';
|
||||
if (!authHeader) {
|
||||
return c.json(
|
||||
{
|
||||
errcode: 'M_UNAUTHORIZED',
|
||||
error: 'Missing Authorization header',
|
||||
},
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
const verificationResult = await federationSDK.verifyRequestSignature(authHeader, method, path, body);
|
||||
if (!verificationResult) {
|
||||
return c.json(
|
||||
{
|
||||
errcode: errCodes.M_UNAUTHORIZED.errcode,
|
||||
error: errCodes.M_UNAUTHORIZED.error,
|
||||
},
|
||||
errCodes.M_UNAUTHORIZED.status,
|
||||
);
|
||||
}
|
||||
|
||||
c.set('authenticatedServer', verificationResult);
|
||||
return next();
|
||||
} catch (error) {
|
||||
return c.json(errCodes.M_UNKNOWN, 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Settings } from '@rocket.chat/core-services';
|
||||
import { createMiddleware } from 'hono/factory';
|
||||
import mem from 'mem';
|
||||
|
||||
// cache for 60 seconds
|
||||
const getAllowList = mem(
|
||||
async () => {
|
||||
const allowListSetting = await Settings.get<string>('Federation_Service_Allow_List');
|
||||
return allowListSetting
|
||||
? allowListSetting
|
||||
.split(',')
|
||||
.map((d) => d.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
},
|
||||
{ maxAge: 60000 },
|
||||
);
|
||||
|
||||
/**
|
||||
* Parses all key-value pairs from a Matrix authorization header.
|
||||
* Example: X-Matrix origin="matrix.org", key="value", ...
|
||||
* Returns an object with all parsed values.
|
||||
*/
|
||||
// TODO make this function more of a utility if needed elsewhere
|
||||
function parseMatrixAuthorizationHeader(header: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
// Match key="value" pairs
|
||||
const regex = /([a-zA-Z0-9_-]+)\s*=\s*"([^"]*)"/g;
|
||||
let match;
|
||||
while ((match = regex.exec(header)) !== null) {
|
||||
result[match[1]] = match[2];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const isFederationDomainAllowedMiddleware = createMiddleware(async (c, next) => {
|
||||
const allowList = await getAllowList();
|
||||
if (!allowList || allowList.length === 0) {
|
||||
// No restriction, allow all
|
||||
return next();
|
||||
}
|
||||
|
||||
// Extract all key-value pairs from Matrix authorization header
|
||||
const authHeader = c.req.header('authorization');
|
||||
if (!authHeader) {
|
||||
return c.json({ errcode: 'M_UNAUTHORIZED', error: 'Missing Authorization headers.' }, 401);
|
||||
}
|
||||
|
||||
const authValues = parseMatrixAuthorizationHeader(authHeader);
|
||||
const domain = authValues.origin?.toLowerCase();
|
||||
if (!domain) {
|
||||
return c.json({ errcode: 'M_MISSING_ORIGIN', error: 'Missing origin in authorization header.' }, 401);
|
||||
}
|
||||
|
||||
// Check if domain is in allowed list (exact match only)
|
||||
if (allowList.some((allowed) => domain === allowed)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return c.json({ errcode: 'M_FORBIDDEN', error: 'Federation from this domain is not allowed.' }, 403);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Settings } from '@rocket.chat/core-services';
|
||||
import { createMiddleware } from 'hono/factory';
|
||||
|
||||
export const isFederationEnabledMiddleware = createMiddleware(async (c, next) => {
|
||||
// TODO use federationSDK to check if federation is enabled
|
||||
if (!(await Settings.get<boolean>('Federation_Service_Enabled'))) {
|
||||
return c.json({ error: 'Federation is not enabled' }, 403);
|
||||
}
|
||||
return next();
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { License } from '@rocket.chat/core-services';
|
||||
import { createMiddleware } from 'hono/factory';
|
||||
|
||||
export const isLicenseEnabledMiddleware = createMiddleware(async (c, next) => {
|
||||
if (!(await License.hasModule('federation'))) {
|
||||
return c.json({ error: 'Federation is not enabled' }, 403);
|
||||
}
|
||||
return next();
|
||||
});
|
||||
44
ee/packages/federation-matrix/src/api/routes.ts
Normal file
44
ee/packages/federation-matrix/src/api/routes.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Router } from '@rocket.chat/http-router';
|
||||
|
||||
import { getWellKnownRoutes } from './.well-known/server';
|
||||
import { getClientRoutes } from './_matrix/client';
|
||||
import { getMatrixInviteRoutes } from './_matrix/invite';
|
||||
import { getKeyServerRoutes } from './_matrix/key/server';
|
||||
import { getMatrixMakeLeaveRoutes } from './_matrix/make-leave';
|
||||
import { getMatrixMediaRoutes } from './_matrix/media';
|
||||
import { getMatrixMediaBridgeRoutes } from './_matrix/media-bridge';
|
||||
import { getMatrixProfilesRoutes } from './_matrix/profiles';
|
||||
import { getMatrixRoomsRoutes } from './_matrix/rooms';
|
||||
import { getMatrixSendJoinRoutes } from './_matrix/send-join';
|
||||
import { getMatrixSendLeaveRoutes } from './_matrix/send-leave';
|
||||
import { getMatrixTransactionsRoutes } from './_matrix/transactions';
|
||||
import { getFederationVersionsRoutes } from './_matrix/versions';
|
||||
import { isFederationDomainAllowedMiddleware } from './middlewares/isFederationDomainAllowed';
|
||||
import { isFederationEnabledMiddleware } from './middlewares/isFederationEnabled';
|
||||
import { isLicenseEnabledMiddleware } from './middlewares/isLicenseEnabled';
|
||||
|
||||
export const getFederationRoutes = (version: string): { matrix: Router<'/_matrix'>; wellKnown: Router<'/.well-known'> } => {
|
||||
const matrix = new Router('/_matrix');
|
||||
const wellKnown = new Router('/.well-known');
|
||||
|
||||
matrix
|
||||
.use(isFederationEnabledMiddleware)
|
||||
.use(isLicenseEnabledMiddleware)
|
||||
.use(getKeyServerRoutes())
|
||||
.use(getFederationVersionsRoutes(version))
|
||||
.use(getClientRoutes())
|
||||
.use(getMatrixMediaBridgeRoutes())
|
||||
.use(isFederationDomainAllowedMiddleware)
|
||||
.use(getMatrixInviteRoutes())
|
||||
.use(getMatrixProfilesRoutes())
|
||||
.use(getMatrixRoomsRoutes())
|
||||
.use(getMatrixSendJoinRoutes())
|
||||
.use(getMatrixTransactionsRoutes())
|
||||
.use(getMatrixMediaRoutes())
|
||||
.use(getMatrixSendLeaveRoutes())
|
||||
.use(getMatrixMakeLeaveRoutes());
|
||||
|
||||
wellKnown.use(isFederationEnabledMiddleware).use(isLicenseEnabledMiddleware).use(getWellKnownRoutes());
|
||||
|
||||
return { matrix, wellKnown };
|
||||
};
|
||||
114
ee/packages/federation-matrix/src/events/edu.ts
Normal file
114
ee/packages/federation-matrix/src/events/edu.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { api, Room } from '@rocket.chat/core-services';
|
||||
import { UserStatus } from '@rocket.chat/core-typings';
|
||||
import { federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
import { Messages, Rooms, Users } from '@rocket.chat/models';
|
||||
|
||||
const logger = new Logger('federation-matrix:edu');
|
||||
|
||||
export const edus = async () => {
|
||||
federationSDK.eventEmitterService.on('homeserver.matrix.typing', async (data) => {
|
||||
try {
|
||||
const matrixRoom = await Rooms.findOne({ 'federation.mrid': data.room_id }, { projection: { _id: 1 } });
|
||||
if (!matrixRoom) {
|
||||
logger.debug({ msg: 'No bridged room found for Matrix room_id', roomId: data.room_id });
|
||||
return;
|
||||
}
|
||||
|
||||
void api.broadcast('user.activity', {
|
||||
user: data.user_id,
|
||||
isTyping: data.typing,
|
||||
roomId: matrixRoom._id,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error handling Matrix typing event', err });
|
||||
}
|
||||
});
|
||||
|
||||
federationSDK.eventEmitterService.on('homeserver.matrix.presence', async (data) => {
|
||||
try {
|
||||
const matrixUser = await Users.findOneByUsername(data.user_id);
|
||||
if (!matrixUser) {
|
||||
logger.debug({ msg: 'No federated user found for Matrix user_id', userId: data.user_id });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!matrixUser.federated) {
|
||||
logger.debug({ msg: 'User is not federated, skipping presence update from Matrix', username: matrixUser.username });
|
||||
return;
|
||||
}
|
||||
|
||||
const statusMap = {
|
||||
online: UserStatus.ONLINE,
|
||||
offline: UserStatus.OFFLINE,
|
||||
unavailable: UserStatus.AWAY,
|
||||
};
|
||||
|
||||
const status = statusMap[data.presence] || UserStatus.OFFLINE;
|
||||
|
||||
if (matrixUser.status === status) {
|
||||
logger.debug({ msg: 'User already has target status, skipping update', username: matrixUser.username, status });
|
||||
return;
|
||||
}
|
||||
|
||||
await Users.updateOne(
|
||||
{ _id: matrixUser._id },
|
||||
{
|
||||
$set: {
|
||||
status,
|
||||
statusDefault: status,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { _id, username, statusText, roles, name } = matrixUser;
|
||||
void api.broadcast('presence.status', {
|
||||
user: { status, _id, username, statusText, roles, name },
|
||||
previousStatus: undefined,
|
||||
});
|
||||
logger.debug({ msg: 'Updated presence for user from Matrix federation', userId: matrixUser._id, status });
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error handling Matrix presence event', err });
|
||||
}
|
||||
});
|
||||
|
||||
federationSDK.eventEmitterService.on('homeserver.matrix.receipt', async (data) => {
|
||||
try {
|
||||
const matrixUser = await Users.findOneByUsername(data.user_id);
|
||||
if (!matrixUser) {
|
||||
logger.debug({ msg: 'No federated user found for Matrix user_id', userId: data.user_id });
|
||||
return;
|
||||
}
|
||||
|
||||
const matrixRoom = await Rooms.findOne({ 'federation.mrid': data.room_id });
|
||||
if (!matrixRoom) {
|
||||
logger.debug({ msg: 'No bridged room found for Matrix room_id', roomId: data.room_id });
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.thread_id && data.thread_id !== 'main') {
|
||||
const msg = await Messages.findOneByFederationId(data.thread_id);
|
||||
if (!msg) {
|
||||
logger.debug({ msg: 'No message found for Matrix thread_id', threadId: data.thread_id });
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.rid !== matrixRoom._id) {
|
||||
logger.warn({
|
||||
msg: 'Message thread_id does not belong to the expected room',
|
||||
threadId: data.thread_id,
|
||||
expectedRoomId: matrixRoom._id,
|
||||
actualRoomId: msg.rid,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await Room.readThread({ room: matrixRoom, user: matrixUser, tmid: msg._id });
|
||||
} else {
|
||||
await Room.markAsRead(matrixRoom, matrixUser._id);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error handling Matrix receipt event', err });
|
||||
}
|
||||
});
|
||||
};
|
||||
15
ee/packages/federation-matrix/src/events/index.ts
Normal file
15
ee/packages/federation-matrix/src/events/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { edus } from './edu';
|
||||
import { member } from './member';
|
||||
import { message } from './message';
|
||||
import { ping } from './ping';
|
||||
import { reaction } from './reaction';
|
||||
import { room } from './room';
|
||||
|
||||
export function registerEvents() {
|
||||
void ping();
|
||||
message();
|
||||
reaction();
|
||||
member();
|
||||
void edus();
|
||||
room();
|
||||
}
|
||||
452
ee/packages/federation-matrix/src/events/member.ts
Normal file
452
ee/packages/federation-matrix/src/events/member.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
import { Room, Upload } from '@rocket.chat/core-services';
|
||||
import { isBannedSubscription } from '@rocket.chat/core-typings';
|
||||
import type { IRoomNativeFederated, IRoom, IUser, RoomType } from '@rocket.chat/core-typings';
|
||||
import { federationSDK, type HomeserverEventSignatures, type PduForType } from '@rocket.chat/federation-sdk';
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
import { Rooms, Subscriptions, Users } from '@rocket.chat/models';
|
||||
import debounce from 'lodash.debounce';
|
||||
import mem from 'mem';
|
||||
|
||||
import { extractDomainFromMatrixUserId } from '../helpers/extractDomainFromMatrixUserId';
|
||||
import { getFederatedRoomName } from '../helpers/getFederatedRoomName';
|
||||
import { getOrCreateFederatedUser } from '../helpers/getOrCreateFederatedUser';
|
||||
import { getUsernameServername } from '../helpers/getUsernameServername';
|
||||
import { MatrixMediaService } from '../services/MatrixMediaService';
|
||||
|
||||
const logger = new Logger('federation-matrix:member');
|
||||
|
||||
async function downloadAndSetAvatar(user: IUser, avatarUrl: string | null): Promise<void> {
|
||||
try {
|
||||
// if no avatarUrl is provided, it means the user removed his avatar, so we need to set an empty avatar to remove the avatar from their side as well
|
||||
if (!avatarUrl) {
|
||||
await Upload.resetUserAvatar(user);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!avatarUrl?.startsWith('mxc://')) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Downloading avatar for user ${user.username}: ${avatarUrl}`);
|
||||
|
||||
const parsed = MatrixMediaService.parseMXCUri(avatarUrl);
|
||||
if (!parsed) {
|
||||
logger.warn(`Invalid MXC URI: ${avatarUrl}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = await federationSDK.downloadFromRemoteServer(parsed.serverName, parsed.mediaId);
|
||||
if (!buffer) {
|
||||
logger.warn(`Failed to download avatar from ${avatarUrl}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// detect content type from buffer (basic image type detection)
|
||||
let contentType: string | undefined;
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8) {
|
||||
contentType = 'image/jpeg';
|
||||
} else if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
|
||||
contentType = 'image/png';
|
||||
} else if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) {
|
||||
contentType = 'image/gif';
|
||||
} else if (
|
||||
buffer[0] === 0x52 &&
|
||||
buffer[1] === 0x49 &&
|
||||
buffer[2] === 0x46 &&
|
||||
buffer[3] === 0x46 &&
|
||||
buffer[8] === 0x57 &&
|
||||
buffer[9] === 0x45 &&
|
||||
buffer[10] === 0x42 &&
|
||||
buffer[11] === 0x50
|
||||
) {
|
||||
contentType = 'image/webp';
|
||||
}
|
||||
|
||||
if (!contentType) {
|
||||
logger.warn({ msg: 'Unsupported remote avatar format from external server', username: user.username, avatarUrl });
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO need to perform a validation to check if the user actually changed avatar
|
||||
await Upload.setUserAvatar(user, buffer, contentType, 'rest');
|
||||
} catch (error) {
|
||||
logger.error({ err: error, user: user.username, msg: `Error downloading/setting avatar for user` });
|
||||
}
|
||||
}
|
||||
|
||||
async function getOrCreateFederatedRoom({
|
||||
matrixRoomId,
|
||||
roomName,
|
||||
roomFName,
|
||||
roomType,
|
||||
inviterUserId,
|
||||
inviterUsername,
|
||||
inviteeUsername,
|
||||
}: {
|
||||
matrixRoomId: string;
|
||||
roomName: string;
|
||||
roomFName: string;
|
||||
roomType: RoomType;
|
||||
inviterUserId: string;
|
||||
inviterUsername: string;
|
||||
inviteeUsername?: string;
|
||||
}): Promise<IRoom> {
|
||||
try {
|
||||
const room = await Rooms.findOne({ 'federation.mrid': matrixRoomId });
|
||||
if (room) {
|
||||
return room;
|
||||
}
|
||||
|
||||
const origin = matrixRoomId.split(':').pop();
|
||||
if (!origin) {
|
||||
throw new Error(`Room origin not found for Matrix ID: ${matrixRoomId}`);
|
||||
}
|
||||
|
||||
// TODO room creator is not always the inviter
|
||||
|
||||
return Room.create<IRoomNativeFederated>(inviterUserId, {
|
||||
type: roomType,
|
||||
name: roomName,
|
||||
members: inviteeUsername ? [inviteeUsername, inviterUsername] : [inviterUsername],
|
||||
options: {
|
||||
forceNew: true, // an invite means the room does not exist yet
|
||||
creator: inviterUserId,
|
||||
},
|
||||
extraData: {
|
||||
federated: true,
|
||||
federation: {
|
||||
version: 1,
|
||||
mrid: matrixRoomId,
|
||||
origin,
|
||||
},
|
||||
...(roomType !== 'd' && { fname: roomFName }), // DMs do not have a fname
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error getting or creating federated room', err, roomName });
|
||||
throw new Error(`Error getting or creating federated room ${roomName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// get the join rule type from the stripped state stored in the unsigned section of the event
|
||||
// as per the spec, we must support several types but we only support invite and public for now.
|
||||
// in the future, we must start looking into 'knock', 'knock_restricted', 'restricted' and 'private'.
|
||||
function getJoinRuleType(strippedState: PduForType<'m.room.join_rules'>[]): 'p' | 'c' | 'd' {
|
||||
const joinRulesState = strippedState?.find((state: PduForType<'m.room.join_rules'>) => state.type === 'm.room.join_rules');
|
||||
|
||||
// as per the spec, users need to be invited to join a room, unless the room’s join rules state otherwise.
|
||||
if (!joinRulesState) {
|
||||
return 'p';
|
||||
}
|
||||
|
||||
const joinRule = joinRulesState?.content?.join_rule;
|
||||
switch (joinRule) {
|
||||
case 'invite':
|
||||
return 'p';
|
||||
case 'public':
|
||||
return 'c';
|
||||
case 'knock':
|
||||
throw new Error(`Knock join rule is not supported`);
|
||||
case 'knock_restricted':
|
||||
throw new Error(`Knock restricted join rule is not supported`);
|
||||
case 'restricted':
|
||||
throw new Error(`Restricted join rule is not supported`);
|
||||
case 'private':
|
||||
throw new Error(`Private join rule is not supported`);
|
||||
default:
|
||||
throw new Error(`Unknown join rule type: ${joinRule}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInvite({
|
||||
sender: senderId,
|
||||
state_key: userId,
|
||||
room_id: roomId,
|
||||
content,
|
||||
unsigned,
|
||||
}: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise<void> {
|
||||
const inviterUser = await getOrCreateFederatedUser(senderId);
|
||||
const inviteeUser = await getOrCreateFederatedUser(userId);
|
||||
|
||||
const strippedState = unsigned.invite_room_state;
|
||||
|
||||
const joinRuleType = getJoinRuleType(strippedState);
|
||||
|
||||
const roomOriginDomain = senderId.split(':')?.pop();
|
||||
if (!roomOriginDomain) {
|
||||
throw new Error(`Room origin domain not found: ${roomId}`);
|
||||
}
|
||||
|
||||
const roomNameState = strippedState?.find((state: PduForType<'m.room.name'>) => state.type === 'm.room.name');
|
||||
const matrixRoomName = roomNameState?.content?.name;
|
||||
|
||||
// DMs do not have a join rule type (they are treated as invite only rooms),
|
||||
// so we use 'd' for direct messages translation to RC.
|
||||
const roomType = content?.is_direct || !matrixRoomName ? 'd' : joinRuleType;
|
||||
|
||||
let roomName: string;
|
||||
let roomFName: string;
|
||||
|
||||
if (roomType === 'd') {
|
||||
roomName = senderId;
|
||||
roomFName = senderId;
|
||||
} else {
|
||||
roomName = getFederatedRoomName(roomId);
|
||||
roomFName = `${matrixRoomName}:${roomOriginDomain}`;
|
||||
}
|
||||
|
||||
const room = await getOrCreateFederatedRoom({
|
||||
matrixRoomId: roomId,
|
||||
roomName,
|
||||
roomFName,
|
||||
roomType,
|
||||
inviterUserId: inviterUser._id,
|
||||
inviterUsername: inviterUser.username,
|
||||
inviteeUsername: roomType === 'd' ? inviteeUser.username : undefined,
|
||||
});
|
||||
|
||||
if (!room) {
|
||||
throw new Error(`Room not found or could not be created: ${roomId}`);
|
||||
}
|
||||
|
||||
const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, inviteeUser._id);
|
||||
if (subscription) {
|
||||
// if subscription state says the user is banned, it means the user was previously banned and is now being re-invited,
|
||||
// so we need to unban the user instead of creating a new invite
|
||||
if (isBannedSubscription(subscription)) {
|
||||
await Room.unbanAndInviteUser(subscription, inviteeUser, inviterUser);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await Room.createUserSubscription({
|
||||
ts: new Date(),
|
||||
room,
|
||||
userToBeAdded: inviteeUser,
|
||||
inviter: inviterUser,
|
||||
status: 'INVITED',
|
||||
});
|
||||
|
||||
// if an invite is sent to a DM, we need to update the room name to reflect all participants
|
||||
if (room.t === 'd') {
|
||||
await Room.updateDirectMessageRoomName(room);
|
||||
}
|
||||
}
|
||||
|
||||
const getUpdateUserNameDebounced = mem((userId: string) => debounce((name: string) => Users.setName(userId, name), 1000));
|
||||
|
||||
function updateUserNameDebounced(userId: string, newName: string): void {
|
||||
void getUpdateUserNameDebounced(userId)(newName);
|
||||
}
|
||||
|
||||
const getDownloadAndSetAvatarDebounced = mem((_userId: string) =>
|
||||
debounce((user: IUser, avatarUrl: string | null) => downloadAndSetAvatar(user, avatarUrl), 2000),
|
||||
);
|
||||
|
||||
function downloadAndSetAvatarDebounced(userId: string, user: IUser, newAvatarUrl: string | null): void {
|
||||
void getDownloadAndSetAvatarDebounced(userId)(user, newAvatarUrl);
|
||||
}
|
||||
|
||||
async function handleJoin({
|
||||
room_id: roomId,
|
||||
state_key: userId,
|
||||
content,
|
||||
}: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise<void> {
|
||||
const joiningUser = await getOrCreateFederatedUser(userId);
|
||||
|
||||
const room = await Rooms.findOneFederatedByMrid(roomId);
|
||||
if (!room) {
|
||||
throw new Error(`Room not found while joining user ${userId} to room ${roomId}`);
|
||||
}
|
||||
|
||||
// if we receive a join event but still don't have a subscription for the user,
|
||||
// it means the join event was sent before the invite event, so we need to create the subscription and then accept the invite.
|
||||
// this will happen when for example the user is unbanned, so the leave event will remove the subscription and then we just
|
||||
// receive the join event without receiving the invite.
|
||||
let subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, joiningUser._id);
|
||||
if (!subscription) {
|
||||
const subId = await Room.createUserSubscription({
|
||||
ts: new Date(),
|
||||
room,
|
||||
userToBeAdded: joiningUser,
|
||||
});
|
||||
subscription = subId ? await Subscriptions.findOneById(subId) : null;
|
||||
}
|
||||
if (!subscription) {
|
||||
throw new Error(`Subscription not found while joining user ${userId} to room ${roomId}`);
|
||||
}
|
||||
|
||||
const senderServerName = extractDomainFromMatrixUserId(userId);
|
||||
|
||||
// handle avatar updates to membership events
|
||||
if (senderServerName !== federationSDK.getConfig('serverName')) {
|
||||
// TODO if there is no avatar_url we may want to validate first if we should remove the user avatar because if may be dealing with an old join event, and the user may have changed their avatar since then, so we need to check if the avatar_url is different from the current one before removing it
|
||||
void downloadAndSetAvatarDebounced(joiningUser._id, joiningUser, content.avatar_url || null);
|
||||
}
|
||||
|
||||
// updates user name whenever we receive a join event, because Matrix sends a new join event with the updated display name whenever a user changes their display name
|
||||
if ('displayname' in content && content.displayname !== joiningUser.name) {
|
||||
// whan a user changes the it's display name we receive a new join event for every room the user is in
|
||||
// so we need to debounce the name update to avoid updating the name multiple times in a row
|
||||
void updateUserNameDebounced(joiningUser._id, content.displayname || '');
|
||||
}
|
||||
|
||||
if (room.t === 'd') {
|
||||
await Room.updateDirectMessageRoomName(
|
||||
room,
|
||||
[subscription._id],
|
||||
[{ _id: joiningUser._id, name: content.displayname || joiningUser.name, username: joiningUser.username }],
|
||||
);
|
||||
}
|
||||
|
||||
if (!subscription.status) {
|
||||
logger.info('User is already joined to the room, skipping...');
|
||||
return;
|
||||
}
|
||||
|
||||
await Room.performAcceptRoomInvite(room, subscription, joiningUser);
|
||||
}
|
||||
|
||||
async function handleLeave({
|
||||
room_id: roomId,
|
||||
state_key: userId,
|
||||
sender,
|
||||
unsigned: { prev_content: prevContent },
|
||||
}: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise<void> {
|
||||
const serverName = federationSDK.getConfig('serverName');
|
||||
const [username] = getUsernameServername(userId, serverName);
|
||||
|
||||
const leavingUser = await Users.findOneByUsername(username);
|
||||
if (!leavingUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [senderUsername] = getUsernameServername(sender, serverName);
|
||||
|
||||
const senderUser = await Users.findOneByUsername(senderUsername);
|
||||
if (!senderUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const room = await Rooms.findOneFederatedByMrid(roomId);
|
||||
if (!room) {
|
||||
throw new Error(`Room not found while leaving user ${userId} from room ${roomId}`);
|
||||
}
|
||||
|
||||
// In Matrix, unban is a leave event for a previously banned user.
|
||||
// Check local subscription state to distinguish leave from unban.
|
||||
const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, leavingUser._id);
|
||||
if (subscription && isBannedSubscription(subscription)) {
|
||||
await Room.performUserUnban(room, leavingUser, senderUser);
|
||||
logger.info({ msg: 'Unbanned user via federation leave event', userId: leavingUser._id, roomId: room._id });
|
||||
return;
|
||||
}
|
||||
|
||||
// this means the leave event is actually an unban
|
||||
if (prevContent?.membership === 'ban') {
|
||||
await Room.performUserUnban(room, leavingUser, senderUser);
|
||||
} else {
|
||||
await Room.performUserRemoval(room, leavingUser);
|
||||
}
|
||||
|
||||
// update room name for DMs
|
||||
if (room.t === 'd') {
|
||||
await Room.updateDirectMessageRoomName(room);
|
||||
}
|
||||
|
||||
// TODO check if there are no pending invites to the room, and if so, delete the room
|
||||
}
|
||||
|
||||
async function handleBan({
|
||||
room_id: roomId,
|
||||
state_key: userId,
|
||||
sender: senderId,
|
||||
}: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise<void> {
|
||||
const serverName = federationSDK.getConfig('serverName');
|
||||
const [username] = getUsernameServername(userId, serverName);
|
||||
|
||||
const bannedUser = await Users.findOneByUsername(username);
|
||||
if (!bannedUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const room = await Rooms.findOneFederatedByMrid(roomId);
|
||||
if (!room) {
|
||||
throw new Error(`Room not found while banning user ${userId} from room ${roomId}`);
|
||||
}
|
||||
|
||||
const [senderUsername] = getUsernameServername(senderId, serverName);
|
||||
const senderUser = await Users.findOneByUsername(senderUsername);
|
||||
if (!senderUser) {
|
||||
throw new Error(`Ban sender not found locally: ${senderUsername} (Matrix id ${senderId})`);
|
||||
}
|
||||
|
||||
await Room.performUserBan(room, bannedUser, senderUser);
|
||||
}
|
||||
|
||||
async function handleMembershipRejected({
|
||||
event,
|
||||
reason,
|
||||
}: HomeserverEventSignatures['homeserver.matrix.membership.rejected']): Promise<void> {
|
||||
const room = await Rooms.findOne({ 'federation.mrid': event.room_id });
|
||||
if (!room) {
|
||||
logger.debug({ msg: 'No local room found for rejected membership event', roomId: event.room_id, reason });
|
||||
return;
|
||||
}
|
||||
|
||||
const serverName = federationSDK.getConfig('serverName');
|
||||
const [username] = getUsernameServername(event.state_key, serverName);
|
||||
|
||||
const user = await Users.findOneByUsername(username);
|
||||
if (!user) {
|
||||
logger.debug({ msg: 'User not found for rejected membership event', userId: event.state_key, reason });
|
||||
return;
|
||||
}
|
||||
|
||||
await Room.revokeInvite(room, user);
|
||||
|
||||
logger.info({
|
||||
msg: 'Revoked invite due to rejected membership event',
|
||||
userId: user._id,
|
||||
roomId: room._id,
|
||||
membership: event.content.membership,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
export function member() {
|
||||
federationSDK.eventEmitterService.on('homeserver.matrix.membership', async ({ event }) => {
|
||||
try {
|
||||
switch (event.content.membership) {
|
||||
case 'invite':
|
||||
await handleInvite(event);
|
||||
break;
|
||||
|
||||
case 'join':
|
||||
await handleJoin(event);
|
||||
break;
|
||||
|
||||
case 'leave':
|
||||
await handleLeave(event);
|
||||
break;
|
||||
|
||||
case 'ban':
|
||||
await handleBan(event);
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.warn({ msg: 'Unknown membership type', membership: event.content.membership });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Failed to process Matrix membership event', err });
|
||||
}
|
||||
});
|
||||
|
||||
federationSDK.eventEmitterService.on('homeserver.matrix.membership.rejected', async (payload) => {
|
||||
try {
|
||||
await handleMembershipRejected(payload);
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Failed to process rejected membership event', err });
|
||||
}
|
||||
});
|
||||
}
|
||||
166
ee/packages/federation-matrix/src/events/message.ts
Normal file
166
ee/packages/federation-matrix/src/events/message.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { FederationMatrix, Message } from '@rocket.chat/core-services';
|
||||
import { federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
import { Users, Rooms, Messages } from '@rocket.chat/models';
|
||||
|
||||
import { getThreadMessageId } from '../helpers/getThreadMessageId';
|
||||
|
||||
const logger = new Logger('federation-matrix:message');
|
||||
|
||||
export function message() {
|
||||
federationSDK.eventEmitterService.on('homeserver.matrix.message', async (event) => {
|
||||
try {
|
||||
await FederationMatrix.saveFederationMessage(event);
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error processing Matrix message', err });
|
||||
}
|
||||
});
|
||||
|
||||
federationSDK.eventEmitterService.on('homeserver.matrix.encrypted', async ({ event, event_id: eventId }) => {
|
||||
try {
|
||||
if (!event.content.ciphertext) {
|
||||
logger.debug('No message content found in event');
|
||||
return;
|
||||
}
|
||||
|
||||
// at this point we know for sure the user already exists
|
||||
const user = await Users.findOneByUsername(event.sender);
|
||||
if (!user) {
|
||||
throw new Error(`User not found for sender: ${event.sender}`);
|
||||
}
|
||||
|
||||
const room = await Rooms.findOne({ 'federation.mrid': event.room_id });
|
||||
if (!room) {
|
||||
throw new Error(`No mapped room found for room_id: ${event.room_id}`);
|
||||
}
|
||||
|
||||
const relation = event.content['m.relates_to'];
|
||||
|
||||
// SPEC: For example, an m.thread relationship type denotes that the event is part of a “thread” of messages and should be rendered as such.
|
||||
const hasRelation = relation && 'rel_type' in relation;
|
||||
|
||||
const isThreadMessage = hasRelation && relation.rel_type === 'm.thread';
|
||||
|
||||
const threadRootEventId = isThreadMessage && relation.event_id;
|
||||
|
||||
// SPEC: Though rich replies form a relationship to another event, they do not use rel_type to create this relationship.
|
||||
// Instead, a subkey named m.in_reply_to is used to describe the reply’s relationship,
|
||||
const isRichReply = relation && !('rel_type' in relation) && 'm.in_reply_to' in relation;
|
||||
|
||||
const quoteMessageEventId = isRichReply && relation['m.in_reply_to']?.event_id;
|
||||
|
||||
const thread = threadRootEventId ? await getThreadMessageId(threadRootEventId) : undefined;
|
||||
|
||||
const isEditedMessage = hasRelation && relation.rel_type === 'm.replace';
|
||||
if (isEditedMessage && relation.event_id) {
|
||||
logger.debug('Received edited message from Matrix, updating existing message');
|
||||
const originalMessage = await Messages.findOneByFederationId(relation.event_id);
|
||||
if (!originalMessage) {
|
||||
logger.error({ event_id: relation.event_id, msg: 'Original message not found for edit' });
|
||||
return;
|
||||
}
|
||||
if (originalMessage.federation?.eventId !== relation.event_id) {
|
||||
return;
|
||||
}
|
||||
if (originalMessage.content?.ciphertext === event.content.ciphertext) {
|
||||
logger.debug('No changes in message content, skipping update');
|
||||
return;
|
||||
}
|
||||
|
||||
if (quoteMessageEventId) {
|
||||
await Message.updateMessage(
|
||||
{
|
||||
...originalMessage,
|
||||
content: {
|
||||
algorithm: event.content.algorithm,
|
||||
ciphertext: event.content.ciphertext,
|
||||
},
|
||||
},
|
||||
user,
|
||||
originalMessage,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await Message.updateMessage(
|
||||
{
|
||||
...originalMessage,
|
||||
content: {
|
||||
algorithm: event.content.algorithm,
|
||||
ciphertext: event.content.ciphertext,
|
||||
},
|
||||
},
|
||||
user,
|
||||
originalMessage,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (quoteMessageEventId) {
|
||||
const originalMessage = await Messages.findOneByFederationId(quoteMessageEventId);
|
||||
if (!originalMessage) {
|
||||
logger.error({ quoteMessageEventId, msg: 'Original message not found for quote' });
|
||||
return;
|
||||
}
|
||||
await Message.saveMessageFromFederation({
|
||||
fromId: user._id,
|
||||
rid: room._id,
|
||||
e2e_content: {
|
||||
algorithm: event.content.algorithm,
|
||||
ciphertext: event.content.ciphertext,
|
||||
},
|
||||
federation_event_id: eventId,
|
||||
thread,
|
||||
ts: new Date(event.origin_server_ts),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await Message.saveMessageFromFederation({
|
||||
fromId: user._id,
|
||||
rid: room._id,
|
||||
e2e_content: {
|
||||
algorithm: event.content.algorithm,
|
||||
ciphertext: event.content.ciphertext,
|
||||
},
|
||||
federation_event_id: eventId,
|
||||
thread,
|
||||
ts: new Date(event.origin_server_ts),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error processing Matrix message', err });
|
||||
}
|
||||
});
|
||||
|
||||
federationSDK.eventEmitterService.on('homeserver.matrix.redaction', async ({ event }) => {
|
||||
try {
|
||||
const redactedEventId = event.redacts;
|
||||
if (!redactedEventId) {
|
||||
logger.debug('No redacts field in redaction event');
|
||||
return;
|
||||
}
|
||||
|
||||
const messageEvent = await FederationMatrix.getEventById(redactedEventId);
|
||||
if (messageEvent?.event.type !== 'm.room.message') {
|
||||
logger.debug({ msg: 'Event is not a message event', eventId: redactedEventId });
|
||||
return;
|
||||
}
|
||||
|
||||
const rcMessage = await Messages.findOneByFederationId(event.redacts);
|
||||
if (!rcMessage) {
|
||||
logger.debug({ msg: 'No RC message found for event', eventId: event.redacts });
|
||||
return;
|
||||
}
|
||||
const internalUsername = event.sender;
|
||||
const user = await Users.findOneByUsername(internalUsername);
|
||||
if (!user) {
|
||||
logger.debug({ msg: 'User not found', username: internalUsername });
|
||||
return;
|
||||
}
|
||||
|
||||
await Message.deleteMessage(user, rcMessage);
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Failed to process Matrix removal redaction', err });
|
||||
}
|
||||
});
|
||||
}
|
||||
7
ee/packages/federation-matrix/src/events/ping.ts
Normal file
7
ee/packages/federation-matrix/src/events/ping.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { federationSDK } from '@rocket.chat/federation-sdk';
|
||||
|
||||
export const ping = async () => {
|
||||
federationSDK.eventEmitterService.on('homeserver.ping', async (data) => {
|
||||
console.log('Message received from homeserver', data);
|
||||
});
|
||||
};
|
||||
103
ee/packages/federation-matrix/src/events/reaction.ts
Normal file
103
ee/packages/federation-matrix/src/events/reaction.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Message, FederationMatrix } from '@rocket.chat/core-services';
|
||||
import { federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
import { Users, Messages } from '@rocket.chat/models'; // Rooms
|
||||
|
||||
import { unicodeToShortname } from '../utils/emojiConverter';
|
||||
|
||||
const logger = new Logger('federation-matrix:reaction');
|
||||
|
||||
export function reaction() {
|
||||
federationSDK.eventEmitterService.on('homeserver.matrix.reaction', async ({ event, event_id: eventId }) => {
|
||||
try {
|
||||
const isSetReaction = event.content?.['m.relates_to'];
|
||||
|
||||
const reactionTargetEventId = isSetReaction?.event_id;
|
||||
const reactionKey = isSetReaction?.key;
|
||||
|
||||
if (typeof reactionKey !== 'string' || reactionKey.length === 0) {
|
||||
logger.error({ reactionTargetEventId, reactionKey, msg: 'Invalid or missing reaction key in Matrix reaction event' });
|
||||
return;
|
||||
}
|
||||
|
||||
const [userPart, domain] = event.sender.split(':');
|
||||
if (!userPart || !domain) {
|
||||
logger.error({ sender: event.sender, msg: 'Invalid Matrix sender ID format' });
|
||||
return;
|
||||
}
|
||||
|
||||
const internalUsername = event.sender;
|
||||
const user = await Users.findOneByUsername(internalUsername);
|
||||
if (!user) {
|
||||
logger.error({ reactionTargetEventId, internalUsername, msg: 'No RC user mapping found for Matrix event' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSetReaction) {
|
||||
logger.debug('No relates_to content in reaction event');
|
||||
return;
|
||||
}
|
||||
|
||||
const rcMessage = await Messages.findOneByFederationId(reactionTargetEventId);
|
||||
if (!rcMessage) {
|
||||
logger.debug({ msg: 'No RC message mapping found for Matrix event', eventId: reactionTargetEventId });
|
||||
return;
|
||||
}
|
||||
|
||||
const reactionEmoji = unicodeToShortname(reactionKey);
|
||||
await Message.reactToMessage(user._id, reactionEmoji, rcMessage._id, true);
|
||||
await Messages.setFederationReactionEventId(internalUsername, rcMessage._id, reactionEmoji, eventId);
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Failed to process Matrix reaction', err });
|
||||
}
|
||||
});
|
||||
|
||||
federationSDK.eventEmitterService.on('homeserver.matrix.redaction', async ({ event }) => {
|
||||
try {
|
||||
const redactedEventId = event.redacts;
|
||||
if (!redactedEventId) {
|
||||
logger.debug('No redacts field in redaction event');
|
||||
return;
|
||||
}
|
||||
|
||||
const reactionEvent = await FederationMatrix.getEventById(redactedEventId);
|
||||
if (!reactionEvent || reactionEvent.event.type !== 'm.reaction') {
|
||||
logger.debug({ msg: 'Event is not a reaction event', eventId: redactedEventId });
|
||||
return;
|
||||
}
|
||||
|
||||
const reactionContent = reactionEvent.event.content?.['m.relates_to'];
|
||||
if (!reactionContent) {
|
||||
logger.debug('No relates_to content in reaction event');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetMessageEventId = reactionContent.event_id;
|
||||
const reactionKey = reactionContent.key;
|
||||
|
||||
if (typeof reactionKey !== 'string' || reactionKey.length === 0) {
|
||||
logger.error({ redactedEventId, reactionKey, msg: 'Invalid or missing reaction key in redacted reaction event' });
|
||||
return;
|
||||
}
|
||||
|
||||
const rcMessage = await Messages.findOneByFederationId(targetMessageEventId);
|
||||
if (!rcMessage) {
|
||||
logger.debug({ msg: 'No RC message found for event', eventId: targetMessageEventId });
|
||||
return;
|
||||
}
|
||||
|
||||
const internalUsername = event.sender;
|
||||
const user = await Users.findOneByUsername(internalUsername);
|
||||
if (!user) {
|
||||
logger.debug({ msg: 'User not found', username: internalUsername });
|
||||
return;
|
||||
}
|
||||
|
||||
const reactionEmoji = unicodeToShortname(reactionKey);
|
||||
await Message.reactToMessage(user._id, reactionEmoji, rcMessage._id, false);
|
||||
await Messages.unsetFederationReactionEventId(redactedEventId, rcMessage._id, reactionEmoji);
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Failed to process Matrix reaction redaction', err });
|
||||
}
|
||||
});
|
||||
}
|
||||
87
ee/packages/federation-matrix/src/events/room.ts
Normal file
87
ee/packages/federation-matrix/src/events/room.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Room } from '@rocket.chat/core-services';
|
||||
import { federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { Rooms, Users } from '@rocket.chat/models';
|
||||
|
||||
import { getUsernameServername } from '../helpers/getUsernameServername';
|
||||
|
||||
export function room() {
|
||||
federationSDK.eventEmitterService.on('homeserver.matrix.room.name', async ({ event }) => {
|
||||
const {
|
||||
room_id: roomId,
|
||||
content: { name },
|
||||
sender: userId,
|
||||
} = event;
|
||||
|
||||
const localRoomId = await Rooms.findOne({ 'federation.mrid': roomId }, { projection: { _id: 1 } });
|
||||
if (!localRoomId) {
|
||||
throw new Error(`mapped room not found: ${roomId}`);
|
||||
}
|
||||
|
||||
const localUserId = await Users.findOneByUsername(userId, { projection: { _id: 1 } });
|
||||
if (!localUserId) {
|
||||
throw new Error(`mapped user not found: ${userId}`);
|
||||
}
|
||||
|
||||
await Room.saveRoomName(localRoomId._id, localUserId._id, name);
|
||||
});
|
||||
|
||||
federationSDK.eventEmitterService.on('homeserver.matrix.room.topic', async ({ event }) => {
|
||||
const {
|
||||
room_id: roomId,
|
||||
content: { topic },
|
||||
sender: userId,
|
||||
} = event;
|
||||
|
||||
const localRoomId = await Rooms.findOne({ 'federation.mrid': roomId }, { projection: { _id: 1 } });
|
||||
if (!localRoomId) {
|
||||
throw new Error('mapped room not found');
|
||||
}
|
||||
|
||||
const localUser = await Users.findOneByUsername(userId, { projection: { _id: 1, federation: 1, federated: 1 } });
|
||||
if (!localUser) {
|
||||
throw new Error('mapped user not found');
|
||||
}
|
||||
|
||||
await Room.saveRoomTopic(localRoomId._id, topic, {
|
||||
_id: localUser._id,
|
||||
username: userId,
|
||||
federation: localUser.federation,
|
||||
federated: localUser.federated,
|
||||
});
|
||||
});
|
||||
|
||||
federationSDK.eventEmitterService.on('homeserver.matrix.room.role', async (data) => {
|
||||
const { room_id: roomId, user_id: userId, sender_id: senderId, role } = data;
|
||||
|
||||
const localRoomId = await Rooms.findOne({ 'federation.mrid': roomId }, { projection: { _id: 1 } });
|
||||
if (!localRoomId) {
|
||||
throw new Error('mapped room not found');
|
||||
}
|
||||
|
||||
const serverName = federationSDK.getConfig('serverName');
|
||||
|
||||
const [allegedUsernameLocal, , allegedUserLocalIsLocal] = getUsernameServername(userId, serverName);
|
||||
const localUserId = allegedUserLocalIsLocal && (await Users.findOneByUsername(allegedUsernameLocal, { projection: { _id: 1 } }));
|
||||
|
||||
if (!allegedUserLocalIsLocal) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!localUserId) {
|
||||
throw new Error('mapped user not found');
|
||||
}
|
||||
|
||||
const [senderUsername, , senderIsLocal] = getUsernameServername(senderId, serverName);
|
||||
|
||||
if (senderIsLocal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const localSenderId = await Users.findOneByUsername(senderUsername, { projection: { _id: 1 } });
|
||||
if (!localSenderId) {
|
||||
throw new Error('mapped user not found');
|
||||
}
|
||||
|
||||
await Room.addUserRoleRoomScoped(localSenderId._id, localUserId._id, localRoomId._id, role);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { UserStatus } from '@rocket.chat/core-typings';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
|
||||
import { createOrUpdateFederatedUser } from './createOrUpdateFederatedUser';
|
||||
|
||||
jest.mock('@rocket.chat/models', () => ({
|
||||
Users: {
|
||||
findOneAndUpdate: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockFindOneAndUpdate = Users.findOneAndUpdate as jest.MockedFunction<typeof Users.findOneAndUpdate>;
|
||||
|
||||
const fakeUser = {
|
||||
_id: 'user123',
|
||||
username: '@alice:example.com',
|
||||
name: '@alice:example.com',
|
||||
federated: true,
|
||||
federation: { version: 1, mui: '@alice:example.com', origin: 'example.com' },
|
||||
};
|
||||
|
||||
describe('createOrUpdateFederatedUser', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should assign the "federated" role to the new user', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any);
|
||||
|
||||
await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' });
|
||||
|
||||
expect(mockFindOneAndUpdate).toHaveBeenCalledTimes(1);
|
||||
const [, updateDoc] = mockFindOneAndUpdate.mock.calls[0];
|
||||
expect((updateDoc as any).$set.roles).toEqual(['federated-external']);
|
||||
});
|
||||
|
||||
it('should not assign the "user" role to federated users', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any);
|
||||
|
||||
await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' });
|
||||
|
||||
const [, updateDoc] = mockFindOneAndUpdate.mock.calls[0];
|
||||
expect((updateDoc as any).$set.roles).not.toContain('user');
|
||||
});
|
||||
|
||||
it('should set federated=true on the created/updated user', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any);
|
||||
|
||||
await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' });
|
||||
|
||||
const [, updateDoc] = mockFindOneAndUpdate.mock.calls[0];
|
||||
expect((updateDoc as any).$set.federated).toBe(true);
|
||||
});
|
||||
|
||||
it('should use the provided name when supplied', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any);
|
||||
|
||||
await createOrUpdateFederatedUser({ username: '@alice:example.com', name: 'Alice', origin: 'example.com' });
|
||||
|
||||
const [, updateDoc] = mockFindOneAndUpdate.mock.calls[0];
|
||||
expect((updateDoc as any).$set.name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should default name to username when name is not provided', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any);
|
||||
|
||||
await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' });
|
||||
|
||||
const [, updateDoc] = mockFindOneAndUpdate.mock.calls[0];
|
||||
expect((updateDoc as any).$set.name).toBe('@alice:example.com');
|
||||
});
|
||||
|
||||
it('should set initial status to OFFLINE', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any);
|
||||
|
||||
await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' });
|
||||
|
||||
const [, updateDoc] = mockFindOneAndUpdate.mock.calls[0];
|
||||
expect((updateDoc as any).$set.status).toBe(UserStatus.OFFLINE);
|
||||
});
|
||||
|
||||
it('should store the origin server in the federation object', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any);
|
||||
|
||||
await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' });
|
||||
|
||||
const [, updateDoc] = mockFindOneAndUpdate.mock.calls[0];
|
||||
expect((updateDoc as any).$set.federation.origin).toBe('example.com');
|
||||
});
|
||||
|
||||
it('should use upsert so the user is created if not found', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any);
|
||||
|
||||
await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' });
|
||||
|
||||
const [, , options] = mockFindOneAndUpdate.mock.calls[0];
|
||||
expect((options as any).upsert).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw when findOneAndUpdate returns null', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValueOnce(null as any);
|
||||
|
||||
await expect(createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' })).rejects.toThrow(
|
||||
'Failed to create or update federated user: @alice:example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when the returned document is not a native federated user', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValueOnce({ _id: 'user123', username: '@alice:example.com', name: 'Alice' } as any);
|
||||
|
||||
await expect(createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' })).rejects.toThrow(
|
||||
'Failed to create or update federated user: @alice:example.com',
|
||||
);
|
||||
});
|
||||
|
||||
// callers subscribe the returned document to a room, so a projection here silently hands them a
|
||||
// user missing every field but the two projected ones
|
||||
it('should not project fields away, so the full user document is returned', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any);
|
||||
|
||||
await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' });
|
||||
|
||||
const [, , options] = mockFindOneAndUpdate.mock.calls[0];
|
||||
expect(options).not.toHaveProperty('projection');
|
||||
});
|
||||
|
||||
// the pre-image of an upsert that inserted is null, which would fail every first contact
|
||||
it('should ask for the document as it looks after the upsert', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any);
|
||||
|
||||
await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' });
|
||||
|
||||
const [, , options] = mockFindOneAndUpdate.mock.calls[0];
|
||||
expect((options as any).returnDocument).toBe('after');
|
||||
});
|
||||
|
||||
it('should return the user returned by findOneAndUpdate', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any);
|
||||
|
||||
const result = await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' });
|
||||
|
||||
expect(result).toBe(fakeUser);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
type IRegisterUser,
|
||||
type IUserNativeFederated,
|
||||
isRegisterUser,
|
||||
isUserNativeFederated,
|
||||
UserStatus,
|
||||
} from '@rocket.chat/core-typings';
|
||||
import type { UserID } from '@rocket.chat/federation-sdk';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
|
||||
/**
|
||||
* Helper function to create a federated user
|
||||
*
|
||||
* Because of historical reasons, we can have users only with federated flag but no federation object
|
||||
* So we need to upsert the user with the federation object
|
||||
*/
|
||||
|
||||
export async function createOrUpdateFederatedUser(options: {
|
||||
username: UserID;
|
||||
name?: string;
|
||||
origin: string;
|
||||
asId?: string;
|
||||
}): Promise<IUserNativeFederated & IRegisterUser> {
|
||||
const { username, name = username, origin, asId } = options;
|
||||
|
||||
// TODO: Have a specific method to handle this upsert
|
||||
const user = await Users.findOneAndUpdate(
|
||||
{
|
||||
username,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
username,
|
||||
name: name || username,
|
||||
type: 'user' as const,
|
||||
status: UserStatus.OFFLINE,
|
||||
active: true,
|
||||
roles: ['federated-external'],
|
||||
requirePasswordChange: false,
|
||||
federated: true,
|
||||
federation: {
|
||||
version: 1,
|
||||
mui: username,
|
||||
origin,
|
||||
...(asId && { asId }),
|
||||
},
|
||||
_updatedAt: new Date(),
|
||||
},
|
||||
$setOnInsert: {
|
||||
createdAt: new Date(),
|
||||
},
|
||||
},
|
||||
{
|
||||
upsert: true,
|
||||
returnDocument: 'after',
|
||||
},
|
||||
);
|
||||
|
||||
// the upsert above writes every field these guards check, so they should never reject a returned document
|
||||
if (!user || !isUserNativeFederated(user) || !isRegisterUser(user)) {
|
||||
throw new Error(`Failed to create or update federated user: ${username}`);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const extractDomainFromMatrixUserId = (mxid: string): string => {
|
||||
const separatorIndex = mxid.indexOf(':', 1);
|
||||
if (separatorIndex === -1) {
|
||||
throw new Error(`Invalid federated username: ${mxid}`);
|
||||
}
|
||||
return mxid.substring(separatorIndex + 1);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { getFederatedRoomName } from './getFederatedRoomName';
|
||||
|
||||
describe('getFederatedRoomName', () => {
|
||||
it('should strip the leading `!` sigil and replace the `:` separator with `_`', () => {
|
||||
expect(getFederatedRoomName('!abcdef:matrix.org')).toBe('abcdef_matrix.org');
|
||||
});
|
||||
|
||||
it('should produce a slug-valid name (only [0-9a-zA-Z-_.])', () => {
|
||||
expect(getFederatedRoomName('!abcdef:matrix.org')).toMatch(/^[0-9a-zA-Z-_.]+$/);
|
||||
});
|
||||
|
||||
it('should handle server names with a port (multiple `:`)', () => {
|
||||
expect(getFederatedRoomName('!abcdef:matrix.org:8448')).toBe('abcdef_matrix.org_8448');
|
||||
});
|
||||
|
||||
it('should replace slug-invalid characters in the opaque id', () => {
|
||||
expect(getFederatedRoomName('!ab+cd/ef=:matrix.org')).toBe('ab_cd_ef__matrix.org');
|
||||
expect(getFederatedRoomName('!ab+cd/ef=:matrix.org')).toMatch(/^[0-9a-zA-Z-_.]+$/);
|
||||
});
|
||||
|
||||
it('should only strip a `!` at the start, not elsewhere', () => {
|
||||
expect(getFederatedRoomName('!ab!cd:matrix.org')).toBe('ab_cd_matrix.org');
|
||||
});
|
||||
|
||||
it('should be deterministic for the same room id', () => {
|
||||
expect(getFederatedRoomName('!room:server.com')).toBe(getFederatedRoomName('!room:server.com'));
|
||||
});
|
||||
|
||||
it('should derive distinct names for distinct room ids', () => {
|
||||
expect(getFederatedRoomName('!a:server.com')).not.toBe(getFederatedRoomName('!b:server.com'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
// Derives a valid Rocket.Chat room name (slug) from a Matrix room id.
|
||||
// Matrix room ids look like `!opaqueId:server.domain`; we strip the leading `!`
|
||||
// sigil and replace every char RC rejects in a slug (including the `:` separator,
|
||||
// which may also appear in a server port) with `_`, producing a deterministic,
|
||||
// slug-valid name. Matrix rooms may have no name (or a name with characters RC
|
||||
// rejects), so the room id is the only always-present, addressable identifier.
|
||||
export const getFederatedRoomName = (matrixRoomId: string): string => matrixRoomId.replace(/^!/, '').replace(/[^0-9a-zA-Z-_.]/g, '_');
|
||||
@@ -0,0 +1,179 @@
|
||||
import { federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
|
||||
import { createOrUpdateFederatedUser } from './createOrUpdateFederatedUser';
|
||||
import { getOrCreateFederatedUser } from './getOrCreateFederatedUser';
|
||||
|
||||
jest.mock('@rocket.chat/models', () => ({
|
||||
Users: {
|
||||
findOneByUsername: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@rocket.chat/federation-sdk', () => ({
|
||||
federationSDK: {
|
||||
getConfig: jest.fn(),
|
||||
getAppServiceForUser: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@rocket.chat/logger', () => ({
|
||||
Logger: jest.fn().mockImplementation(() => ({
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
info: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('./createOrUpdateFederatedUser', () => ({
|
||||
createOrUpdateFederatedUser: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockFindOneByUsername = Users.findOneByUsername as jest.MockedFunction<typeof Users.findOneByUsername>;
|
||||
const mockGetConfig = federationSDK.getConfig as jest.MockedFunction<typeof federationSDK.getConfig>;
|
||||
const mockGetAppServiceForUser = federationSDK.getAppServiceForUser as jest.MockedFunction<typeof federationSDK.getAppServiceForUser>;
|
||||
const mockCreateOrUpdateFederatedUser = createOrUpdateFederatedUser as jest.MockedFunction<typeof createOrUpdateFederatedUser>;
|
||||
|
||||
// what createOrUpdateFederatedUser resolves to on first contact: a complete user document
|
||||
const createdUser = {
|
||||
_id: 'user123',
|
||||
username: '@alice:example.com',
|
||||
name: '@alice:example.com',
|
||||
createdAt: new Date(),
|
||||
_updatedAt: new Date(),
|
||||
type: 'user',
|
||||
active: true,
|
||||
roles: ['federated-external'],
|
||||
federated: true,
|
||||
federation: { version: 1, mui: '@alice:example.com', origin: 'example.com' },
|
||||
};
|
||||
|
||||
describe('getOrCreateFederatedUser', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetConfig.mockReturnValue('local.com' as any);
|
||||
mockGetAppServiceForUser.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
describe('when the user already exists locally (warm path)', () => {
|
||||
it('should return the existing user without creating anything', async () => {
|
||||
mockFindOneByUsername.mockResolvedValueOnce(createdUser as any);
|
||||
|
||||
const result = await getOrCreateFederatedUser('@alice:example.com');
|
||||
|
||||
expect(result).toBe(createdUser);
|
||||
expect(mockCreateOrUpdateFederatedUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should look the remote user up by the full Matrix ID', async () => {
|
||||
mockFindOneByUsername.mockResolvedValueOnce(createdUser as any);
|
||||
|
||||
await getOrCreateFederatedUser('@alice:example.com');
|
||||
|
||||
expect(mockFindOneByUsername).toHaveBeenCalledWith('@alice:example.com');
|
||||
});
|
||||
|
||||
it('should look a local user up by the localpart only', async () => {
|
||||
mockFindOneByUsername.mockResolvedValueOnce({ ...createdUser, username: 'bob', name: 'Bob' } as any);
|
||||
|
||||
await getOrCreateFederatedUser('@bob:local.com');
|
||||
|
||||
expect(mockFindOneByUsername).toHaveBeenCalledWith('bob');
|
||||
});
|
||||
|
||||
// local users are allowed to exist without a display name, so requiring one here would
|
||||
// drop every membership event they take part in
|
||||
it('should return a local user that has no name', async () => {
|
||||
const namelessUser = { _id: 'user123', username: 'bob' };
|
||||
mockFindOneByUsername.mockResolvedValueOnce(namelessUser as any);
|
||||
|
||||
await expect(getOrCreateFederatedUser('@bob:local.com')).resolves.toBe(namelessUser);
|
||||
});
|
||||
|
||||
it('should throw when the existing user has no username', async () => {
|
||||
mockFindOneByUsername.mockResolvedValueOnce({ _id: 'user123', name: 'Alice' } as any);
|
||||
|
||||
await expect(getOrCreateFederatedUser('@alice:example.com')).rejects.toThrow(
|
||||
'Error getting or creating federated user @alice:example.com',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the remote user is unknown locally (cold path)', () => {
|
||||
// the document itself is built by createOrUpdateFederatedUser, so its completeness is covered
|
||||
// in createOrUpdateFederatedUser.spec.ts; all this path can show is that nothing is stripped
|
||||
// off or rebuilt on the way out
|
||||
it('should return the created document untouched', async () => {
|
||||
mockFindOneByUsername.mockResolvedValueOnce(null);
|
||||
mockCreateOrUpdateFederatedUser.mockResolvedValueOnce(createdUser as any);
|
||||
|
||||
const result = await getOrCreateFederatedUser('@alice:example.com');
|
||||
|
||||
expect(result).toBe(createdUser);
|
||||
});
|
||||
|
||||
it('should create the user with the full Matrix ID and the origin server', async () => {
|
||||
mockFindOneByUsername.mockResolvedValueOnce(null);
|
||||
mockCreateOrUpdateFederatedUser.mockResolvedValueOnce(createdUser as any);
|
||||
|
||||
await getOrCreateFederatedUser('@alice:example.com');
|
||||
|
||||
expect(mockCreateOrUpdateFederatedUser).toHaveBeenCalledWith({
|
||||
username: '@alice:example.com',
|
||||
name: '@alice:example.com',
|
||||
origin: 'example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not create anything for a malformed Matrix ID', async () => {
|
||||
mockFindOneByUsername.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(getOrCreateFederatedUser('@not a valid mxid:example.com')).rejects.toThrow(
|
||||
'Error getting or creating federated user @not a valid mxid:example.com',
|
||||
);
|
||||
expect(mockCreateOrUpdateFederatedUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should preserve the underlying failure as the error cause', async () => {
|
||||
const cause = new Error('mongo is down');
|
||||
mockFindOneByUsername.mockResolvedValueOnce(null);
|
||||
mockCreateOrUpdateFederatedUser.mockRejectedValueOnce(cause);
|
||||
|
||||
await expect(getOrCreateFederatedUser('@alice:example.com')).rejects.toThrow(expect.objectContaining({ cause }) as unknown as Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user is local', () => {
|
||||
it('should throw instead of creating a federated user for an unknown local user', async () => {
|
||||
mockFindOneByUsername.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(getOrCreateFederatedUser('@bob:local.com')).rejects.toThrow('Error getting or creating federated user @bob:local.com');
|
||||
expect(mockCreateOrUpdateFederatedUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user belongs to an application service', () => {
|
||||
it('should return the appservice user looked up by full Matrix ID', async () => {
|
||||
const appServiceUser = { ...createdUser, username: '@irc_bob:local.com', name: 'irc_bob' };
|
||||
mockFindOneByUsername.mockResolvedValueOnce(null).mockResolvedValueOnce(appServiceUser as any);
|
||||
mockGetAppServiceForUser.mockReturnValue({ registration: { _id: 'as1' } } as any);
|
||||
|
||||
const result = await getOrCreateFederatedUser('@irc_bob:local.com');
|
||||
|
||||
expect(result).toBe(appServiceUser);
|
||||
expect(mockFindOneByUsername).toHaveBeenLastCalledWith('@irc_bob:local.com');
|
||||
expect(mockCreateOrUpdateFederatedUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw when the appservice user does not exist', async () => {
|
||||
mockFindOneByUsername.mockResolvedValueOnce(null).mockResolvedValueOnce(null);
|
||||
mockGetAppServiceForUser.mockReturnValue({ registration: { _id: 'as1' } } as any);
|
||||
|
||||
await expect(getOrCreateFederatedUser('@irc_bob:local.com')).rejects.toThrow(
|
||||
'Error getting or creating federated user @irc_bob:local.com',
|
||||
);
|
||||
expect(mockCreateOrUpdateFederatedUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { IUser, RequiredField } from '@rocket.chat/core-typings';
|
||||
import { federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
|
||||
import { createOrUpdateFederatedUser } from './createOrUpdateFederatedUser';
|
||||
import { getUsernameServername } from './getUsernameServername';
|
||||
import { validateFederatedUsername } from './validateFederatedUsername';
|
||||
|
||||
const logger = new Logger('federation-matrix:user');
|
||||
|
||||
/**
|
||||
* `name` is deliberately not required: Rocket.Chat users are valid with a username and no
|
||||
* display name (`Accounts_RequireNameForSignUp` off), so demanding it would drop membership
|
||||
* events for nameless local users. Consumers must fall back to the username when displaying.
|
||||
*/
|
||||
type FederatedUser = RequiredField<IUser, 'username'>;
|
||||
|
||||
const hasUsername = (user: IUser): user is FederatedUser => user.username !== undefined;
|
||||
|
||||
/**
|
||||
* Resolves the local user for a Matrix user id, creating it on first contact.
|
||||
*
|
||||
* Only the last branch creates anything: once a user exists locally every later call
|
||||
* short-circuits on the lookup, which is why a broken creation path only ever surfaces
|
||||
* on the very first interaction with a given remote user.
|
||||
*/
|
||||
export async function getOrCreateFederatedUser(userId: string): Promise<FederatedUser> {
|
||||
try {
|
||||
const serverName = federationSDK.getConfig('serverName');
|
||||
const [username, userServerName, isLocal] = getUsernameServername(userId, serverName);
|
||||
|
||||
const user = await Users.findOneByUsername(username);
|
||||
if (user) {
|
||||
if (!hasUsername(user)) {
|
||||
throw new Error(`User ${username} has no username for Matrix ID: ${userId}`);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
const as = federationSDK.getAppServiceForUser(userId);
|
||||
if (as) {
|
||||
const user = await Users.findOneByUsername(userId);
|
||||
if (!user) {
|
||||
throw new Error('AppService user not found for creating user');
|
||||
}
|
||||
if (!hasUsername(user)) {
|
||||
throw new Error(`AppService user ${userId} has no username`);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
if (isLocal) {
|
||||
throw new Error(`Local user ${username} not found for Matrix ID: ${userId}`);
|
||||
}
|
||||
|
||||
if (!validateFederatedUsername(userId)) {
|
||||
throw new Error(`Invalid federated Matrix ID: ${userId}`);
|
||||
}
|
||||
|
||||
// awaited so a failure is logged with context and wrapped, instead of escaping the catch below
|
||||
return await createOrUpdateFederatedUser({
|
||||
username: userId,
|
||||
name: userId,
|
||||
origin: userServerName,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error getting or creating federated user', err, userId });
|
||||
throw new Error(`Error getting or creating federated user ${userId}`, { cause: err });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { type EventID } from '@rocket.chat/federation-sdk';
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
import { Messages } from '@rocket.chat/models';
|
||||
|
||||
// TODO replace by a reusable logger
|
||||
const logger = new Logger('federation-matrix:message');
|
||||
|
||||
export async function getThreadMessageId(threadRootEventId: EventID): Promise<{ tmid: string; tshow: boolean } | undefined> {
|
||||
const threadRootMessage = await Messages.findOneByFederationId(threadRootEventId);
|
||||
if (!threadRootMessage) {
|
||||
logger.warn({ msg: 'Thread root message not found for event', eventId: threadRootEventId });
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldSetTshow = !threadRootMessage?.tcount;
|
||||
return { tmid: threadRootMessage._id, tshow: shouldSetTshow };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { extractDomainFromMatrixUserId } from './extractDomainFromMatrixUserId';
|
||||
|
||||
/**
|
||||
* Extract the username and the servername from a matrix user id
|
||||
* if the serverName is the same as the serverName in the mxid, return only the username (rocket.chat regular username)
|
||||
* otherwise, return the full mxid and the servername
|
||||
*/
|
||||
|
||||
export const getUsernameServername = (mxid: string, serverName: string): [mxid: string, serverName: string, isLocal: boolean] => {
|
||||
const senderServerName = extractDomainFromMatrixUserId(mxid);
|
||||
// if the serverName is the same as the serverName in the mxid, return only the username (rocket.chat regular username)
|
||||
if (serverName === senderServerName) {
|
||||
const separatorIndex = mxid.indexOf(':', 1);
|
||||
if (separatorIndex === -1) {
|
||||
throw new Error(`Invalid federated username: ${mxid}`);
|
||||
}
|
||||
return [mxid.substring(1, separatorIndex), senderServerName, true]; // removers also the @
|
||||
}
|
||||
|
||||
return [mxid, senderServerName, false];
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { IUser, IRoom, FileAttachmentProps } from '@rocket.chat/core-typings';
|
||||
import { type MessageType, type FileMessageContent, type EventID } from '@rocket.chat/federation-sdk';
|
||||
|
||||
import { MatrixMediaService } from '../services/MatrixMediaService';
|
||||
|
||||
export async function handleMediaMessage(
|
||||
url: string,
|
||||
fileInfo: FileMessageContent['info'],
|
||||
msgtype: MessageType,
|
||||
messageBody: string,
|
||||
user: IUser,
|
||||
room: IRoom,
|
||||
matrixRoomId: string,
|
||||
eventId: EventID,
|
||||
thread?: { tmid: string; tshow: boolean },
|
||||
): Promise<{
|
||||
fromId: string;
|
||||
rid: string;
|
||||
msg: string;
|
||||
federation_event_id: string;
|
||||
thread?: { tmid: string; tshow: boolean };
|
||||
attachments: [FileAttachmentProps];
|
||||
}> {
|
||||
const mimeType = fileInfo?.mimetype;
|
||||
const fileName = messageBody;
|
||||
|
||||
const fileRefId = await MatrixMediaService.downloadAndStoreRemoteFile(url, matrixRoomId, {
|
||||
name: messageBody,
|
||||
size: fileInfo?.size || 0,
|
||||
type: mimeType || 'application/octet-stream',
|
||||
rid: room._id,
|
||||
userId: user._id,
|
||||
});
|
||||
|
||||
const fileUrl = `/file-upload/${fileRefId}/${encodeURIComponent(fileName)}`;
|
||||
|
||||
let attachment: FileAttachmentProps = {
|
||||
title: fileName,
|
||||
type: 'file',
|
||||
title_link: fileUrl,
|
||||
title_link_download: true,
|
||||
description: '',
|
||||
};
|
||||
|
||||
if (msgtype === 'm.image') {
|
||||
attachment = {
|
||||
...attachment,
|
||||
image_url: fileUrl,
|
||||
image_type: mimeType,
|
||||
image_size: fileInfo?.size || 0,
|
||||
...(fileInfo?.w &&
|
||||
fileInfo?.h && {
|
||||
image_dimensions: {
|
||||
width: fileInfo.w,
|
||||
height: fileInfo.h,
|
||||
},
|
||||
}),
|
||||
};
|
||||
} else if (msgtype === 'm.video') {
|
||||
attachment = {
|
||||
...attachment,
|
||||
video_url: fileUrl,
|
||||
video_type: mimeType,
|
||||
video_size: fileInfo?.size || 0,
|
||||
};
|
||||
} else if (msgtype === 'm.audio') {
|
||||
attachment = {
|
||||
...attachment,
|
||||
audio_url: fileUrl,
|
||||
audio_type: mimeType,
|
||||
audio_size: fileInfo?.size || 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
fromId: user._id,
|
||||
rid: room._id,
|
||||
msg: '',
|
||||
federation_event_id: eventId,
|
||||
thread,
|
||||
attachments: [attachment],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { federationSDK } from '@rocket.chat/federation-sdk';
|
||||
|
||||
import { isReservedByExclusiveBridge } from './isReservedByExclusiveBridge';
|
||||
|
||||
jest.mock('@rocket.chat/federation-sdk', () => ({
|
||||
federationSDK: {
|
||||
getConfig: jest.fn(),
|
||||
isExclusiveNamespace: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockGetConfig = federationSDK.getConfig as jest.MockedFunction<typeof federationSDK.getConfig>;
|
||||
const mockIsExclusiveNamespace = federationSDK.isExclusiveNamespace as jest.MockedFunction<typeof federationSDK.isExclusiveNamespace>;
|
||||
|
||||
describe('isReservedByExclusiveBridge', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetConfig.mockReturnValue('example.com');
|
||||
});
|
||||
|
||||
it('should return false when federation is not configured (no serverName)', () => {
|
||||
mockGetConfig.mockReturnValue('');
|
||||
|
||||
expect(isReservedByExclusiveBridge('user', 'irc_bob')).toBe(false);
|
||||
expect(isReservedByExclusiveBridge('room', 'irc_general')).toBe(false);
|
||||
expect(mockIsExclusiveNamespace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('user', () => {
|
||||
it('should return false when the username matches no exclusive namespace', () => {
|
||||
mockIsExclusiveNamespace.mockReturnValue(undefined);
|
||||
|
||||
expect(isReservedByExclusiveBridge('user', 'alice')).toBe(false);
|
||||
});
|
||||
|
||||
it('should check the `users` namespace with the full MXID', () => {
|
||||
mockIsExclusiveNamespace.mockReturnValue(undefined);
|
||||
|
||||
isReservedByExclusiveBridge('user', 'alice');
|
||||
|
||||
expect(mockIsExclusiveNamespace).toHaveBeenCalledWith('users', '@alice:example.com');
|
||||
});
|
||||
|
||||
it('should return true when the username falls within an exclusive namespace', () => {
|
||||
mockIsExclusiveNamespace.mockImplementation((_type, value) =>
|
||||
value === '@irc_bob:example.com' ? ({ registration: { _id: 'irc' } } as any) : undefined,
|
||||
);
|
||||
|
||||
expect(isReservedByExclusiveBridge('user', 'irc_bob')).toBe(true);
|
||||
});
|
||||
|
||||
it('should also match against the lowercased localpart', () => {
|
||||
mockIsExclusiveNamespace.mockImplementation((_type, value) =>
|
||||
value === '@irc_bob:example.com' ? ({ registration: { _id: 'irc' } } as any) : undefined,
|
||||
);
|
||||
|
||||
expect(isReservedByExclusiveBridge('user', 'IRC_bob')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('room', () => {
|
||||
it('should check both the `aliases` and `rooms` namespaces with the room alias', () => {
|
||||
mockIsExclusiveNamespace.mockReturnValue(undefined);
|
||||
|
||||
isReservedByExclusiveBridge('room', 'general');
|
||||
|
||||
expect(mockIsExclusiveNamespace).toHaveBeenCalledWith('aliases', '#general:example.com');
|
||||
expect(mockIsExclusiveNamespace).toHaveBeenCalledWith('rooms', '#general:example.com');
|
||||
});
|
||||
|
||||
it('should return true when the room name falls within the exclusive `aliases` namespace', () => {
|
||||
mockIsExclusiveNamespace.mockImplementation((type, value) =>
|
||||
type === 'aliases' && value === '#irc_general:example.com' ? ({ registration: { _id: 'irc' } } as any) : undefined,
|
||||
);
|
||||
|
||||
expect(isReservedByExclusiveBridge('room', 'irc_general')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when the room name falls within the exclusive `rooms` namespace', () => {
|
||||
mockIsExclusiveNamespace.mockImplementation((type, value) =>
|
||||
type === 'rooms' && value === '#irc_general:example.com' ? ({ registration: { _id: 'irc' } } as any) : undefined,
|
||||
);
|
||||
|
||||
expect(isReservedByExclusiveBridge('room', 'irc_general')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not check the `users` namespace for a room', () => {
|
||||
mockIsExclusiveNamespace.mockReturnValue(undefined);
|
||||
|
||||
isReservedByExclusiveBridge('room', 'general');
|
||||
|
||||
expect(mockIsExclusiveNamespace).not.toHaveBeenCalledWith('users', expect.anything());
|
||||
});
|
||||
|
||||
it('should also match against the lowercased localpart', () => {
|
||||
mockIsExclusiveNamespace.mockImplementation((_type, value) =>
|
||||
value === '#irc_general:example.com' ? ({ registration: { _id: 'irc' } } as any) : undefined,
|
||||
);
|
||||
|
||||
expect(isReservedByExclusiveBridge('room', 'IRC_general')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { federationSDK } from '@rocket.chat/federation-sdk';
|
||||
|
||||
// The federation SDK does not re-export `NamespaceType`, so mirror the union it accepts.
|
||||
type NamespaceType = 'users' | 'aliases' | 'rooms';
|
||||
|
||||
/**
|
||||
* The kind of Rocket.Chat handle being validated. A `user` is a person's username; a `room`
|
||||
* is a room or team name. Each maps to different Matrix appservice namespaces.
|
||||
*/
|
||||
export type ExclusiveBridgeHandleType = 'user' | 'room';
|
||||
|
||||
/**
|
||||
* Matrix appservice registrations declare separate exclusive namespaces. A username maps to a
|
||||
* user MXID (`@localpart:server`) in the `users` namespace; a room/team name maps to a room
|
||||
* alias (`#localpart:server`) — in Rocket.Chat the room alias and the room itself are one
|
||||
* concept, so both the `aliases` and `rooms` namespaces are checked.
|
||||
*/
|
||||
const NAMESPACES: Record<ExclusiveBridgeHandleType, { sigil: string; namespaces: NamespaceType[] }> = {
|
||||
user: { sigil: '@', namespaces: ['users'] },
|
||||
room: { sigil: '#', namespaces: ['aliases', 'rooms'] },
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns `true` when the given local Rocket.Chat handle falls within a bridge's *exclusive*
|
||||
* namespace, meaning only that bridge may own it and a regular user must not be allowed to
|
||||
* register/rename into it.
|
||||
*
|
||||
* Inert (returns `false`) when federation is not configured: with no `serverName` and no loaded
|
||||
* appservice registrations, `isExclusiveNamespace` has nothing to match.
|
||||
*/
|
||||
export function isReservedByExclusiveBridge(type: ExclusiveBridgeHandleType, name: string): boolean {
|
||||
const serverName = federationSDK.getConfig('serverName');
|
||||
if (!serverName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { sigil, namespaces } = NAMESPACES[type];
|
||||
|
||||
// Matrix localparts are conventionally lowercase while Rocket.Chat handles may be mixed-case,
|
||||
// so also test the lowercased localpart — erring towards reserving is the safe failure mode
|
||||
// for an exclusive namespace.
|
||||
const candidates = new Set([`${sigil}${name}:${serverName}`, `${sigil}${name.toLowerCase()}:${serverName}`]);
|
||||
|
||||
for (const namespace of namespaces) {
|
||||
for (const value of candidates) {
|
||||
if (federationSDK.isExclusiveNamespace(namespace, value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
1750
ee/packages/federation-matrix/src/helpers/message.parsers.spec.ts
Normal file
1750
ee/packages/federation-matrix/src/helpers/message.parsers.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
185
ee/packages/federation-matrix/src/helpers/message.parsers.ts
Normal file
185
ee/packages/federation-matrix/src/helpers/message.parsers.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import type { EventID, HomeserverEventSignatures } from '@rocket.chat/federation-sdk';
|
||||
import { marked } from 'marked';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
type MatrixMessageContent = HomeserverEventSignatures['homeserver.matrix.message']['event']['content'] & { format?: string };
|
||||
|
||||
type MatrixEvent = {
|
||||
content?: { body?: string; formatted_body?: string };
|
||||
event_id: string;
|
||||
sender: string;
|
||||
};
|
||||
|
||||
const MATRIX_TO_URL = 'https://matrix.to/#/';
|
||||
const MATRIX_QUOTE_TAGS = ['mx-reply', 'blockquote'];
|
||||
const REGEX = {
|
||||
anchor: /<a\s+(?:[^>]*?\s+)?href=["']([^"']*)["'][^>]*>(.*?)<\/a>/gi, // <a href="https://matrix.to/#/@user:server.com">@user:server.com</a>
|
||||
externalUsers: /@([0-9a-zA-Z-_.]+(@([0-9a-zA-Z-_.]+))?):+([0-9a-zA-Z-_.]+)(?=[^<>]*(?:<\w|$))/gm, // @username:server.com
|
||||
internalUsers: /(?:^|(?<=\s))@([0-9a-zA-Z-_.]+(@([0-9a-zA-Z-_.]+))?)(?=[^<>]*(?:<\w|$))/gm, // @username
|
||||
general: /(@all)|(@here)/gm,
|
||||
};
|
||||
|
||||
const escapeHtml = (text: string): string =>
|
||||
text.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] || c);
|
||||
|
||||
const stripHtml = (html: string, keep: string[] = []): string => sanitizeHtml(html, { allowedTags: keep.includes('a') ? ['a'] : [] });
|
||||
|
||||
const createMentionHtml = (id: string): string => `<a href="${MATRIX_TO_URL}${id}">${id}</a>`;
|
||||
|
||||
const extractAnchors = (html: string) => Array.from(html.matchAll(REGEX.anchor), ([, href, text]) => ({ href, text }));
|
||||
|
||||
const extractMentions = (html: string, homeServerDomain: string, senderExternalId: string) =>
|
||||
extractAnchors(html)
|
||||
.filter(({ href, text }) => href?.includes(MATRIX_TO_URL) && text)
|
||||
.map(({ href, text }) => {
|
||||
const userMatch = href.match(/@([^:]+):(.+)/);
|
||||
if (!userMatch) {
|
||||
return { mention: '@all', realName: text };
|
||||
}
|
||||
|
||||
const [, usernameWithoutDomain, serverDomain] = userMatch;
|
||||
const localUsername = `@${usernameWithoutDomain}`;
|
||||
const fullUsername = `@${usernameWithoutDomain}:${serverDomain}`;
|
||||
const mention = serverDomain === homeServerDomain ? localUsername : fullUsername;
|
||||
const realName = senderExternalId === text ? localUsername : text;
|
||||
return { mention, realName };
|
||||
});
|
||||
|
||||
const replaceMentions = (message: string, mentions: Array<{ mention: string; realName: string }>): string => {
|
||||
if (!mentions.length) return message;
|
||||
|
||||
let parsedMessage = '';
|
||||
let remaining = message;
|
||||
|
||||
for (const { mention, realName } of mentions) {
|
||||
const regex = new RegExp(`(?<!\\w)${realName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?!\\w)`);
|
||||
const position = remaining.search(regex);
|
||||
|
||||
if (position !== -1) {
|
||||
parsedMessage += remaining.slice(0, position) + mention;
|
||||
remaining = remaining.slice(position + realName.length);
|
||||
} else if (realName.startsWith('!')) {
|
||||
const allRegex = /(?<!\w)@all(?!\w)/;
|
||||
const allPosition = remaining.search(allRegex);
|
||||
if (allPosition !== -1) {
|
||||
parsedMessage += remaining.slice(0, allPosition) + mention;
|
||||
remaining = remaining.slice(allPosition + 4); // length of '@all'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parsedMessage += remaining;
|
||||
return parsedMessage.trim();
|
||||
};
|
||||
|
||||
const replaceWithMentionPills = async (message: string, regex: RegExp, createPill: (match: string) => string): Promise<string> => {
|
||||
const matches = Array.from(message.matchAll(regex), ([match]) => createPill(match.trimStart()));
|
||||
let i = 0;
|
||||
return message.replace(regex, () => ` ${matches[i++]}`);
|
||||
};
|
||||
|
||||
const stripQuotePrefix = (message: string): string => {
|
||||
const lines = message.split(/\r?\n/);
|
||||
const index = lines.findIndex((l) => !l.startsWith('>'));
|
||||
return lines
|
||||
.slice(index === -1 ? lines.length : index)
|
||||
.join('\n')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const createReplyContent = (roomId: string, event: MatrixEvent, textBody: string, htmlBody: string): MatrixMessageContent => {
|
||||
const body = event.content?.body || '';
|
||||
const html = event.content?.formatted_body || escapeHtml(body);
|
||||
const quote = `> <${event.sender}> ${body.split('\n').join('\n> ')}`;
|
||||
const htmlQuote =
|
||||
`<mx-reply><blockquote>` +
|
||||
`<a href="${MATRIX_TO_URL}${roomId}/${event.event_id}">In reply to</a> ` +
|
||||
`<a href="${MATRIX_TO_URL}${event.sender}">${event.sender}</a><br />${html}` +
|
||||
`</blockquote></mx-reply>`;
|
||||
|
||||
return {
|
||||
'm.relates_to': { 'm.in_reply_to': { event_id: event.event_id as EventID } },
|
||||
'msgtype': 'm.text',
|
||||
'body': `${quote}\n\n${textBody}`,
|
||||
'format': 'org.matrix.custom.html',
|
||||
'formatted_body': `${htmlQuote}${htmlBody}`,
|
||||
};
|
||||
};
|
||||
|
||||
export const toInternalMessageFormat = ({
|
||||
rawMessage,
|
||||
formattedMessage,
|
||||
homeServerDomain,
|
||||
senderExternalId,
|
||||
}: {
|
||||
rawMessage: string;
|
||||
formattedMessage: string;
|
||||
homeServerDomain: string;
|
||||
senderExternalId: string;
|
||||
}): string => replaceMentions(rawMessage, extractMentions(formattedMessage, homeServerDomain, senderExternalId));
|
||||
|
||||
export const toInternalQuoteMessageFormat = async ({
|
||||
homeServerDomain,
|
||||
formattedMessage,
|
||||
rawMessage,
|
||||
messageToReplyToUrl,
|
||||
senderExternalId,
|
||||
}: {
|
||||
messageToReplyToUrl: string;
|
||||
formattedMessage: string;
|
||||
rawMessage: string;
|
||||
homeServerDomain: string;
|
||||
senderExternalId: string;
|
||||
}): Promise<string> => {
|
||||
let cleaned = formattedMessage;
|
||||
MATRIX_QUOTE_TAGS.forEach((tag) => {
|
||||
cleaned = cleaned.replace(new RegExp(`<${tag}[^>]*>.*?</${tag}>`, 'gis'), '');
|
||||
});
|
||||
cleaned = stripHtml(cleaned, ['a']);
|
||||
|
||||
return `[ ](${messageToReplyToUrl}) ${replaceMentions(stripQuotePrefix(rawMessage), extractMentions(cleaned, homeServerDomain, senderExternalId))}`;
|
||||
};
|
||||
|
||||
export const toExternalMessageFormat = async ({
|
||||
externalRoomId,
|
||||
homeServerDomain,
|
||||
message,
|
||||
}: {
|
||||
message: string;
|
||||
externalRoomId: string;
|
||||
homeServerDomain: string;
|
||||
}): Promise<string> => {
|
||||
let result = message;
|
||||
result = await replaceWithMentionPills(result, REGEX.general, () => createMentionHtml(externalRoomId));
|
||||
result = await replaceWithMentionPills(result, REGEX.externalUsers, (match) => createMentionHtml(match));
|
||||
result = await replaceWithMentionPills(result, REGEX.internalUsers, (match) => createMentionHtml(`${match}:${homeServerDomain}`));
|
||||
|
||||
return (await marked.parse(result.trim())).replace(/\s+/g, ' ').trim();
|
||||
};
|
||||
|
||||
export const toExternalQuoteMessageFormat = async ({
|
||||
message,
|
||||
eventToReplyTo,
|
||||
externalRoomId,
|
||||
homeServerDomain,
|
||||
originalEventSender,
|
||||
}: {
|
||||
externalRoomId: string;
|
||||
eventToReplyTo: string;
|
||||
originalEventSender: string;
|
||||
message: string;
|
||||
homeServerDomain: string;
|
||||
}): Promise<{ message: string; formattedMessage: string }> => {
|
||||
const event = { event_id: eventToReplyTo, sender: originalEventSender, content: {} };
|
||||
const markdownHtml = await marked.parse(message);
|
||||
const withMentions = await toExternalMessageFormat({ message, externalRoomId, homeServerDomain });
|
||||
const withMentionsHtml = await marked.parse(withMentions);
|
||||
|
||||
const reply1 = createReplyContent(externalRoomId, event, markdownHtml, withMentionsHtml);
|
||||
const reply2 = createReplyContent(externalRoomId, event, message, withMentionsHtml);
|
||||
|
||||
return {
|
||||
message: reply2.body,
|
||||
formattedMessage: reply1.formatted_body ?? '',
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { decodeXmppUserId, isFullXmppUserId, parseXmppUserId } from './parseXmppUserId';
|
||||
|
||||
describe('decodeXmppUserId', () => {
|
||||
it('should decode the `=xx` escapes back to their characters', () => {
|
||||
expect(decodeXmppUserId('prince=2fmychannel=40conference.xmpp.host')).toBe('prince/mychannel@conference.xmpp.host');
|
||||
});
|
||||
|
||||
it('should accept uppercase hex digits', () => {
|
||||
expect(decodeXmppUserId('a=2Fb=40d')).toBe('a/b@d');
|
||||
});
|
||||
|
||||
it('should decode multi-byte UTF-8 characters', () => {
|
||||
// "é" is U+00E9 -> UTF-8 bytes 0xc3 0xa9
|
||||
expect(decodeXmppUserId('caf=c3=a9=40xmpp.host')).toBe('café@xmpp.host');
|
||||
});
|
||||
|
||||
it('should leave a value without escapes untouched', () => {
|
||||
expect(decodeXmppUserId('justaname')).toBe('justaname');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFullXmppUserId', () => {
|
||||
it('should accept a value with both `@` and `/`', () => {
|
||||
expect(isFullXmppUserId('prince/mychannel@conference.xmpp.host')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject a bare JID with no resource', () => {
|
||||
expect(isFullXmppUserId('alice@xmpp.host')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject a value with no domain separator', () => {
|
||||
expect(isFullXmppUserId('prince/mychannel')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseXmppUserId', () => {
|
||||
it('should split a MUC occupant id into resource, local and domain', () => {
|
||||
expect(parseXmppUserId('prince/mychannel@conference.xmpp.host')).toEqual({
|
||||
local: 'mychannel',
|
||||
domain: 'conference.xmpp.host',
|
||||
resource: 'prince',
|
||||
jid: 'mychannel@conference.xmpp.host/prince',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse a bare JID with no resource', () => {
|
||||
expect(parseXmppUserId('alice@xmpp.host')).toEqual({
|
||||
local: 'alice',
|
||||
domain: 'xmpp.host',
|
||||
resource: undefined,
|
||||
jid: 'alice@xmpp.host',
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep a / that belongs to the resource', () => {
|
||||
expect(parseXmppUserId('a/b/mychannel@conference.xmpp.host')).toEqual({
|
||||
local: 'mychannel',
|
||||
domain: 'conference.xmpp.host',
|
||||
resource: 'a/b',
|
||||
jid: 'mychannel@conference.xmpp.host/a/b',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when there is no domain separator', () => {
|
||||
expect(() => parseXmppUserId('justaname')).toThrow('missing domain separator');
|
||||
});
|
||||
|
||||
it('should throw when the local part is empty', () => {
|
||||
expect(() => parseXmppUserId('prince/@conference.xmpp.host')).toThrow('empty local or domain');
|
||||
});
|
||||
|
||||
it('should throw when the domain is empty', () => {
|
||||
expect(() => parseXmppUserId('prince/mychannel@')).toThrow('empty local or domain');
|
||||
});
|
||||
|
||||
it('should throw when both local and domain are empty', () => {
|
||||
expect(() => parseXmppUserId('@')).toThrow('empty local or domain');
|
||||
});
|
||||
|
||||
it('should throw when a resource separator is present but the resource is empty', () => {
|
||||
expect(() => parseXmppUserId('/mychannel@conference.xmpp.host')).toThrow('empty resource');
|
||||
});
|
||||
});
|
||||
102
ee/packages/federation-matrix/src/helpers/parseXmppUserId.ts
Normal file
102
ee/packages/federation-matrix/src/helpers/parseXmppUserId.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export interface ParsedXmppUserId {
|
||||
/** node / localpart of the JID, e.g. `mychannel` */
|
||||
local: string;
|
||||
/** domain of the JID, e.g. `conference.xmpp.host` */
|
||||
domain: string;
|
||||
/** optional resource — usually the user's nick in a MUC, e.g. `prince` */
|
||||
resource?: string;
|
||||
/** canonical XMPP JID rebuilt as `local@domain[/resource]` */
|
||||
jid: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the `=xx` escapes of a Matrix localpart back to their characters.
|
||||
*
|
||||
* Characters outside the safe localpart set are encoded as `=` followed by the
|
||||
* lowercase hex of each UTF-8 byte (https://spec.matrix.org/latest/appendices/#mapping-from-other-character-sets),
|
||||
* e.g. `/` -> `=2f`, `@` -> `=40`. Multi-byte characters become several
|
||||
* consecutive `=xx` sequences, so we collect the raw bytes and decode them
|
||||
* together as UTF-8 rather than per-escape.
|
||||
*
|
||||
* @param value - escaped localpart, e.g. `prince=2fmychannel=40conference.xmpp.host`
|
||||
* @returns the decoded value, e.g. `prince/mychannel@conference.xmpp.host`
|
||||
*/
|
||||
export const decodeXmppUserId = (value: string): string => {
|
||||
const bytes: number[] = [];
|
||||
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const char = value[i];
|
||||
const hex = value.substring(i + 1, i + 3);
|
||||
|
||||
if (char === '=' && /^[0-9a-fA-F]{2}$/.test(hex)) {
|
||||
bytes.push(parseInt(hex, 16));
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
bytes.push(...Buffer.from(char, 'utf8'));
|
||||
}
|
||||
|
||||
return Buffer.from(bytes).toString('utf8');
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a decoded value is a full XMPP MUC occupant id, i.e. it carries both a
|
||||
* `@` (separating local from domain) and a `/` (separating the resource). Use
|
||||
* this to guard {@link parseXmppUserId}, which assumes a domain separator is
|
||||
* present and otherwise rejects the input.
|
||||
*
|
||||
* @param decoded - already-decoded value, e.g. `prince/mychannel@conference.xmpp.host`
|
||||
*/
|
||||
export const isFullXmppUserId = (decoded: string): boolean => decoded.includes('@') && decoded.includes('/');
|
||||
|
||||
/**
|
||||
* Parse a decoded XMPP user identifier into its JID components. The input must
|
||||
* already be decoded (see {@link decodeXmppUserId}); the value carried in a
|
||||
* Matrix localpart is the part after the bridge prefix and before `:serverName`.
|
||||
*
|
||||
* matrix-bifrost packs an XMPP JID into the localpart as `<resource>/<local>@<domain>`
|
||||
* — resource first, since it's usually the more meaningful MUC nick. As neither
|
||||
* `local` nor `domain` may contain `/` or `@`, we parse from the right so a `/`
|
||||
* or `@` inside the resource is preserved.
|
||||
*
|
||||
* @param decoded - decoded user id, e.g. `prince/mychannel@conference.xmpp.host`
|
||||
* @throws if the value has no `@` separating local from domain, if `local` or
|
||||
* `domain` is empty, or if a resource separator (`/`) is present but the
|
||||
* resource is empty
|
||||
*
|
||||
* @example
|
||||
* parseXmppUserId('prince/mychannel@conference.xmpp.host');
|
||||
* // -> { local: 'mychannel', domain: 'conference.xmpp.host', resource: 'prince',
|
||||
* // jid: 'mychannel@conference.xmpp.host/prince' }
|
||||
*/
|
||||
export const parseXmppUserId = (decoded: string): ParsedXmppUserId => {
|
||||
const atIndex = decoded.lastIndexOf('@');
|
||||
if (atIndex === -1) {
|
||||
throw new Error(`Invalid XMPP user id, missing domain separator: ${decoded}`);
|
||||
}
|
||||
|
||||
const domain = decoded.substring(atIndex + 1);
|
||||
const beforeDomain = decoded.substring(0, atIndex);
|
||||
|
||||
const slashIndex = beforeDomain.lastIndexOf('/');
|
||||
const resource = slashIndex === -1 ? undefined : beforeDomain.substring(0, slashIndex);
|
||||
const local = slashIndex === -1 ? beforeDomain : beforeDomain.substring(slashIndex + 1);
|
||||
|
||||
if (!local || !domain) {
|
||||
throw new Error(`Invalid XMPP user id, empty local or domain: ${decoded}`);
|
||||
}
|
||||
|
||||
// A `/` with nothing before it is a malformed resource, not a bare JID.
|
||||
if (slashIndex !== -1 && !resource) {
|
||||
throw new Error(`Invalid XMPP user id, empty resource: ${decoded}`);
|
||||
}
|
||||
|
||||
return {
|
||||
local,
|
||||
domain,
|
||||
resource,
|
||||
jid: resource ? `${local}@${domain}/${resource}` : `${local}@${domain}`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { validateFederatedUsername } from './validateFederatedUsername';
|
||||
|
||||
describe('validateFederatedUsername', () => {
|
||||
describe('invalid formats', () => {
|
||||
it('should return false when mxid does not start with @', () => {
|
||||
expect(validateFederatedUsername('user:example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when mxid has no colon separator', () => {
|
||||
expect(validateFederatedUsername('@user')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when mxid has empty localpart', () => {
|
||||
expect(validateFederatedUsername('@:example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when localpart contains invalid characters', () => {
|
||||
expect(validateFederatedUsername('@user@name:example.com')).toBe(false);
|
||||
expect(validateFederatedUsername('@user#name:example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when localpart exceeds 255 characters', () => {
|
||||
const longLocalpart = 'a'.repeat(256);
|
||||
expect(validateFederatedUsername(`@${longLocalpart}:example.com`)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when domain is invalid', () => {
|
||||
expect(validateFederatedUsername('@user:invalid_domain')).toBe(false);
|
||||
expect(validateFederatedUsername('@user:-example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when port is invalid', () => {
|
||||
expect(validateFederatedUsername('@user:example.com:0')).toBe(false);
|
||||
expect(validateFederatedUsername('@user:example.com:65536')).toBe(false);
|
||||
expect(validateFederatedUsername('@user:example.com:abc')).toBe(false);
|
||||
expect(validateFederatedUsername('@user:example.com:-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('valid formats', () => {
|
||||
it('should return true for basic valid mxid', () => {
|
||||
expect(validateFederatedUsername('@user:example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when localpart contains uppercase letters', () => {
|
||||
expect(validateFederatedUsername('@User:example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for mxid with dots and hyphens in localpart', () => {
|
||||
expect(validateFederatedUsername('@user.name:example.com')).toBe(true);
|
||||
expect(validateFederatedUsername('@user-name:example.com')).toBe(true);
|
||||
expect(validateFederatedUsername('@user_name:example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for mxid with encoded characters in localpart', () => {
|
||||
expect(validateFederatedUsername('@user=2dname:example.com')).toBe(true);
|
||||
expect(validateFederatedUsername('@user=2Dname:example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for mxid with subdomain', () => {
|
||||
expect(validateFederatedUsername('@user:subdomain.example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for mxid with valid port', () => {
|
||||
expect(validateFederatedUsername('@user:example.com:8008')).toBe(true);
|
||||
expect(validateFederatedUsername('@user:example.com:1')).toBe(true);
|
||||
expect(validateFederatedUsername('@user:example.com:65535')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for mxid with IPv4 address', () => {
|
||||
expect(validateFederatedUsername('@user:192.168.1.1')).toBe(true);
|
||||
expect(validateFederatedUsername('@user:192.168.1.1:8008')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for mxid with IPv6 address', () => {
|
||||
expect(validateFederatedUsername('@user:[::1]')).toBe(true);
|
||||
expect(validateFederatedUsername('@user:[2001:db8::1]')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for mxid with numbers in localpart', () => {
|
||||
expect(validateFederatedUsername('@user123:example.com')).toBe(true);
|
||||
expect(validateFederatedUsername('@123user:example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for mxid with single character localpart', () => {
|
||||
expect(validateFederatedUsername('@a:example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for mxid with 255 character localpart', () => {
|
||||
const maxLocalpart = 'a'.repeat(255);
|
||||
expect(validateFederatedUsername(`@${maxLocalpart}:example.com`)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { isIPv4, isIPv6 } from 'node:net';
|
||||
|
||||
import type { UserID } from '@rocket.chat/federation-sdk';
|
||||
|
||||
/** helper to validate the username format */
|
||||
export function validateFederatedUsername(mxid: string): mxid is UserID {
|
||||
if (!mxid.startsWith('@')) return false;
|
||||
|
||||
const withoutAt = mxid.substring(1);
|
||||
const firstColonIndex = withoutAt.indexOf(':');
|
||||
|
||||
if (firstColonIndex === -1) return false;
|
||||
|
||||
const localpart = withoutAt.substring(0, firstColonIndex);
|
||||
const domainAndPort = withoutAt.substring(firstColonIndex + 1);
|
||||
|
||||
const localpartRegex = /^(?:[a-zA-Z0-9._\-]|=[0-9a-fA-F]{2}){1,255}$/;
|
||||
if (!localpartRegex.test(localpart)) return false;
|
||||
|
||||
// Handle IPv6 addresses specially - they're wrapped in brackets and contain colons
|
||||
let domain: string;
|
||||
let port: string | undefined;
|
||||
|
||||
if (domainAndPort.startsWith('[')) {
|
||||
// IPv6 address
|
||||
const closeBracketIndex = domainAndPort.indexOf(']');
|
||||
if (closeBracketIndex === -1) return false;
|
||||
|
||||
domain = domainAndPort.substring(0, closeBracketIndex + 1);
|
||||
|
||||
// Check if there's a port after the IPv6 address
|
||||
const afterBracket = domainAndPort.substring(closeBracketIndex + 1);
|
||||
if (afterBracket.startsWith(':')) {
|
||||
port = afterBracket.substring(1);
|
||||
} else if (afterBracket.length > 0) {
|
||||
return false; // Invalid format after IPv6 bracket
|
||||
}
|
||||
} else {
|
||||
// Regular domain or IPv4
|
||||
const lastColonIndex = domainAndPort.lastIndexOf(':');
|
||||
const possiblePort = domainAndPort.substring(lastColonIndex + 1);
|
||||
|
||||
// Check if the part after the last colon is a valid port number
|
||||
if (lastColonIndex !== -1 && /^[0-9]+$/.test(possiblePort)) {
|
||||
domain = domainAndPort.substring(0, lastColonIndex);
|
||||
port = possiblePort;
|
||||
} else {
|
||||
domain = domainAndPort;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate domain: hostname, IPv4, or IPv6
|
||||
const hostnameRegex = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?)*$/i;
|
||||
|
||||
// Check if it's a valid IPv6 address (in brackets)
|
||||
if (domain.startsWith('[') && domain.endsWith(']')) {
|
||||
const ipv6Address = domain.substring(1, domain.length - 1);
|
||||
if (!isIPv6(ipv6Address)) {
|
||||
return false;
|
||||
}
|
||||
} else if (isIPv4(domain)) {
|
||||
// Valid IPv4 address
|
||||
} else if (!hostnameRegex.test(domain)) {
|
||||
// Not a valid hostname
|
||||
return false;
|
||||
}
|
||||
|
||||
if (port !== undefined) {
|
||||
const portNum = Number(port);
|
||||
if (!/^[0-9]+$/.test(port) || portNum < 1 || portNum > 65535) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
13
ee/packages/federation-matrix/src/index.ts
Normal file
13
ee/packages/federation-matrix/src/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
export { validateFederatedUsername } from './helpers/validateFederatedUsername';
|
||||
|
||||
export { isReservedByExclusiveBridge } from './helpers/isReservedByExclusiveBridge';
|
||||
|
||||
export { FederationMatrix } from './FederationMatrix';
|
||||
|
||||
export { generateEd25519RandomSecretKey } from '@rocket.chat/federation-sdk';
|
||||
|
||||
export { getFederationRoutes } from './api/routes';
|
||||
|
||||
export { setupFederationMatrix, configureFederationMatrixSettings } from './setup';
|
||||
168
ee/packages/federation-matrix/src/services/MatrixMediaService.ts
Normal file
168
ee/packages/federation-matrix/src/services/MatrixMediaService.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
import type { IUploadDetails } from '@rocket.chat/apps-engine/definition/uploads/IUploadDetails';
|
||||
import { Upload } from '@rocket.chat/core-services';
|
||||
import type { IUpload } from '@rocket.chat/core-typings';
|
||||
import { federationSDK } from '@rocket.chat/federation-sdk';
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
import { Avatars, Uploads } from '@rocket.chat/models';
|
||||
|
||||
const logger = new Logger('federation-matrix:media-service');
|
||||
|
||||
export class MatrixMediaService {
|
||||
static generateMXCUri(fileId: string, serverName: string): string {
|
||||
return `mxc://${serverName}/${fileId}`;
|
||||
}
|
||||
|
||||
static parseMXCUri(mxcUri: string): { serverName: string; mediaId: string } | null {
|
||||
const match = mxcUri.match(/^mxc:\/\/([^/]+)\/(.+)$/);
|
||||
if (!match) {
|
||||
logger.error({ mxcUri, msg: 'Invalid MXC URI format' });
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
serverName: match[1],
|
||||
mediaId: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
static async prepareLocalFileForMatrix(fileId: string, serverName: string, matrixRoomId: string): Promise<string> {
|
||||
try {
|
||||
const file = await Uploads.findOneById(fileId);
|
||||
if (!file) {
|
||||
logger.error({ msg: 'File not found in database', fileId });
|
||||
throw new Error(`File ${fileId} not found`);
|
||||
}
|
||||
|
||||
if (file.federation?.mxcUri) {
|
||||
return file.federation.mxcUri;
|
||||
}
|
||||
|
||||
const mxcUri = this.generateMXCUri(fileId, serverName);
|
||||
|
||||
await Uploads.setFederationInfo(fileId, {
|
||||
mrid: matrixRoomId,
|
||||
mxcUri,
|
||||
serverName,
|
||||
mediaId: fileId,
|
||||
});
|
||||
|
||||
return mxcUri;
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error preparing file for Matrix', err });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
static async getLocalFileForMatrixNode(mediaId: string, serverName: string): Promise<IUpload | null> {
|
||||
try {
|
||||
// try to find an avatar with the given mediaId as etag first, the index tends to be smaller
|
||||
const avatarFile = await Avatars.findOneByETag(mediaId);
|
||||
if (avatarFile) {
|
||||
return avatarFile;
|
||||
}
|
||||
|
||||
let file = await Uploads.findByFederationMediaIdAndServerName(mediaId, serverName);
|
||||
|
||||
if (!file) {
|
||||
file = await Uploads.findOneById(mediaId);
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return file;
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error retrieving local file', err });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async uploadFromAppService(params: {
|
||||
buffer: Buffer;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
userId: string;
|
||||
}): Promise<{ mediaId: string; mxcUri: string }> {
|
||||
try {
|
||||
const serverName = federationSDK.getConfig('serverName');
|
||||
const mediaId = crypto.randomUUID().replace(/-/g, ''); // TODO maybe change to @rocket.chat/random ?
|
||||
const mxcUri = this.generateMXCUri(mediaId, serverName);
|
||||
|
||||
await Upload.uploadFile({
|
||||
userId: params.userId,
|
||||
buffer: params.buffer,
|
||||
details: {
|
||||
name: params.fileName,
|
||||
size: params.buffer.length,
|
||||
type: params.mimeType,
|
||||
rid: '',
|
||||
userId: params.userId,
|
||||
},
|
||||
federation: {
|
||||
mxcUri,
|
||||
mrid: '',
|
||||
serverName,
|
||||
mediaId,
|
||||
},
|
||||
});
|
||||
|
||||
return { mediaId, mxcUri };
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error uploading file from app service', err });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
static async downloadAndStoreRemoteFile(mxcUri: string, matrixRoomId: string, metadata: IUploadDetails): Promise<string> {
|
||||
try {
|
||||
const parts = this.parseMXCUri(mxcUri);
|
||||
if (!parts) {
|
||||
logger.error({ mxcUri, msg: 'Invalid MXC URI format' });
|
||||
throw new Error('Invalid MXC URI');
|
||||
}
|
||||
|
||||
const uploadAlreadyExists = await Uploads.findByFederationMediaIdAndServerName(parts.mediaId, parts.serverName);
|
||||
if (uploadAlreadyExists) {
|
||||
// App-service uploads are stored before any room is known (empty rid/mrid);
|
||||
// backfill the association now that a message ties the file to a room.
|
||||
if (!uploadAlreadyExists.rid && metadata.rid) {
|
||||
await Uploads.setFederationRoomInfo(uploadAlreadyExists._id, metadata.rid, matrixRoomId);
|
||||
}
|
||||
return uploadAlreadyExists._id;
|
||||
}
|
||||
|
||||
const buffer = await federationSDK.downloadFromRemoteServer(parts.serverName, parts.mediaId);
|
||||
if (!buffer) {
|
||||
throw new Error('Download from remote server returned null content.');
|
||||
}
|
||||
|
||||
// TODO: Make uploadFile support Partial<IUpload> to avoid calling a DB update right after the upload to set the federation info
|
||||
const uploadedFile = await Upload.uploadFile({
|
||||
userId: metadata.userId || 'federation',
|
||||
buffer,
|
||||
details: {
|
||||
...metadata,
|
||||
size: buffer.length,
|
||||
},
|
||||
});
|
||||
|
||||
await Uploads.setFederationInfo(uploadedFile._id, {
|
||||
mxcUri,
|
||||
mrid: matrixRoomId,
|
||||
serverName: parts.serverName,
|
||||
mediaId: parts.mediaId,
|
||||
});
|
||||
|
||||
return uploadedFile._id;
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error downloading and storing remote file', err });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
static async getLocalFileBuffer(file: IUpload): Promise<Buffer> {
|
||||
return Upload.getFileBuffer({ file });
|
||||
}
|
||||
}
|
||||
141
ee/packages/federation-matrix/src/setup.ts
Normal file
141
ee/packages/federation-matrix/src/setup.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { federationSDK, init } from '@rocket.chat/federation-sdk';
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
|
||||
import { registerEvents } from './events';
|
||||
|
||||
const logger = new Logger('FederationSetup');
|
||||
|
||||
function validateDomain(domain: string): boolean {
|
||||
const value = domain.trim();
|
||||
|
||||
if (!value) {
|
||||
logger.error('The Federation domain is not set');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.toLowerCase() !== value) {
|
||||
logger.error({ msg: 'The Federation domain cannot have uppercase letters', domain: value });
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const valid = new URL(`https://${value}`).hostname === value;
|
||||
|
||||
if (!valid) {
|
||||
throw new Error();
|
||||
}
|
||||
} catch {
|
||||
logger.error({ msg: 'The configured Federation domain is not valid', domain: value });
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function configureFederationMatrixSettings(settings: {
|
||||
instanceId: string;
|
||||
domain: string;
|
||||
signingKey: string;
|
||||
signingAlgorithm: string;
|
||||
signingVersion: string;
|
||||
allowedEncryptedRooms: boolean;
|
||||
allowedNonPrivateRooms: boolean;
|
||||
processEDUTyping: boolean;
|
||||
processEDUPresence: boolean;
|
||||
processEDUReceipt: boolean;
|
||||
xmppEnabled: boolean;
|
||||
xmppBridgeURL: string;
|
||||
xmppBridgeHSToken: string;
|
||||
xmppBridgeASToken: string;
|
||||
}) {
|
||||
const {
|
||||
instanceId,
|
||||
domain: serverName,
|
||||
signingKey,
|
||||
signingAlgorithm: signingAlg,
|
||||
signingVersion,
|
||||
allowedEncryptedRooms,
|
||||
allowedNonPrivateRooms,
|
||||
processEDUTyping,
|
||||
processEDUPresence,
|
||||
processEDUReceipt,
|
||||
xmppEnabled,
|
||||
xmppBridgeURL,
|
||||
xmppBridgeHSToken,
|
||||
xmppBridgeASToken,
|
||||
} = settings;
|
||||
|
||||
if (!validateDomain(serverName)) {
|
||||
throw new Error('Invalid Federation domain');
|
||||
}
|
||||
|
||||
federationSDK.setConfig({
|
||||
instanceId,
|
||||
serverName,
|
||||
keyRefreshInterval: Number.parseInt(process.env.MATRIX_KEY_REFRESH_INTERVAL || '60', 10),
|
||||
matrixDomain: serverName,
|
||||
version: process.env.SERVER_VERSION || '1.0',
|
||||
port: Number.parseInt(process.env.SERVER_PORT || '8080', 10),
|
||||
signingKey: `${signingAlg} ${signingVersion} ${signingKey}`,
|
||||
signingKeyPath: '', // TODO remove
|
||||
media: {
|
||||
maxFileSize: Number.parseInt(process.env.MEDIA_MAX_FILE_SIZE || '100', 10) * 1024 * 1024,
|
||||
allowedMimeTypes: process.env.MEDIA_ALLOWED_MIME_TYPES?.split(',') || [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'text/plain',
|
||||
'application/pdf',
|
||||
'video/mp4',
|
||||
'audio/mpeg',
|
||||
'audio/ogg',
|
||||
],
|
||||
enableThumbnails: process.env.MEDIA_ENABLE_THUMBNAILS !== 'true',
|
||||
rateLimits: {
|
||||
uploadPerMinute: Number.parseInt(process.env.MEDIA_UPLOAD_RATE_LIMIT || '10', 10),
|
||||
downloadPerMinute: Number.parseInt(process.env.MEDIA_DOWNLOAD_RATE_LIMIT || '60', 10),
|
||||
},
|
||||
},
|
||||
invite: {
|
||||
allowedEncryptedRooms,
|
||||
allowedNonPrivateRooms,
|
||||
},
|
||||
edu: {
|
||||
processTyping: processEDUTyping,
|
||||
processPresence: processEDUPresence,
|
||||
processReceipt: processEDUReceipt,
|
||||
},
|
||||
});
|
||||
|
||||
if (xmppEnabled) {
|
||||
await federationSDK.registerAppService({
|
||||
_id: 'xmpp',
|
||||
url: xmppBridgeURL,
|
||||
asToken: xmppBridgeASToken,
|
||||
hsToken: xmppBridgeHSToken,
|
||||
senderLocalpart: 'xmpp',
|
||||
namespaces: {
|
||||
users: [{ regex: '@_xmpp_.*', exclusive: true }],
|
||||
aliases: [{ regex: '#_xmpp_.*', exclusive: true }],
|
||||
rooms: [],
|
||||
},
|
||||
protocols: ['xmpp'],
|
||||
rateLimited: false,
|
||||
receiveEphemeral: true,
|
||||
});
|
||||
} else {
|
||||
await federationSDK.unregisterAppService('xmpp');
|
||||
}
|
||||
}
|
||||
|
||||
export async function setupFederationMatrix() {
|
||||
await init({
|
||||
dbConfig: {
|
||||
uri: process.env.MONGO_URL || 'mongodb://localhost:3001/meteor',
|
||||
poolSize: Number.parseInt(process.env.DATABASE_POOL_SIZE || '10', 10),
|
||||
},
|
||||
});
|
||||
|
||||
registerEvents();
|
||||
}
|
||||
18
ee/packages/federation-matrix/src/types/ICallbacks.ts
Normal file
18
ee/packages/federation-matrix/src/types/ICallbacks.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { IMessage, IUser } from '@rocket.chat/core-typings';
|
||||
|
||||
export interface ICallbackPriority {
|
||||
HIGH: number;
|
||||
MEDIUM: number;
|
||||
LOW: number;
|
||||
}
|
||||
|
||||
export interface ICallbacks {
|
||||
priority: ICallbackPriority;
|
||||
add(hook: string, callback: (...args: any[]) => any, priority?: number, id?: string): void;
|
||||
remove(hook: string, id: string): void;
|
||||
}
|
||||
|
||||
export interface IFederationCallbackHandlers {
|
||||
afterSetReaction?: (message: IMessage, params: { user: IUser; reaction: string }) => Promise<void>;
|
||||
afterUnsetReaction?: (message: IMessage, params: { user: IUser; reaction: string; oldMessage: IMessage }) => Promise<void>;
|
||||
}
|
||||
60
ee/packages/federation-matrix/src/utils/emojiConverter.ts
Normal file
60
ee/packages/federation-matrix/src/utils/emojiConverter.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Emoji } from 'emojibase';
|
||||
import data from 'emojibase-data/en/data.json';
|
||||
import shortcodes from 'emojibase-data/en/shortcodes/emojibase.json';
|
||||
|
||||
let _unicodeToShort: Map<string, string> | null = null;
|
||||
let _shortToUnicode: Map<string, string> | null = null;
|
||||
|
||||
function getShortcodes(hexcode: string): string[] {
|
||||
const entry = (shortcodes as Record<string, string | string[]>)[hexcode];
|
||||
if (!entry) return [];
|
||||
return Array.isArray(entry) ? entry : [entry];
|
||||
}
|
||||
|
||||
function buildMaps() {
|
||||
_unicodeToShort = new Map();
|
||||
_shortToUnicode = new Map();
|
||||
|
||||
for (const emoji of data as Emoji[]) {
|
||||
const codes = getShortcodes(emoji.hexcode);
|
||||
if (codes.length === 0) continue;
|
||||
|
||||
const shortname = `:${codes[0]}:`;
|
||||
_unicodeToShort.set(emoji.emoji, shortname);
|
||||
_shortToUnicode.set(shortname, emoji.emoji);
|
||||
|
||||
for (const alt of codes.slice(1)) {
|
||||
_shortToUnicode.set(`:${alt}:`, emoji.emoji);
|
||||
}
|
||||
|
||||
if (emoji.skins) {
|
||||
for (const skin of emoji.skins) {
|
||||
const skinCodes = getShortcodes(skin.hexcode);
|
||||
if (skinCodes.length > 0) {
|
||||
_unicodeToShort.set(skin.emoji, `:${skinCodes[0]}:`);
|
||||
for (const code of skinCodes) {
|
||||
_shortToUnicode.set(`:${code}:`, skin.emoji);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureMaps() {
|
||||
if (!_unicodeToShort || !_shortToUnicode) {
|
||||
buildMaps();
|
||||
}
|
||||
}
|
||||
|
||||
export function unicodeToShortname(text: string): string {
|
||||
ensureMaps();
|
||||
return _unicodeToShort!.get(text) ?? text;
|
||||
}
|
||||
|
||||
export function shortnameToUnicode(text: string): string {
|
||||
ensureMaps();
|
||||
return text.replace(/:([a-zA-Z0-9_+-]+):/g, (match) => {
|
||||
return _shortToUnicode!.get(match) ?? match;
|
||||
});
|
||||
}
|
||||
446
ee/packages/federation-matrix/tests/end-to-end/ban.spec.ts
Normal file
446
ee/packages/federation-matrix/tests/end-to-end/ban.spec.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import type { IRoomNativeFederated, IUser } from '@rocket.chat/core-typings';
|
||||
import { Visibility } from 'matrix-js-sdk';
|
||||
|
||||
import type {} from '../../../../../apps/meteor/app/api/server/v1/rooms.ts';
|
||||
import { api } from '../../../../../apps/meteor/tests/data/api-data';
|
||||
import { sendMessage } from '../../../../../apps/meteor/tests/data/messages.helper';
|
||||
import { createRoom, acceptRoomInvite, getRoomMembers } from '../../../../../apps/meteor/tests/data/rooms.helper';
|
||||
import { type IRequestConfig, getRequestConfig, createUser } from '../../../../../apps/meteor/tests/data/users.helper';
|
||||
import { IS_EE } from '../../../../../apps/meteor/tests/e2e/config/constants';
|
||||
import { retry } from '../../../../../apps/meteor/tests/end-to-end/api/helpers/retry';
|
||||
import { federationConfig } from '../helper/config';
|
||||
import { SynapseClient } from '../helper/synapse-client';
|
||||
|
||||
(IS_EE ? describe : describe.skip)('Federation Ban/Unban', () => {
|
||||
let rc1AdminRequestConfig: IRequestConfig;
|
||||
let rc1User1RequestConfig: IRequestConfig;
|
||||
let hs1AdminApp: SynapseClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
rc1AdminRequestConfig = await getRequestConfig(
|
||||
federationConfig.rc1.url,
|
||||
federationConfig.rc1.adminUser,
|
||||
federationConfig.rc1.adminPassword,
|
||||
);
|
||||
|
||||
await createUser(
|
||||
{
|
||||
username: federationConfig.rc1.additionalUser1.username,
|
||||
password: federationConfig.rc1.additionalUser1.password,
|
||||
email: `${federationConfig.rc1.additionalUser1.username}@rocket.chat`,
|
||||
name: federationConfig.rc1.additionalUser1.username,
|
||||
},
|
||||
rc1AdminRequestConfig,
|
||||
);
|
||||
|
||||
rc1User1RequestConfig = await getRequestConfig(
|
||||
federationConfig.rc1.url,
|
||||
federationConfig.rc1.additionalUser1.username,
|
||||
federationConfig.rc1.additionalUser1.password,
|
||||
);
|
||||
|
||||
hs1AdminApp = new SynapseClient(federationConfig.hs1.url, federationConfig.hs1.adminUser, federationConfig.hs1.adminPassword);
|
||||
await hs1AdminApp.initialize();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (hs1AdminApp) {
|
||||
await hs1AdminApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe('Ban from RC side', () => {
|
||||
let channelName: string;
|
||||
let federatedChannelId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
channelName = `fed-ban-rc-${Date.now()}`;
|
||||
|
||||
const createResponse = await createRoom({
|
||||
type: 'p',
|
||||
name: channelName,
|
||||
members: [federationConfig.hs1.adminMatrixUserId, federationConfig.rc1.additionalUser1.username],
|
||||
extraData: { federated: true },
|
||||
config: rc1AdminRequestConfig,
|
||||
});
|
||||
|
||||
federatedChannelId = createResponse.body.group._id;
|
||||
|
||||
// Accept invitation on Synapse side
|
||||
await hs1AdminApp.acceptInvitationForRoomName(channelName);
|
||||
|
||||
// Accept invitation for the local RC user
|
||||
await acceptRoomInvite(federatedChannelId, rc1User1RequestConfig);
|
||||
|
||||
// Wait for Synapse to see the RC user
|
||||
await retry(
|
||||
'wait for RC user on Synapse',
|
||||
async () => {
|
||||
const member = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.additionalUser1.matrixUserId);
|
||||
expect(member).not.toBeNull();
|
||||
expect(member!.membership).toBe('join');
|
||||
},
|
||||
{ retries: 10, delayMs: 2000 },
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
it('should ban the RC user via rooms.banUser', async () => {
|
||||
const response = await rc1AdminRequestConfig.request.post(api('rooms.banUser')).set(rc1AdminRequestConfig.credentials).send({
|
||||
roomId: federatedChannelId,
|
||||
userId: rc1User1RequestConfig.credentials['X-User-Id'],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('success', true);
|
||||
});
|
||||
|
||||
it('should list the banned user via rooms.bannedUsers', async () => {
|
||||
const response = await rc1AdminRequestConfig.request
|
||||
.get(api('rooms.bannedUsers'))
|
||||
.set(rc1AdminRequestConfig.credentials)
|
||||
.query({ roomId: federatedChannelId });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('success', true);
|
||||
expect(response.body.bannedUsers).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ _id: rc1User1RequestConfig.credentials['X-User-Id'] })]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reflect ban on Synapse side', async () => {
|
||||
await retry(
|
||||
'wait for ban on Synapse',
|
||||
async () => {
|
||||
const member = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.additionalUser1.matrixUserId);
|
||||
expect(member).not.toBeNull();
|
||||
expect(member!.membership).toBe('ban');
|
||||
},
|
||||
{ retries: 10, delayMs: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should not list banned user in room members', async () => {
|
||||
const membersResponse = await getRoomMembers(federatedChannelId, rc1AdminRequestConfig);
|
||||
const banned = membersResponse.members.find((m: IUser) => m._id === rc1User1RequestConfig.credentials['X-User-Id']);
|
||||
expect(banned).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should unban the user via rooms.unbanUser', async () => {
|
||||
const response = await rc1AdminRequestConfig.request.post(api('rooms.unbanUser')).set(rc1AdminRequestConfig.credentials).send({
|
||||
roomId: federatedChannelId,
|
||||
userId: rc1User1RequestConfig.credentials['X-User-Id'],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('success', true);
|
||||
});
|
||||
|
||||
it('should no longer list the user as banned', async () => {
|
||||
const response = await rc1AdminRequestConfig.request
|
||||
.get(api('rooms.bannedUsers'))
|
||||
.set(rc1AdminRequestConfig.credentials)
|
||||
.query({ roomId: federatedChannelId });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const bannedIds = (response.body.bannedUsers || []).map((u: any) => u._id);
|
||||
expect(bannedIds).not.toContain(rc1User1RequestConfig.credentials['X-User-Id']);
|
||||
});
|
||||
|
||||
it('should reflect unban on Synapse side', async () => {
|
||||
await retry(
|
||||
'wait for unban on Synapse',
|
||||
async () => {
|
||||
const member = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.additionalUser1.matrixUserId);
|
||||
expect(member).not.toBeNull();
|
||||
expect(member!.membership).not.toBe('ban');
|
||||
},
|
||||
{ retries: 10, delayMs: 2000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ban and unban invited user (invite status preservation)', () => {
|
||||
let channelName: string;
|
||||
let federatedChannelId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
channelName = `fed-ban-invite-${Date.now()}`;
|
||||
|
||||
const createResponse = await createRoom({
|
||||
type: 'p',
|
||||
name: channelName,
|
||||
members: [federationConfig.hs1.adminMatrixUserId, federationConfig.rc1.additionalUser1.username],
|
||||
extraData: { federated: true },
|
||||
config: rc1AdminRequestConfig,
|
||||
});
|
||||
|
||||
federatedChannelId = createResponse.body.group._id;
|
||||
|
||||
// Accept invitation on Synapse side only — RC user stays as INVITED
|
||||
await hs1AdminApp.acceptInvitationForRoomName(channelName);
|
||||
}, 30000);
|
||||
|
||||
it('should ban the invited user before they accept', async () => {
|
||||
const response = await rc1AdminRequestConfig.request.post(api('rooms.banUser')).set(rc1AdminRequestConfig.credentials).send({
|
||||
roomId: federatedChannelId,
|
||||
userId: rc1User1RequestConfig.credentials['X-User-Id'],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('success', true);
|
||||
});
|
||||
|
||||
it('should have the user listed as banned', async () => {
|
||||
const response = await rc1AdminRequestConfig.request
|
||||
.get(api('rooms.bannedUsers'))
|
||||
.set(rc1AdminRequestConfig.credentials)
|
||||
.query({ roomId: federatedChannelId });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.bannedUsers).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ _id: rc1User1RequestConfig.credentials['X-User-Id'] })]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should unban the user (removing the subscription entirely)', async () => {
|
||||
const response = await rc1AdminRequestConfig.request.post(api('rooms.unbanUser')).set(rc1AdminRequestConfig.credentials).send({
|
||||
roomId: federatedChannelId,
|
||||
userId: rc1User1RequestConfig.credentials['X-User-Id'],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('success', true);
|
||||
});
|
||||
|
||||
it('should have no subscription after unban (user must be re-invited)', async () => {
|
||||
const response = await rc1User1RequestConfig.request
|
||||
.get(api('subscriptions.getOne'))
|
||||
.set(rc1User1RequestConfig.credentials)
|
||||
.query({ roomId: federatedChannelId });
|
||||
|
||||
expect(response.body.subscription).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ban from Synapse side', () => {
|
||||
let channelName: string;
|
||||
let federatedChannelId: string;
|
||||
let synapseRoomId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
channelName = `fed-ban-synapse-${Date.now()}`;
|
||||
synapseRoomId = await hs1AdminApp.createRoom(channelName, Visibility.Private);
|
||||
|
||||
await hs1AdminApp.inviteUserToRoom(synapseRoomId, federationConfig.rc1.additionalUser1.matrixUserId);
|
||||
await hs1AdminApp.inviteUserToRoom(synapseRoomId, federationConfig.rc1.adminMatrixUserId);
|
||||
|
||||
const roomsResponse = await rc1AdminRequestConfig.request.get(api('rooms.get')).set(rc1AdminRequestConfig.credentials).expect(200);
|
||||
|
||||
expect(roomsResponse.body).toHaveProperty('success', true);
|
||||
expect(roomsResponse.body).toHaveProperty('update');
|
||||
|
||||
const rcRoom = roomsResponse.body.update.find(
|
||||
(room: IRoomNativeFederated) => room.federation?.mrid === synapseRoomId,
|
||||
) as IRoomNativeFederated | null;
|
||||
|
||||
expect(rcRoom).not.toBeNull();
|
||||
|
||||
federatedChannelId = rcRoom!._id;
|
||||
|
||||
// Accept invitation for the local RC user
|
||||
await acceptRoomInvite(federatedChannelId, rc1User1RequestConfig);
|
||||
await acceptRoomInvite(federatedChannelId, rc1AdminRequestConfig);
|
||||
|
||||
// Wait for Synapse to see the RC user
|
||||
await retry(
|
||||
'wait for RC user on Synapse',
|
||||
async () => {
|
||||
const member = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.additionalUser1.matrixUserId);
|
||||
expect(member).not.toBeNull();
|
||||
expect(member!.membership).toBe('join');
|
||||
},
|
||||
{ retries: 10, delayMs: 2000 },
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
it('should ban the RC user from Synapse', async () => {
|
||||
await hs1AdminApp.banUser(synapseRoomId, federationConfig.rc1.additionalUser1.matrixUserId, 'federation ban test');
|
||||
});
|
||||
|
||||
it('should reflect ban on RC side', async () => {
|
||||
await retry(
|
||||
'wait for ban to propagate to RC',
|
||||
async () => {
|
||||
const response = await rc1AdminRequestConfig.request
|
||||
.get(api('rooms.bannedUsers'))
|
||||
.set(rc1AdminRequestConfig.credentials)
|
||||
.query({ roomId: federatedChannelId });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.bannedUsers).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ _id: rc1User1RequestConfig.credentials['X-User-Id'] })]),
|
||||
);
|
||||
},
|
||||
{ retries: 10, delayMs: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should not list banned user in room members on RC side', async () => {
|
||||
const membersResponse = await getRoomMembers(federatedChannelId, rc1AdminRequestConfig);
|
||||
const banned = membersResponse.members.find((m: IUser) => m._id === rc1User1RequestConfig.credentials['X-User-Id']);
|
||||
expect(banned).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should unban the RC user from Synapse', async () => {
|
||||
await hs1AdminApp.unbanUser(synapseRoomId, federationConfig.rc1.additionalUser1.matrixUserId);
|
||||
});
|
||||
|
||||
it('should reflect unban on RC side', async () => {
|
||||
await retry(
|
||||
'wait for unban to propagate to RC',
|
||||
async () => {
|
||||
const response = await rc1AdminRequestConfig.request
|
||||
.get(api('rooms.bannedUsers'))
|
||||
.set(rc1AdminRequestConfig.credentials)
|
||||
.query({ roomId: federatedChannelId });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const bannedIds = (response.body.bannedUsers || []).map((u: any) => u._id);
|
||||
expect(bannedIds).not.toContain(rc1User1RequestConfig.credentials['X-User-Id']);
|
||||
},
|
||||
{ retries: 10, delayMs: 2000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ban from Synapse, re-invite and messaging after unban', () => {
|
||||
let channelName: string;
|
||||
let federatedChannelId: string;
|
||||
let synapseRoomId: string;
|
||||
|
||||
it('should create room on Synapse and have RC user accept invite', async () => {
|
||||
channelName = `fed-ban-reinvite-${Date.now()}`;
|
||||
synapseRoomId = await hs1AdminApp.createRoom(channelName, Visibility.Private);
|
||||
|
||||
await hs1AdminApp.inviteUserToRoom(synapseRoomId, federationConfig.rc1.additionalUser1.matrixUserId);
|
||||
|
||||
// Wait for the invite to reach RC and find the room
|
||||
await retry(
|
||||
'wait for invite to reach RC',
|
||||
async () => {
|
||||
const roomsResponse = await rc1User1RequestConfig.request
|
||||
.get(api('rooms.get'))
|
||||
.set(rc1User1RequestConfig.credentials)
|
||||
.expect(200);
|
||||
|
||||
const rcRoom = roomsResponse.body.update.find(
|
||||
(room: IRoomNativeFederated) => room.federation?.mrid === synapseRoomId,
|
||||
) as IRoomNativeFederated | null;
|
||||
|
||||
expect(rcRoom).not.toBeNull();
|
||||
federatedChannelId = rcRoom!._id;
|
||||
},
|
||||
{ retries: 5, delayMs: 1000 },
|
||||
);
|
||||
|
||||
await acceptRoomInvite(federatedChannelId, rc1User1RequestConfig);
|
||||
|
||||
// Wait for Synapse to see the RC user as joined
|
||||
await retry(
|
||||
'wait for RC user join on Synapse',
|
||||
async () => {
|
||||
const member = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.additionalUser1.matrixUserId);
|
||||
expect(member).not.toBeNull();
|
||||
expect(member!.membership).toBe('join');
|
||||
},
|
||||
{ retries: 5, delayMs: 1000 },
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
it('should ban the RC user from Synapse', async () => {
|
||||
await hs1AdminApp.banUser(synapseRoomId, federationConfig.rc1.additionalUser1.matrixUserId, 'ban before re-invite test');
|
||||
|
||||
// Wait for ban to propagate to RC — the banned user should no longer see the room
|
||||
await retry(
|
||||
'wait for ban on RC',
|
||||
async () => {
|
||||
const roomsResponse = await rc1User1RequestConfig.request
|
||||
.get(api('rooms.get'))
|
||||
.set(rc1User1RequestConfig.credentials)
|
||||
.expect(200);
|
||||
|
||||
const rcRoom = roomsResponse.body.update.find((room: IRoomNativeFederated) => room.federation?.mrid === synapseRoomId);
|
||||
|
||||
expect(rcRoom).toBeUndefined();
|
||||
},
|
||||
{ retries: 5, delayMs: 1000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should send a message from Synapse while RC user is banned', async () => {
|
||||
const messageText = `message-while-banned-${Date.now()}`;
|
||||
await hs1AdminApp.sendTextMessage(channelName, messageText);
|
||||
|
||||
const synapseMessage = await hs1AdminApp.findMessageInRoom(channelName, messageText);
|
||||
expect(synapseMessage?.content.body).toBe(messageText);
|
||||
});
|
||||
|
||||
it('should unban the RC user from Synapse', async () => {
|
||||
await hs1AdminApp.unbanUser(synapseRoomId, federationConfig.rc1.additionalUser1.matrixUserId);
|
||||
|
||||
// Verify unban on Synapse side — membership should no longer be 'ban'
|
||||
await retry(
|
||||
'wait for unban on Synapse',
|
||||
async () => {
|
||||
const member = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.additionalUser1.matrixUserId);
|
||||
expect(member).not.toBeNull();
|
||||
expect(member!.membership).not.toBe('ban');
|
||||
},
|
||||
{ retries: 5, delayMs: 1000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should re-invite the RC user from Synapse and have them accept', async () => {
|
||||
await hs1AdminApp.inviteUserToRoom(synapseRoomId, federationConfig.rc1.additionalUser1.matrixUserId);
|
||||
|
||||
// Wait for the re-invite to reach RC
|
||||
await retry(
|
||||
'wait for re-invite to reach RC',
|
||||
async () => {
|
||||
const response = await rc1User1RequestConfig.request
|
||||
.get(api('subscriptions.getOne'))
|
||||
.set(rc1User1RequestConfig.credentials)
|
||||
.query({ roomId: federatedChannelId });
|
||||
|
||||
expect(response.body.subscription).not.toBeNull();
|
||||
},
|
||||
{ retries: 5, delayMs: 1000 },
|
||||
);
|
||||
|
||||
await acceptRoomInvite(federatedChannelId, rc1User1RequestConfig);
|
||||
|
||||
// Wait for Synapse to see the RC user as joined again
|
||||
await retry(
|
||||
'wait for RC user re-join on Synapse',
|
||||
async () => {
|
||||
const member = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.additionalUser1.matrixUserId);
|
||||
expect(member).not.toBeNull();
|
||||
expect(member!.membership).toBe('join');
|
||||
},
|
||||
{ retries: 5, delayMs: 1000 },
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
it('should allow the RC user to send a message after re-joining', async () => {
|
||||
const messageText = `message-after-rejoin-${Date.now()}`;
|
||||
|
||||
const sendResponse = await sendMessage({ rid: federatedChannelId, msg: messageText, config: rc1User1RequestConfig });
|
||||
expect(sendResponse.body).toHaveProperty('success', true);
|
||||
|
||||
// Validate message is received on Synapse
|
||||
const synapseMessage = await hs1AdminApp.findMessageInRoom(channelName, messageText);
|
||||
expect(synapseMessage).not.toBeNull();
|
||||
expect(synapseMessage?.content.body).toBe(messageText);
|
||||
});
|
||||
});
|
||||
});
|
||||
1957
ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts
Normal file
1957
ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
||||
import type { IRoomNativeFederated, IUser } from '@rocket.chat/core-typings';
|
||||
import { Visibility } from 'matrix-js-sdk';
|
||||
|
||||
import { api } from '../../../../../apps/meteor/tests/data/api-data';
|
||||
import { acceptRoomInvite } from '../../../../../apps/meteor/tests/data/rooms.helper';
|
||||
import {
|
||||
type IRequestConfig,
|
||||
createUser,
|
||||
deleteUser,
|
||||
getRequestConfig,
|
||||
getUserByUsername,
|
||||
} from '../../../../../apps/meteor/tests/data/users.helper';
|
||||
import { IS_EE } from '../../../../../apps/meteor/tests/e2e/config/constants';
|
||||
import { retry } from '../../../../../apps/meteor/tests/end-to-end/api/helpers/retry';
|
||||
import { federationConfig } from '../helper/config';
|
||||
import { SynapseClient } from '../helper/synapse-client';
|
||||
|
||||
const remoteUser = federationConfig.hs1.firstContactUser;
|
||||
const localUser = federationConfig.rc1.firstContactUser;
|
||||
|
||||
/**
|
||||
* Every other federation spec reuses Synapse users that earlier specs have already pulled into
|
||||
* the local database, so the membership handlers always find a complete user document and the
|
||||
* creation path is never observed. This spec reserves a Synapse user nobody else touches, removes
|
||||
* the local document if a previous run left one behind, and only then lets that user reach in.
|
||||
*
|
||||
* The user is therefore created by the inbound invite itself, which is the one code path whose
|
||||
* result is consumed rather than discarded.
|
||||
*/
|
||||
(IS_EE ? describe : describe.skip)('Federation first contact', () => {
|
||||
let rc1AdminRequestConfig: IRequestConfig;
|
||||
let rc1InviteeRequestConfig: IRequestConfig;
|
||||
let hs1FirstContactApp: SynapseClient;
|
||||
|
||||
const forgetRemoteUserLocally = async () => {
|
||||
const known = await getUserByUsername(remoteUser.matrixUserId, rc1AdminRequestConfig);
|
||||
if (known?._id) {
|
||||
await deleteUser({ _id: known._id }, { confirmRelinquish: true }, rc1AdminRequestConfig);
|
||||
}
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
rc1AdminRequestConfig = await getRequestConfig(
|
||||
federationConfig.rc1.url,
|
||||
federationConfig.rc1.adminUser,
|
||||
federationConfig.rc1.adminPassword,
|
||||
);
|
||||
|
||||
const existingLocalUser = await getUserByUsername(localUser.username, rc1AdminRequestConfig);
|
||||
if (!existingLocalUser?._id) {
|
||||
await createUser(
|
||||
{
|
||||
username: localUser.username,
|
||||
password: localUser.password,
|
||||
email: `${localUser.username}@rocket.chat`,
|
||||
name: localUser.username,
|
||||
},
|
||||
rc1AdminRequestConfig,
|
||||
);
|
||||
}
|
||||
|
||||
rc1InviteeRequestConfig = await getRequestConfig(federationConfig.rc1.url, localUser.username, localUser.password);
|
||||
|
||||
// a previous run leaves the user behind, which would make this spec exercise the warm path instead
|
||||
await forgetRemoteUserLocally();
|
||||
|
||||
hs1FirstContactApp = new SynapseClient(federationConfig.hs1.url, remoteUser.username, remoteUser.password);
|
||||
await hs1FirstContactApp.initialize();
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
// leave the environment cold so a rerun against persistent containers still tests first contact
|
||||
await forgetRemoteUserLocally();
|
||||
await hs1FirstContactApp?.close();
|
||||
});
|
||||
|
||||
it('should not know the remote user before the first interaction', async () => {
|
||||
const response = await rc1AdminRequestConfig.request
|
||||
.get(api('users.info'))
|
||||
.set(rc1AdminRequestConfig.credentials)
|
||||
.query({ username: remoteUser.matrixUserId });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('success', false);
|
||||
});
|
||||
|
||||
describe('when the first interaction is an invite from Synapse', () => {
|
||||
let channelName: string;
|
||||
let synapseRoomId: string;
|
||||
let federatedRoomId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
channelName = `fed-first-contact-${Date.now()}`;
|
||||
synapseRoomId = await hs1FirstContactApp.createRoom(channelName, Visibility.Private);
|
||||
|
||||
await hs1FirstContactApp.inviteUserToRoom(synapseRoomId, localUser.matrixUserId);
|
||||
}, 60000);
|
||||
|
||||
it('should create the room on the Rocket.Chat side', async () => {
|
||||
await retry(
|
||||
'waiting for the federated room to reach RC',
|
||||
async () => {
|
||||
// rooms.get is scoped to the caller's subscriptions, and only the invitee is in this room
|
||||
const response = await rc1InviteeRequestConfig.request.get(api('rooms.get')).set(rc1InviteeRequestConfig.credentials).expect(200);
|
||||
|
||||
const rcRoom = response.body.update.find(
|
||||
(room: IRoomNativeFederated) => room.federation?.mrid === synapseRoomId,
|
||||
) as IRoomNativeFederated | null;
|
||||
|
||||
expect(rcRoom).toBeTruthy();
|
||||
federatedRoomId = rcRoom!._id;
|
||||
},
|
||||
{ retries: 10, delayMs: 2000 },
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
// this covers the persisted document only. The shape of the object the invite handler reads back
|
||||
// is not observable from here — it belongs to the createOrUpdateFederatedUser and
|
||||
// getOrCreateFederatedUser unit tests, and when it regresses the room and subscription
|
||||
// assertions below are what fail.
|
||||
it('should persist the remote user locally as a complete document', async () => {
|
||||
const response = await rc1AdminRequestConfig.request
|
||||
.get(api('users.info'))
|
||||
.set(rc1AdminRequestConfig.credentials)
|
||||
.query({ username: remoteUser.matrixUserId })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.user).toMatchObject({
|
||||
username: remoteUser.matrixUserId,
|
||||
name: remoteUser.matrixUserId,
|
||||
type: 'user',
|
||||
active: true,
|
||||
federated: true,
|
||||
roles: expect.arrayContaining(['federated-external']),
|
||||
});
|
||||
expect(response.body.user.createdAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should create the invited subscription for the local user', async () => {
|
||||
const response = await rc1InviteeRequestConfig.request
|
||||
.get(api('subscriptions.getOne'))
|
||||
.set(rc1InviteeRequestConfig.credentials)
|
||||
.query({ roomId: federatedRoomId })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.subscription).toBeTruthy();
|
||||
expect(response.body.subscription).toHaveProperty('status', 'INVITED');
|
||||
});
|
||||
|
||||
it('should let the local user accept the invite', async () => {
|
||||
const response = await acceptRoomInvite(federatedRoomId, rc1InviteeRequestConfig);
|
||||
|
||||
expect(response).toHaveProperty('success', true);
|
||||
});
|
||||
|
||||
it('should reach the local user on the Synapse side', async () => {
|
||||
await retry(
|
||||
'waiting for the local user to appear as joined on Synapse',
|
||||
async () => {
|
||||
const member = await hs1FirstContactApp.findRoomMember(channelName, localUser.matrixUserId);
|
||||
|
||||
expect(member).not.toBeNull();
|
||||
expect(member!.membership).toBe('join');
|
||||
},
|
||||
{ retries: 10, delayMs: 2000 },
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
// a named, invite-only Synapse room becomes a private group on RC, so groups.members applies.
|
||||
// it gates on canAccessRoomAsync without includeInvitations, meaning the accepted subscription
|
||||
// has to have settled first — hence the retry and the ordering after the Synapse round-trip
|
||||
it('should list the remote user among the room members', async () => {
|
||||
await retry(
|
||||
'waiting for the room members to include the remote user',
|
||||
async () => {
|
||||
const response = await rc1InviteeRequestConfig.request
|
||||
.get(api('groups.members'))
|
||||
.set(rc1InviteeRequestConfig.credentials)
|
||||
.query({ roomId: federatedRoomId })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.members).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ username: remoteUser.matrixUserId } as Partial<IUser>)]),
|
||||
);
|
||||
},
|
||||
{ retries: 5, delayMs: 2000 },
|
||||
);
|
||||
}, 30000);
|
||||
});
|
||||
});
|
||||
1387
ee/packages/federation-matrix/tests/end-to-end/messaging.spec.ts
Normal file
1387
ee/packages/federation-matrix/tests/end-to-end/messaging.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,890 @@
|
||||
import type { IRoomNativeFederated, IUser } from '@rocket.chat/core-typings';
|
||||
|
||||
import type {} from '../../../../../apps/meteor/app/api/server/v1/permissions.ts';
|
||||
import { api } from '../../../../../apps/meteor/tests/data/api-data';
|
||||
import {
|
||||
addUserToRoomViaMethod,
|
||||
createRoom,
|
||||
getSubscriptionByRoomId,
|
||||
getSubscriptions,
|
||||
} from '../../../../../apps/meteor/tests/data/rooms.helper';
|
||||
import { createUser, deleteUser, getRequestConfig } from '../../../../../apps/meteor/tests/data/users.helper';
|
||||
import type { IRequestConfig, TestUser } from '../../../../../apps/meteor/tests/data/users.helper';
|
||||
import { IS_EE } from '../../../../../apps/meteor/tests/e2e/config/constants';
|
||||
import { retry } from '../../../../../apps/meteor/tests/end-to-end/api/helpers/retry.ts';
|
||||
import { federationConfig } from '../helper/config';
|
||||
import { SynapseClient } from '../helper/synapse-client';
|
||||
|
||||
(IS_EE ? describe : describe.skip)('Federation Permissions', () => {
|
||||
let rc1AdminRequestConfig: IRequestConfig;
|
||||
let rc1User1RequestConfig: IRequestConfig;
|
||||
let hs1AdminApp: SynapseClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create admin request config for RC1
|
||||
rc1AdminRequestConfig = await getRequestConfig(
|
||||
federationConfig.rc1.url,
|
||||
federationConfig.rc1.adminUser,
|
||||
federationConfig.rc1.adminPassword,
|
||||
);
|
||||
|
||||
// Create user1 request config for RC1
|
||||
rc1User1RequestConfig = await getRequestConfig(
|
||||
federationConfig.rc1.url,
|
||||
federationConfig.rc1.additionalUser1.username,
|
||||
federationConfig.rc1.additionalUser1.password,
|
||||
);
|
||||
|
||||
// Create admin Synapse client for HS1
|
||||
hs1AdminApp = new SynapseClient(federationConfig.hs1.url, federationConfig.hs1.adminUser, federationConfig.hs1.adminPassword);
|
||||
await hs1AdminApp.initialize();
|
||||
await rc1AdminRequestConfig.request
|
||||
.post(api('permissions.update'))
|
||||
.set(rc1AdminRequestConfig.credentials)
|
||||
.send({ permissions: [{ _id: 'access-federation', roles: ['admin'] }] })
|
||||
.expect('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
afterAll(async () =>
|
||||
// Add permissions for access-federation to any user but admin
|
||||
rc1AdminRequestConfig.request
|
||||
.post(api('permissions.update'))
|
||||
.set(rc1AdminRequestConfig.credentials)
|
||||
.send({ permissions: [{ _id: 'access-federation', roles: ['admin', 'user'] }] })
|
||||
.expect('Content-Type', 'application/json')
|
||||
.expect(200),
|
||||
);
|
||||
|
||||
afterAll(async () => hs1AdminApp.close());
|
||||
|
||||
describe('Access Federation Permission', () => {
|
||||
describe('Users without access-federation permission', () => {
|
||||
beforeAll(async () => {
|
||||
await rc1AdminRequestConfig.request
|
||||
.post(api('permissions.update'))
|
||||
.set(rc1AdminRequestConfig.credentials)
|
||||
.send({ permissions: [{ _id: 'access-federation', roles: ['admin'] }] })
|
||||
.expect('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Add permissions for access-federation to any user but admin
|
||||
await rc1AdminRequestConfig.request
|
||||
.post(api('permissions.update'))
|
||||
.set(rc1AdminRequestConfig.credentials)
|
||||
.send({ permissions: [{ _id: 'access-federation', roles: ['admin', 'user'] }] })
|
||||
.expect('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
describe('Inviting from a remote server', () => {
|
||||
let user: TestUser<IUser>;
|
||||
|
||||
let matrixRoomId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
user = await createUser(
|
||||
{
|
||||
username: `g3-${Date.now()}`,
|
||||
password: '1',
|
||||
roles: ['user'],
|
||||
},
|
||||
rc1AdminRequestConfig,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUser(user, {}, rc1AdminRequestConfig);
|
||||
});
|
||||
|
||||
let channelName: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
channelName = `federated-room-${Date.now()}`;
|
||||
matrixRoomId = await hs1AdminApp.createRoom(channelName);
|
||||
});
|
||||
|
||||
it('should throw an error if a remote user tries to invite a user without access-federation permission to a room', async () => {
|
||||
await expect(hs1AdminApp.matrixClient.invite(matrixRoomId, `@${user.username}:${federationConfig.rc1.domain}`)).rejects.toThrow();
|
||||
const subscriptions = await getSubscriptions(rc1AdminRequestConfig);
|
||||
const invitedSub = subscriptions.update.find((sub) => sub.fname?.includes(channelName));
|
||||
expect(invitedSub).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should be able to invite a user to a room if the user has access-federation permission', async () => {
|
||||
await expect(hs1AdminApp.matrixClient.invite(matrixRoomId, federationConfig.rc1.adminMatrixUserId)).resolves.not.toThrow();
|
||||
|
||||
await retry('waiting for invitation to be processed', async () => {
|
||||
const subscriptions = await getSubscriptions(rc1AdminRequestConfig);
|
||||
|
||||
const pendingInvitation = subscriptions.update.find(
|
||||
(subscription) => subscription.status === 'INVITED' && subscription.fname?.includes(channelName),
|
||||
);
|
||||
expect(pendingInvitation).not.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if a user without access-federation permission tries to create a federated room', async () => {
|
||||
const channelName = `federated-room-${Date.now()}`;
|
||||
const createResponse = await createRoom({
|
||||
type: 'p',
|
||||
name: channelName,
|
||||
members: [],
|
||||
extraData: {
|
||||
federated: true,
|
||||
},
|
||||
config: rc1User1RequestConfig,
|
||||
});
|
||||
|
||||
expect(createResponse.status).toBe(400);
|
||||
expect(createResponse.body).toHaveProperty('success', false);
|
||||
expect(createResponse.body).toHaveProperty('errorType', 'error-not-authorized-federation');
|
||||
});
|
||||
|
||||
describe('Inviting from a local server', () => {
|
||||
let channelName: string;
|
||||
|
||||
let createResponse;
|
||||
let addUserResponse;
|
||||
|
||||
beforeAll(async () => {
|
||||
channelName = `federated-room-${Date.now()}`;
|
||||
createResponse = await createRoom({
|
||||
type: 'p',
|
||||
name: channelName,
|
||||
members: [],
|
||||
extraData: {
|
||||
federated: true,
|
||||
},
|
||||
config: rc1AdminRequestConfig,
|
||||
});
|
||||
expect(createResponse.status).toBe(200);
|
||||
expect(createResponse.body).toHaveProperty('success', true);
|
||||
expect(createResponse.body).toHaveProperty('group');
|
||||
expect(createResponse.body.group).toHaveProperty('_id');
|
||||
expect(createResponse.body.group).toHaveProperty('t', 'p');
|
||||
expect(createResponse.body.group).toHaveProperty('federated', true);
|
||||
});
|
||||
let user: TestUser<IUser>;
|
||||
|
||||
beforeAll(async () => {
|
||||
user = await createUser(
|
||||
{
|
||||
username: `g3-${Date.now()}`,
|
||||
password: '1',
|
||||
roles: ['user'],
|
||||
},
|
||||
rc1AdminRequestConfig,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUser(user, {}, rc1AdminRequestConfig);
|
||||
});
|
||||
it('should not be able to add a user without access-federation permission to a room', async () => {
|
||||
const addUserResponse = await addUserToRoomViaMethod({
|
||||
usernames: [user.username],
|
||||
rid: createResponse.body.group._id,
|
||||
config: rc1AdminRequestConfig,
|
||||
});
|
||||
|
||||
expect(addUserResponse.status).toBe(400);
|
||||
expect(addUserResponse.body).toHaveProperty('success', false);
|
||||
expect(addUserResponse.body.message).toMatch(/error-not-authorized-federation/);
|
||||
});
|
||||
|
||||
it("should be able to add a remote user to a room regardless of the user's access-federation permission defined locally", async () => {
|
||||
addUserResponse = await addUserToRoomViaMethod({
|
||||
usernames: [federationConfig.hs1.adminMatrixUserId],
|
||||
rid: createResponse.body.group._id,
|
||||
config: rc1AdminRequestConfig,
|
||||
});
|
||||
|
||||
expect(addUserResponse.status).toBe(200);
|
||||
expect(addUserResponse.body).toHaveProperty('success', true);
|
||||
expect(addUserResponse.body).toHaveProperty('message');
|
||||
expect(addUserResponse.body.message).toMatch('{"msg":"result","id":"id","result":true}');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Users with access-federation permission', () => {
|
||||
let user: TestUser<IUser>;
|
||||
|
||||
beforeAll(async () => {
|
||||
user = await createUser(
|
||||
{
|
||||
username: `g3-${Date.now()}`,
|
||||
password: '1',
|
||||
roles: ['user', 'admin'],
|
||||
},
|
||||
rc1AdminRequestConfig,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUser(user, {}, rc1AdminRequestConfig);
|
||||
});
|
||||
|
||||
it('should be able to create a federated room if the user has access-federation permission', async () => {
|
||||
const channelName = `federated-room-${Date.now()}`;
|
||||
const createResponse = await createRoom({
|
||||
type: 'p',
|
||||
name: channelName,
|
||||
members: [],
|
||||
extraData: {
|
||||
federated: true,
|
||||
},
|
||||
config: rc1AdminRequestConfig,
|
||||
});
|
||||
|
||||
expect(createResponse.status).toBe(200);
|
||||
expect(createResponse.body).toHaveProperty('success', true);
|
||||
expect(createResponse.body).toHaveProperty('group');
|
||||
expect(createResponse.body.group).toHaveProperty('_id');
|
||||
expect(createResponse.body.group).toHaveProperty('t', 'p');
|
||||
expect(createResponse.body.group).toHaveProperty('federated', true);
|
||||
});
|
||||
|
||||
describe('Add a user with access-federation permission to a room', () => {
|
||||
beforeAll(async () =>
|
||||
rc1AdminRequestConfig.request
|
||||
.post(api('permissions.update'))
|
||||
.set(rc1AdminRequestConfig.credentials)
|
||||
.send({ permissions: [{ _id: 'access-federation', roles: ['admin', 'user'] }] })
|
||||
.expect('Content-Type', 'application/json')
|
||||
.expect(200),
|
||||
);
|
||||
|
||||
it('should be able to add a user with access-federation permission to a room', async () => {
|
||||
const createResponse = await createRoom({
|
||||
type: 'p',
|
||||
name: `federated-room-${Date.now()}`,
|
||||
members: [],
|
||||
extraData: {
|
||||
federated: true,
|
||||
},
|
||||
config: rc1AdminRequestConfig,
|
||||
}).expect(200);
|
||||
|
||||
const addUserResponse = await addUserToRoomViaMethod({
|
||||
usernames: [user.username],
|
||||
rid: createResponse.body.group._id,
|
||||
config: rc1AdminRequestConfig,
|
||||
}).expect(200);
|
||||
|
||||
expect(addUserResponse.body).toHaveProperty('success', true);
|
||||
expect(addUserResponse.body).toHaveProperty('message');
|
||||
expect(addUserResponse.body.message).toMatch('{"msg":"result","id":"id","result":true}');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Federation_Service_Validate_User_Domain Setting', () => {
|
||||
const rcValidUser1 = {
|
||||
username: `valid-user1-${Date.now()}`,
|
||||
fullName: `Valid User1 ${Date.now()}`,
|
||||
get matrixId() {
|
||||
return `@${this.username}:${federationConfig.rc1.domain}`;
|
||||
},
|
||||
config: {} as IRequestConfig,
|
||||
user: {} as TestUser<IUser>,
|
||||
};
|
||||
|
||||
const rcValidUser2 = {
|
||||
username: `valid-user2-${Date.now()}`,
|
||||
fullName: `Valid User2 ${Date.now()}`,
|
||||
get matrixId() {
|
||||
return `@${this.username}:${federationConfig.rc1.domain}`;
|
||||
},
|
||||
config: {} as IRequestConfig,
|
||||
user: {} as TestUser<IUser>,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
// Ensure access-federation is granted to all users
|
||||
await rc1AdminRequestConfig.request
|
||||
.post(api('permissions.update'))
|
||||
.set(rc1AdminRequestConfig.credentials)
|
||||
.send({ permissions: [{ _id: 'access-federation', roles: ['admin', 'user'] }] })
|
||||
.expect('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
|
||||
// Create RC users
|
||||
rcValidUser1.user = await createUser(
|
||||
{
|
||||
username: rcValidUser1.username,
|
||||
password: 'random',
|
||||
email: `${rcValidUser1.username}@${federationConfig.rc1.domain}`,
|
||||
name: rcValidUser1.fullName,
|
||||
},
|
||||
rc1AdminRequestConfig,
|
||||
);
|
||||
|
||||
await rc1AdminRequestConfig.request
|
||||
.post(api('users.update'))
|
||||
.set(rc1AdminRequestConfig.credentials)
|
||||
.send({
|
||||
userId: rcValidUser1.user._id,
|
||||
data: {
|
||||
verified: true,
|
||||
},
|
||||
})
|
||||
.expect('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
|
||||
rcValidUser1.config = await getRequestConfig(federationConfig.rc1.url, rcValidUser1.username, 'random');
|
||||
|
||||
rcValidUser2.user = await createUser(
|
||||
{
|
||||
username: rcValidUser2.username,
|
||||
password: 'random',
|
||||
email: `${rcValidUser2.username}@${federationConfig.rc1.domain}`,
|
||||
name: rcValidUser2.fullName,
|
||||
},
|
||||
rc1AdminRequestConfig,
|
||||
);
|
||||
|
||||
await rc1AdminRequestConfig.request
|
||||
.post(api('users.update'))
|
||||
.set(rc1AdminRequestConfig.credentials)
|
||||
.send({
|
||||
userId: rcValidUser2.user._id,
|
||||
data: {
|
||||
verified: true,
|
||||
},
|
||||
})
|
||||
.expect('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
|
||||
rcValidUser2.config = await getRequestConfig(federationConfig.rc1.url, rcValidUser2.username, 'random');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all([
|
||||
deleteUser(rcValidUser1.user, {}, rc1AdminRequestConfig),
|
||||
deleteUser(rcValidUser2.user, {}, rc1AdminRequestConfig),
|
||||
]);
|
||||
});
|
||||
|
||||
describe('When setting is enabled', () => {
|
||||
beforeAll(async () => {
|
||||
// Enable email domain validation
|
||||
await rc1AdminRequestConfig.request
|
||||
.post(api('settings/Federation_Service_Validate_User_Domain'))
|
||||
.set(rc1AdminRequestConfig.credentials)
|
||||
.send({ value: true })
|
||||
.expect('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Disable email domain validation
|
||||
await rc1AdminRequestConfig.request
|
||||
.post(api('settings/Federation_Service_Validate_User_Domain'))
|
||||
.set(rc1AdminRequestConfig.credentials)
|
||||
.send({ value: false })
|
||||
.expect('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
describe('User with verified email matching federation domain', () => {
|
||||
it('should be able to create a federated room', async () => {
|
||||
const channelName = `federated-room-${Date.now()}`;
|
||||
const createResponse = await createRoom({
|
||||
type: 'p',
|
||||
name: channelName,
|
||||
members: [],
|
||||
extraData: {
|
||||
federated: true,
|
||||
},
|
||||
config: rcValidUser1.config,
|
||||
});
|
||||
|
||||
expect(createResponse.status).toBe(200);
|
||||
expect(createResponse.body).toHaveProperty('success', true);
|
||||
expect(createResponse.body).toHaveProperty('group');
|
||||
expect(createResponse.body.group).toHaveProperty('federated', true);
|
||||
});
|
||||
|
||||
it('should be able to be added to a federated room', async () => {
|
||||
const channelName = `federated-room-${Date.now()}`;
|
||||
const createResponse = await createRoom({
|
||||
type: 'p',
|
||||
name: channelName,
|
||||
members: [],
|
||||
extraData: {
|
||||
federated: true,
|
||||
},
|
||||
config: rcValidUser1.config,
|
||||
});
|
||||
|
||||
expect(createResponse.status).toBe(200);
|
||||
|
||||
const addUserResponse = await addUserToRoomViaMethod({
|
||||
usernames: [rcValidUser2.username],
|
||||
rid: createResponse.body.group._id,
|
||||
config: rcValidUser1.config,
|
||||
});
|
||||
|
||||
expect(addUserResponse.status).toBe(200);
|
||||
expect(addUserResponse.body).toHaveProperty('success', true);
|
||||
});
|
||||
|
||||
it('should be able to be added to a federated room during creation', async () => {
|
||||
const channelName = `federated-room-${Date.now()}`;
|
||||
const createResponse = await createRoom({
|
||||
type: 'p',
|
||||
name: channelName,
|
||||
members: [rcValidUser2.username],
|
||||
extraData: {
|
||||
federated: true,
|
||||
},
|
||||
config: rcValidUser1.config,
|
||||
});
|
||||
|
||||
expect(createResponse.status).toBe(200);
|
||||
expect(createResponse.body).toHaveProperty('success', true);
|
||||
expect(createResponse.body).toHaveProperty('group');
|
||||
expect(createResponse.body.group).toHaveProperty('federated', true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('User with verified email NOT matching federation domain', () => {
|
||||
let userWithNonMatchingEmail: TestUser<IUser>;
|
||||
let userRequestConfig: IRequestConfig;
|
||||
|
||||
beforeAll(async () => {
|
||||
userWithNonMatchingEmail = await createUser(
|
||||
{
|
||||
username: `user-nonmatching-${Date.now()}`,
|
||||
password: 'password123',
|
||||
email: `user-nonmatching-${Date.now()}@external-domain.com`,
|
||||
roles: ['user'],
|
||||
},
|
||||
rc1AdminRequestConfig,
|
||||
);
|
||||
|
||||
// Verify the user's email
|
||||
await rc1AdminRequestConfig.request
|
||||
.post(api('users.update'))
|
||||
.set(rc1AdminRequestConfig.credentials)
|
||||
.send({
|
||||
userId: userWithNonMatchingEmail._id,
|
||||
data: {
|
||||
verified: true,
|
||||
},
|
||||
})
|
||||
.expect('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
|
||||
userRequestConfig = await getRequestConfig(federationConfig.rc1.url, userWithNonMatchingEmail.username, 'password123');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUser(userWithNonMatchingEmail, {}, rc1AdminRequestConfig);
|
||||
});
|
||||
|
||||
it('should NOT be able to create a federated room', async () => {
|
||||
const channelName = `federated-room-${Date.now()}`;
|
||||
const createResponse = await createRoom({
|
||||
type: 'p',
|
||||
name: channelName,
|
||||
members: [],
|
||||
extraData: {
|
||||
federated: true,
|
||||
},
|
||||
config: userRequestConfig,
|
||||
});
|
||||
|
||||
expect(createResponse.status).toBe(400);
|
||||
expect(createResponse.body).toHaveProperty('success', false);
|
||||
expect(createResponse.body).toHaveProperty('errorType', 'error-not-authorized-federation');
|
||||
});
|
||||
|
||||
it('should NOT be able to be added to a federated room', async () => {
|
||||
const channelName = `federated-room-${Date.now()}`;
|
||||
const createResponse = await createRoom({
|
||||
type: 'p',
|
||||
name: channelName,
|
||||
members: [],
|
||||
extraData: {
|
||||
federated: true,
|
||||
},
|
||||
config: rcValidUser1.config,
|
||||
});
|
||||
|
||||
expect(createResponse.status).toBe(200);
|
||||
|
||||
const addUserResponse = await addUserToRoomViaMethod({
|
||||
usernames: [userWithNonMatchingEmail.username],
|
||||
rid: createResponse.body.group._id,
|
||||
config: rcValidUser1.config,
|
||||
});
|
||||
|
||||
expect(addUserResponse.status).toBe(400);
|
||||
expect(addUserResponse.body).toHaveProperty('success', false);
|
||||
expect(addUserResponse.body.message).toMatch(/error-not-authorized-federation/);
|
||||
});
|
||||
|
||||
it('should NOT be able to be added to a federated room during creation', async () => {
|
||||
const channelName = `federated-room-${Date.now()}`;
|
||||
const createResponse = await createRoom({
|
||||
type: 'p',
|
||||
name: channelName,
|
||||
members: [userWithNonMatchingEmail.username],
|
||||
extraData: {
|
||||
federated: true,
|
||||
},
|
||||
config: userRequestConfig,
|
||||
});
|
||||
|
||||
expect(createResponse.status).toBe(400);
|
||||
expect(createResponse.body).toHaveProperty('success', false);
|
||||
expect(createResponse.body).toHaveProperty('errorType', 'error-not-authorized-federation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('User with unverified email matching federation domain', () => {
|
||||
let userWithUnverifiedEmail: TestUser<IUser>;
|
||||
let userRequestConfig: IRequestConfig;
|
||||
|
||||
beforeAll(async () => {
|
||||
userWithUnverifiedEmail = await createUser(
|
||||
{
|
||||
username: `user-unverified-${Date.now()}`,
|
||||
password: 'password123',
|
||||
email: `user-unverified-${Date.now()}@${federationConfig.rc1.domain}`,
|
||||
roles: ['user'],
|
||||
},
|
||||
rc1AdminRequestConfig,
|
||||
);
|
||||
|
||||
userRequestConfig = await getRequestConfig(federationConfig.rc1.url, userWithUnverifiedEmail.username, 'password123');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUser(userWithUnverifiedEmail, {}, rc1AdminRequestConfig);
|
||||
});
|
||||
|
||||
it('should NOT be able to create a federated room', async () => {
|
||||
const channelName = `federated-room-${Date.now()}`;
|
||||
const createResponse = await createRoom({
|
||||
type: 'p',
|
||||
name: channelName,
|
||||
members: [],
|
||||
extraData: {
|
||||
federated: true,
|
||||
},
|
||||
config: userRequestConfig,
|
||||
});
|
||||
|
||||
expect(createResponse.status).toBe(400);
|
||||
expect(createResponse.body).toHaveProperty('success', false);
|
||||
expect(createResponse.body).toHaveProperty('errorType', 'error-not-authorized-federation');
|
||||
});
|
||||
|
||||
it('should NOT be able to be added to a federated room', async () => {
|
||||
const channelName = `federated-room-${Date.now()}`;
|
||||
const createResponse = await createRoom({
|
||||
type: 'p',
|
||||
name: channelName,
|
||||
members: [],
|
||||
extraData: {
|
||||
federated: true,
|
||||
},
|
||||
config: rcValidUser1.config,
|
||||
});
|
||||
|
||||
expect(createResponse.status).toBe(200);
|
||||
|
||||
const addUserResponse = await addUserToRoomViaMethod({
|
||||
usernames: [userWithUnverifiedEmail.username],
|
||||
rid: createResponse.body.group._id,
|
||||
config: rcValidUser1.config,
|
||||
});
|
||||
|
||||
expect(addUserResponse.status).toBe(400);
|
||||
expect(addUserResponse.body).toHaveProperty('success', false);
|
||||
expect(addUserResponse.body.message).toMatch(/error-not-authorized-federation/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('User without email', () => {
|
||||
let userWithoutEmail: TestUser<IUser>;
|
||||
let userRequestConfig: IRequestConfig;
|
||||
|
||||
beforeAll(async () => {
|
||||
userWithoutEmail = await createUser(
|
||||
{
|
||||
username: `user-noemail-${Date.now()}`,
|
||||
password: 'password123',
|
||||
roles: ['user'],
|
||||
},
|
||||
rc1AdminRequestConfig,
|
||||
);
|
||||
|
||||
userRequestConfig = await getRequestConfig(federationConfig.rc1.url, userWithoutEmail.username, 'password123');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUser(userWithoutEmail, {}, rc1AdminRequestConfig);
|
||||
});
|
||||
|
||||
it('should NOT be able to create a federated room', async () => {
|
||||
const channelName = `federated-room-${Date.now()}`;
|
||||
const createResponse = await createRoom({
|
||||
type: 'p',
|
||||
name: channelName,
|
||||
members: [],
|
||||
extraData: {
|
||||
federated: true,
|
||||
},
|
||||
config: userRequestConfig,
|
||||
});
|
||||
|
||||
expect(createResponse.status).toBe(400);
|
||||
expect(createResponse.body).toHaveProperty('success', false);
|
||||
expect(createResponse.body).toHaveProperty('errorType', 'error-not-authorized-federation');
|
||||
});
|
||||
|
||||
it('should NOT be able to be added to a federated room', async () => {
|
||||
const channelName = `federated-room-${Date.now()}`;
|
||||
const createResponse = await createRoom({
|
||||
type: 'p',
|
||||
name: channelName,
|
||||
members: [],
|
||||
extraData: {
|
||||
federated: true,
|
||||
},
|
||||
config: rcValidUser1.config,
|
||||
});
|
||||
|
||||
expect(createResponse.status).toBe(200);
|
||||
|
||||
const addUserResponse = await addUserToRoomViaMethod({
|
||||
usernames: [userWithoutEmail.username],
|
||||
rid: createResponse.body.group._id,
|
||||
config: rcValidUser1.config,
|
||||
});
|
||||
|
||||
expect(addUserResponse.status).toBe(400);
|
||||
expect(addUserResponse.body).toHaveProperty('success', false);
|
||||
expect(addUserResponse.body.message).toMatch(/error-not-authorized-federation/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Remote invitations with email domain validation', () => {
|
||||
let userWithMatchingEmail: TestUser<IUser>;
|
||||
let userWithNonMatchingEmail: TestUser<IUser>;
|
||||
let matrixRoomId: string;
|
||||
|
||||
let userWithMatchingEmailRequestConfig: IRequestConfig;
|
||||
let userWithNonMatchingEmailRequestConfig: IRequestConfig;
|
||||
|
||||
const channelName = `federated-room-${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
userWithMatchingEmail = await createUser(
|
||||
{
|
||||
username: `user-remote-matching-${Date.now()}`,
|
||||
password: 'password123',
|
||||
email: `user-remote-matching-${Date.now()}@${federationConfig.rc1.domain}`,
|
||||
roles: ['user'],
|
||||
},
|
||||
rc1AdminRequestConfig,
|
||||
);
|
||||
|
||||
userWithMatchingEmailRequestConfig = await getRequestConfig(
|
||||
federationConfig.rc1.url,
|
||||
userWithMatchingEmail.username,
|
||||
'password123',
|
||||
);
|
||||
|
||||
// Verify the user's email
|
||||
await rc1AdminRequestConfig.request
|
||||
.post(api('users.update'))
|
||||
.set(rc1AdminRequestConfig.credentials)
|
||||
.send({
|
||||
userId: userWithMatchingEmail._id,
|
||||
data: {
|
||||
verified: true,
|
||||
},
|
||||
})
|
||||
.expect('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
|
||||
userWithNonMatchingEmail = await createUser(
|
||||
{
|
||||
username: `user-remote-nonmatching-${Date.now()}`,
|
||||
password: 'password123',
|
||||
email: `user-remote-nonmatching-${Date.now()}@external-domain.com`,
|
||||
roles: ['user'],
|
||||
},
|
||||
rc1AdminRequestConfig,
|
||||
);
|
||||
|
||||
userWithNonMatchingEmailRequestConfig = await getRequestConfig(
|
||||
federationConfig.rc1.url,
|
||||
userWithNonMatchingEmail.username,
|
||||
'password123',
|
||||
);
|
||||
|
||||
// Verify the user's email
|
||||
await rc1AdminRequestConfig.request
|
||||
.post(api('users.update'))
|
||||
.set(rc1AdminRequestConfig.credentials)
|
||||
.send({
|
||||
userId: userWithNonMatchingEmail._id,
|
||||
data: {
|
||||
verified: true,
|
||||
},
|
||||
})
|
||||
.expect('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
|
||||
matrixRoomId = await hs1AdminApp.createRoom(channelName);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUser(userWithMatchingEmail, {}, rc1AdminRequestConfig);
|
||||
await deleteUser(userWithNonMatchingEmail, {}, rc1AdminRequestConfig);
|
||||
});
|
||||
|
||||
it('should accept invitation for user with verified email matching federation domain', async () => {
|
||||
await expect(
|
||||
hs1AdminApp.matrixClient.invite(matrixRoomId, `@${userWithMatchingEmail.username}:${federationConfig.rc1.domain}`),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
await retry('this is an async operation, so we need to wait for the room to be created in RC', async () => {
|
||||
const roomsResponse = await userWithMatchingEmailRequestConfig.request
|
||||
.get(api('rooms.get'))
|
||||
.set(userWithMatchingEmailRequestConfig.credentials)
|
||||
.expect(200);
|
||||
|
||||
expect(roomsResponse.body).toHaveProperty('success', true);
|
||||
expect(roomsResponse.body).toHaveProperty('update');
|
||||
|
||||
const rcRoomConverted = roomsResponse.body.update.find((room: IRoomNativeFederated) => room.federation.mrid === matrixRoomId);
|
||||
|
||||
expect(rcRoomConverted).toHaveProperty('_id');
|
||||
expect(rcRoomConverted).toHaveProperty('t', 'p');
|
||||
|
||||
const subA = await getSubscriptionByRoomId(
|
||||
rcRoomConverted._id,
|
||||
userWithMatchingEmailRequestConfig.credentials,
|
||||
userWithMatchingEmailRequestConfig.request,
|
||||
);
|
||||
|
||||
expect(subA).toHaveProperty('fname', `${channelName}:${federationConfig.hs1.domain}`);
|
||||
expect(subA).toHaveProperty('status', 'INVITED');
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject invitation for user with verified email NOT matching federation domain', async () => {
|
||||
await expect(
|
||||
hs1AdminApp.matrixClient.invite(matrixRoomId, `@${userWithNonMatchingEmail.username}:${federationConfig.rc1.domain}`),
|
||||
).rejects.toThrow();
|
||||
|
||||
const subscriptions = await getSubscriptions(userWithNonMatchingEmailRequestConfig);
|
||||
const invitedSub = subscriptions.update.find((sub) => sub.fname === `${channelName}:${federationConfig.hs1.domain}`);
|
||||
expect(invitedSub).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When setting is disabled', () => {
|
||||
beforeAll(async () => {
|
||||
// Disable email domain validation
|
||||
await rc1AdminRequestConfig.request
|
||||
.post(api('settings/Federation_Service_Validate_User_Domain'))
|
||||
.set(rc1AdminRequestConfig.credentials)
|
||||
.send({ value: false })
|
||||
.expect('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
describe('User with any email domain', () => {
|
||||
let userWithNonMatchingEmail: TestUser<IUser>;
|
||||
let userRequestConfig: IRequestConfig;
|
||||
|
||||
beforeAll(async () => {
|
||||
userWithNonMatchingEmail = await createUser(
|
||||
{
|
||||
username: `user-any-domain-${Date.now()}`,
|
||||
password: 'password123',
|
||||
email: `user-any-domain-${Date.now()}@external-domain.com`,
|
||||
roles: ['user'],
|
||||
},
|
||||
rc1AdminRequestConfig,
|
||||
);
|
||||
|
||||
// Verify the user's email
|
||||
await rc1AdminRequestConfig.request
|
||||
.post(api('users.update'))
|
||||
.set(rc1AdminRequestConfig.credentials)
|
||||
.send({
|
||||
userId: userWithNonMatchingEmail._id,
|
||||
data: {
|
||||
verified: true,
|
||||
},
|
||||
})
|
||||
.expect('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
|
||||
userRequestConfig = await getRequestConfig(federationConfig.rc1.url, userWithNonMatchingEmail.username, 'password123');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUser(userWithNonMatchingEmail, {}, rc1AdminRequestConfig);
|
||||
});
|
||||
|
||||
it('should be able to create a federated room regardless of email domain', async () => {
|
||||
const channelName = `federated-room-${Date.now()}`;
|
||||
const createResponse = await createRoom({
|
||||
type: 'p',
|
||||
name: channelName,
|
||||
members: [],
|
||||
extraData: {
|
||||
federated: true,
|
||||
},
|
||||
config: userRequestConfig,
|
||||
});
|
||||
|
||||
expect(createResponse.status).toBe(200);
|
||||
expect(createResponse.body).toHaveProperty('success', true);
|
||||
expect(createResponse.body).toHaveProperty('group');
|
||||
expect(createResponse.body.group).toHaveProperty('federated', true);
|
||||
});
|
||||
|
||||
it('should be able to be added to a federated room regardless of email domain', async () => {
|
||||
const channelName = `federated-room-${Date.now()}`;
|
||||
const createResponse = await createRoom({
|
||||
type: 'p',
|
||||
name: channelName,
|
||||
members: [],
|
||||
extraData: {
|
||||
federated: true,
|
||||
},
|
||||
config: rc1AdminRequestConfig,
|
||||
});
|
||||
|
||||
expect(createResponse.status).toBe(200);
|
||||
|
||||
const addUserResponse = await addUserToRoomViaMethod({
|
||||
usernames: [userWithNonMatchingEmail.username],
|
||||
rid: createResponse.body.group._id,
|
||||
config: rc1AdminRequestConfig,
|
||||
});
|
||||
|
||||
expect(addUserResponse.status).toBe(200);
|
||||
expect(addUserResponse.body).toHaveProperty('success', true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
2206
ee/packages/federation-matrix/tests/end-to-end/room.spec.ts
Normal file
2206
ee/packages/federation-matrix/tests/end-to-end/room.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
136
ee/packages/federation-matrix/tests/helper/config.ts
Normal file
136
ee/packages/federation-matrix/tests/helper/config.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Configuration interface for federation test environment.
|
||||
*
|
||||
* Defines the structure for all federation-related configuration including
|
||||
* Rocket.Chat instances, Matrix homeservers, and user credentials needed
|
||||
* for end-to-end federation testing.
|
||||
*/
|
||||
|
||||
type FederationUserConfig = {
|
||||
username: string;
|
||||
password: string;
|
||||
matrixUserId: string;
|
||||
};
|
||||
|
||||
type FederationServerConfig = {
|
||||
url: string;
|
||||
domain: string;
|
||||
adminUser: string;
|
||||
adminPassword: string;
|
||||
adminMatrixUserId: string;
|
||||
additionalUser1: FederationUserConfig;
|
||||
/**
|
||||
* Reserved for tests that need a user the other side has never seen. Must not be used
|
||||
* anywhere else: the moment another spec touches it, the local user document exists and
|
||||
* first-contact behaviour can no longer be observed.
|
||||
*/
|
||||
firstContactUser: FederationUserConfig;
|
||||
};
|
||||
export interface IFederationConfig {
|
||||
rc1: FederationServerConfig;
|
||||
hs1: FederationServerConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a required environment variable exists and is not empty.
|
||||
*
|
||||
* Ensures that all federation test configuration is properly set by validating
|
||||
* environment variables and providing sensible defaults where appropriate.
|
||||
* Throws an error if a required variable is missing or empty.
|
||||
*
|
||||
* @param name - The name of the environment variable for error messages
|
||||
* @param defaultValue - Optional default value to use if variable is not set
|
||||
* @returns The validated value (either the env var or default)
|
||||
* @throws Error if the variable is required but missing or empty
|
||||
*/
|
||||
function validateEnvVar(name: string, defaultValue?: string): string {
|
||||
const finalValue = process.env[name] || defaultValue;
|
||||
if (!finalValue || finalValue.trim() === '') {
|
||||
throw new Error(`Required environment variable ${name} is not set or is empty`);
|
||||
}
|
||||
return finalValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and validates the complete federation test configuration.
|
||||
*
|
||||
* Reads all federation-related environment variables, validates them,
|
||||
* and constructs a complete configuration object. Uses sensible defaults
|
||||
* for development and testing scenarios while ensuring all required
|
||||
* values are present.
|
||||
*
|
||||
* @returns Complete federation configuration object
|
||||
* @throws Error if any required configuration is missing or invalid
|
||||
*/
|
||||
function getFederationConfig(): IFederationConfig {
|
||||
const rcDomain = validateEnvVar('FEDERATION_RC1_DOMAIN', 'rc1');
|
||||
const rcAdminUser = validateEnvVar('FEDERATION_RC1_ADMIN_USER', 'admin');
|
||||
const rcAdminPassword = validateEnvVar('FEDERATION_RC1_ADMIN_PASSWORD', 'admin');
|
||||
const rcAdditionalUser1 = validateEnvVar('FEDERATION_RC1_ADDITIONAL_USER1', 'user2');
|
||||
const rcAdditionalUser1Password = validateEnvVar('FEDERATION_RC1_ADDITIONAL_USER1_PASSWORD', 'user2pass');
|
||||
const rcFirstContactUser = validateEnvVar('FEDERATION_RC1_FIRST_CONTACT_USER', 'user3');
|
||||
const rcFirstContactUserPassword = validateEnvVar('FEDERATION_RC1_FIRST_CONTACT_USER_PASSWORD', 'user3pass');
|
||||
|
||||
const hs1Domain = validateEnvVar('FEDERATION_SYNAPSE_DOMAIN', 'hs1');
|
||||
const hs1AdminUser = validateEnvVar('FEDERATION_SYNAPSE_ADMIN_USER', 'admin');
|
||||
const hs1AdminPassword = validateEnvVar('FEDERATION_SYNAPSE_ADMIN_PASSWORD', 'admin');
|
||||
const hs1AdditionalUser1 = validateEnvVar('FEDERATION_SYNAPSE_ADDITIONAL_USER1', 'alice');
|
||||
const hs1AdditionalUser1Password = validateEnvVar('FEDERATION_SYNAPSE_ADDITIONAL_USER1_PASSWORD', 'alice');
|
||||
// `cleiton` is registered by docker-compose.test.yml and deliberately used by no other spec
|
||||
const hs1FirstContactUser = validateEnvVar('FEDERATION_SYNAPSE_FIRST_CONTACT_USER', 'cleiton');
|
||||
const hs1FirstContactUserPassword = validateEnvVar('FEDERATION_SYNAPSE_FIRST_CONTACT_USER_PASSWORD', 'cleiton');
|
||||
|
||||
return {
|
||||
rc1: {
|
||||
url: `https://${rcDomain}`,
|
||||
domain: rcDomain,
|
||||
adminUser: rcAdminUser,
|
||||
adminPassword: rcAdminPassword,
|
||||
adminMatrixUserId: `@${rcAdminUser}:${rcDomain}`,
|
||||
additionalUser1: {
|
||||
username: rcAdditionalUser1,
|
||||
password: rcAdditionalUser1Password,
|
||||
matrixUserId: `@${rcAdditionalUser1}:${rcDomain}`,
|
||||
},
|
||||
firstContactUser: {
|
||||
username: rcFirstContactUser,
|
||||
password: rcFirstContactUserPassword,
|
||||
matrixUserId: `@${rcFirstContactUser}:${rcDomain}`,
|
||||
},
|
||||
},
|
||||
hs1: {
|
||||
url: `https://${hs1Domain}`,
|
||||
domain: hs1Domain,
|
||||
adminUser: hs1AdminUser,
|
||||
adminMatrixUserId: `@${hs1AdminUser}:${hs1Domain}`,
|
||||
adminPassword: hs1AdminPassword,
|
||||
additionalUser1: {
|
||||
username: hs1AdditionalUser1,
|
||||
password: hs1AdditionalUser1Password,
|
||||
matrixUserId: `@${hs1AdditionalUser1}:${hs1Domain}`,
|
||||
},
|
||||
firstContactUser: {
|
||||
username: hs1FirstContactUser,
|
||||
password: hs1FirstContactUserPassword,
|
||||
matrixUserId: `@${hs1FirstContactUser}:${hs1Domain}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validated federation configuration for test execution.
|
||||
*
|
||||
* This configuration is loaded at module initialization time and
|
||||
* will cause the process to exit if any required environment
|
||||
* variables are missing or invalid.
|
||||
*/
|
||||
let federationConfig: IFederationConfig;
|
||||
try {
|
||||
federationConfig = getFederationConfig();
|
||||
} catch (error) {
|
||||
console.error('Federation environment configuration error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export { federationConfig };
|
||||
168
ee/packages/federation-matrix/tests/helper/ddp-listener.ts
Normal file
168
ee/packages/federation-matrix/tests/helper/ddp-listener.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { DDPSDK } from '@rocket.chat/ddp-client';
|
||||
|
||||
import type { IRequestConfig } from '../../../../../apps/meteor/tests/data/users.helper';
|
||||
|
||||
/**
|
||||
* DDP Listener for catching ephemeral messages in federation tests
|
||||
*
|
||||
* This helper creates a DDP connection to listen for ephemeral messages
|
||||
* that are broadcast to a specific user. It's designed to work with
|
||||
* the federation test environment where the test runs separately from
|
||||
* the server.
|
||||
*/
|
||||
export class DDPListener {
|
||||
private sdk: DDPSDK | null = null;
|
||||
|
||||
private ephemeralMessages: IMessage[] = [];
|
||||
|
||||
private timeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
private serverUrl: string;
|
||||
|
||||
private userId: string;
|
||||
|
||||
private authToken?: string;
|
||||
|
||||
constructor(apiUrl: string, requestConfig: IRequestConfig) {
|
||||
// Extract server URL from API URL (convert HTTP/HTTPS to WebSocket)
|
||||
this.serverUrl = apiUrl.replace(/^http/, 'ws');
|
||||
|
||||
// Extract user ID and auth token from request config credentials
|
||||
this.userId = requestConfig.credentials['X-User-Id'];
|
||||
this.authToken = requestConfig.credentials['X-Auth-Token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the DDP server and subscribe to ephemeral messages
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
try {
|
||||
// Create DDP SDK instance
|
||||
this.sdk = DDPSDK.create(this.serverUrl);
|
||||
|
||||
// Add timeout for connection
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
throw new Error('DDP connection timeout');
|
||||
}, 10000);
|
||||
|
||||
// Connect to the server
|
||||
await this.sdk.connection.connect();
|
||||
|
||||
// Wait a bit for the connection to be fully ready
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Authenticate if we have a token
|
||||
if (this.authToken) {
|
||||
await this.sdk.account.loginWithToken(this.authToken);
|
||||
}
|
||||
|
||||
// Subscribe to ephemeral messages using the stream method
|
||||
this.sdk.stream('notify-user', `${this.userId}/message`, (...args) => {
|
||||
// The args should contain the ephemeral message
|
||||
if (args && args.length > 0) {
|
||||
const message = args[0] as IMessage;
|
||||
this.ephemeralMessages.push(message);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear timeout on successful connection
|
||||
clearTimeout(connectionTimeout);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an ephemeral message with a specific content
|
||||
* @param expectedContent - The expected message content (partial match)
|
||||
* @param timeoutMs - Timeout in milliseconds (default: 5000)
|
||||
* @param roomId - Optional room ID to validate the message belongs to the correct room
|
||||
* @returns Promise that resolves with the message or rejects on timeout
|
||||
*/
|
||||
async waitForEphemeralMessage(expectedContent: string, timeoutMs = 5000, roomId?: string): Promise<IMessage> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check if message already exists
|
||||
const existingMessage = this.ephemeralMessages.find((msg) => {
|
||||
const contentMatches = msg.msg?.includes(expectedContent);
|
||||
const roomMatches = !roomId || msg.rid === roomId;
|
||||
return contentMatches && roomMatches;
|
||||
});
|
||||
|
||||
if (existingMessage) {
|
||||
resolve(existingMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
|
||||
this.timeoutId = setTimeout(() => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
const roomInfo = roomId ? ` for room ${roomId}` : '';
|
||||
reject(new Error(`Timeout waiting for ephemeral message containing: "${expectedContent}"${roomInfo}`));
|
||||
}, timeoutMs);
|
||||
|
||||
const checkMessages = () => {
|
||||
const message = this.ephemeralMessages.find((msg) => {
|
||||
const contentMatches = msg.msg?.includes(expectedContent);
|
||||
const roomMatches = !roomId || msg.rid === roomId;
|
||||
return contentMatches && roomMatches;
|
||||
});
|
||||
|
||||
if (message) {
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId);
|
||||
this.timeoutId = null;
|
||||
}
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
resolve(message);
|
||||
}
|
||||
};
|
||||
|
||||
interval = setInterval(checkMessages, 100);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all captured ephemeral messages
|
||||
*/
|
||||
getEphemeralMessages(): IMessage[] {
|
||||
return [...this.ephemeralMessages];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear captured messages
|
||||
*/
|
||||
clearMessages(): void {
|
||||
this.ephemeralMessages = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from DDP server
|
||||
*/
|
||||
disconnect(): void {
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId);
|
||||
this.timeoutId = null;
|
||||
}
|
||||
|
||||
if (this.sdk) {
|
||||
this.sdk.connection.close();
|
||||
this.sdk = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create and manage a DDP listener for federation tests
|
||||
* @param apiUrl - The Rocket.Chat API URL (e.g., 'http://rc1:3000' or 'https://rc1:3000')
|
||||
* @param requestConfig - The request configuration containing credentials
|
||||
* @returns DDPListener instance
|
||||
*/
|
||||
export function createDDPListener(apiUrl: string, requestConfig: IRequestConfig): DDPListener {
|
||||
return new DDPListener(apiUrl, requestConfig);
|
||||
}
|
||||
742
ee/packages/federation-matrix/tests/helper/synapse-client.ts
Normal file
742
ee/packages/federation-matrix/tests/helper/synapse-client.ts
Normal file
@@ -0,0 +1,742 @@
|
||||
/**
|
||||
* Federation test data and configuration
|
||||
* This file provides validated federation configuration for federation tests.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { createClient, type MatrixClient, KnownMembership, type Room, type RoomMember, Visibility } from 'matrix-js-sdk';
|
||||
import { logger } from 'matrix-js-sdk/lib/logger';
|
||||
|
||||
logger.debug = () => void 0;
|
||||
logger.info = () => void 0;
|
||||
logger.warn = () => void 0;
|
||||
logger.error = () => void 0;
|
||||
|
||||
/**
|
||||
* Creates a promise that resolves after the specified delay.
|
||||
*
|
||||
* Utility function for adding delays in async operations, particularly
|
||||
* useful for retry logic and handling eventual consistency in distributed systems.
|
||||
*
|
||||
* @param ms - The delay in milliseconds
|
||||
* @returns Promise that resolves after the specified delay
|
||||
*/
|
||||
export function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Client for interacting with Matrix Synapse homeserver during federation tests.
|
||||
*
|
||||
* Provides a simplified interface for Matrix operations needed in federation
|
||||
* testing scenarios, including room management, member operations, and
|
||||
* invitation handling with built-in retry logic for eventual consistency.
|
||||
*/
|
||||
export class SynapseClient {
|
||||
private _matrixClient: MatrixClient | null = null;
|
||||
|
||||
private url: string;
|
||||
|
||||
private username: string;
|
||||
|
||||
private password: string;
|
||||
|
||||
/**
|
||||
* Creates a new SynapseClient instance.
|
||||
*
|
||||
* @param url - The Matrix homeserver URL
|
||||
* @param username - Matrix user ID (e.g., @user:domain.com)
|
||||
* @param password - User password for authentication
|
||||
*/
|
||||
constructor(url: string, username: string, password: string) {
|
||||
this.url = url;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
get matrixClient(): MatrixClient {
|
||||
if (!this._matrixClient) {
|
||||
throw new Error('Matrix client is not initialized');
|
||||
}
|
||||
return this._matrixClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the Matrix client connection.
|
||||
*
|
||||
* Creates and authenticates a Matrix client, then starts the client
|
||||
* to enable real-time operations. Must be called before using other methods.
|
||||
*
|
||||
* @returns Promise that resolves when initialization is complete
|
||||
* @throws Error if authentication fails or client cannot be started
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
const client = await this.createClient(this.username, this.password, this.url);
|
||||
await client.startClient();
|
||||
this._matrixClient = client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and authenticates a Matrix client with silent logging.
|
||||
*
|
||||
* Sets up a Matrix client with minimal logging to reduce noise during
|
||||
* test execution while maintaining full functionality for federation testing.
|
||||
*
|
||||
* @param username - Matrix user ID for authentication
|
||||
* @param password - User password for authentication
|
||||
* @param url - Matrix homeserver URL
|
||||
* @returns Authenticated Matrix client ready for use
|
||||
* @throws Error if login fails or client creation fails
|
||||
*/
|
||||
private async createClient(username: string, password: string, url: string): Promise<MatrixClient> {
|
||||
const silentLogger = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
trace: () => {},
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
debug: () => {},
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
info: () => {},
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
warn: () => {},
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
error: () => {},
|
||||
getChild: () => silentLogger,
|
||||
};
|
||||
|
||||
const client = createClient({
|
||||
baseUrl: url,
|
||||
useAuthorizationHeader: true,
|
||||
logger: silentLogger,
|
||||
});
|
||||
|
||||
await client.login('m.login.password', {
|
||||
user: username,
|
||||
password,
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a room by its display name.
|
||||
*
|
||||
* Searches through all known rooms to find one matching the specified
|
||||
* display name. Useful for federation testing where room names are
|
||||
* used as identifiers.
|
||||
*
|
||||
* @param roomName - The display name of the room to find
|
||||
* @returns The Matrix room object
|
||||
* @throws Error if client is not initialized or room is not found
|
||||
*/
|
||||
getRoom(roomName: string): Room {
|
||||
if (!this.matrixClient) {
|
||||
throw new Error('Matrix client is not initialized');
|
||||
}
|
||||
const rooms = this.matrixClient.getRooms();
|
||||
const room = rooms.find((room) => room.name === roomName);
|
||||
|
||||
if (room) {
|
||||
return room;
|
||||
}
|
||||
|
||||
throw new Error(`No room found with name ${roomName}`);
|
||||
}
|
||||
|
||||
async createRoom(roomName: string, visibility: Visibility = Visibility.Private): Promise<string> {
|
||||
if (!this.matrixClient) {
|
||||
throw new Error('Matrix client is not initialized');
|
||||
}
|
||||
|
||||
const room = await this.matrixClient.createRoom({
|
||||
name: roomName,
|
||||
visibility,
|
||||
});
|
||||
|
||||
return room.room_id;
|
||||
}
|
||||
|
||||
async createDM(userIds: string[]) {
|
||||
if (!this.matrixClient) {
|
||||
throw new Error('Matrix client is not initialized');
|
||||
}
|
||||
|
||||
const dmRoom = await this.matrixClient.createRoom({
|
||||
is_direct: true,
|
||||
invite: userIds,
|
||||
});
|
||||
|
||||
return this.matrixClient.getRoom(dmRoom.room_id);
|
||||
}
|
||||
|
||||
async inviteUserToRoom(roomId: string, userId: string): Promise<void> {
|
||||
if (!this.matrixClient) {
|
||||
throw new Error('Matrix client is not initialized');
|
||||
}
|
||||
await this.matrixClient.invite(roomId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a room by name and membership status.
|
||||
*
|
||||
* Searches for a room that matches both the display name and the current
|
||||
* user's membership status. Useful for finding rooms in specific states
|
||||
* like 'invite' or 'join' during federation testing.
|
||||
*
|
||||
* @param roomName - The display name of the room to find
|
||||
* @param membership - The required membership status (e.g., 'invite', 'join')
|
||||
* @returns The Matrix room ID
|
||||
* @throws Error if client is not initialized or room is not found
|
||||
*/
|
||||
getRoomIdByRoomNameAndMembership(roomName: string, membership: KnownMembership): string {
|
||||
if (!this.matrixClient) {
|
||||
throw new Error('Matrix client is not initialized');
|
||||
}
|
||||
const rooms = this.matrixClient.getRooms();
|
||||
const room = rooms.find((room) => room.name === roomName && room.getMyMembership() === membership);
|
||||
|
||||
if (room) {
|
||||
return room.roomId;
|
||||
}
|
||||
|
||||
throw new Error(`No room found with name ${roomName} and membership ${membership}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts a room invitation with configurable retry logic.
|
||||
*
|
||||
* Handles the process of accepting a room invitation, which is common
|
||||
* in federation scenarios where users are invited to remote rooms.
|
||||
* Includes retry logic to handle eventual consistency in distributed systems.
|
||||
*
|
||||
* @param roomName - The display name of the room to join
|
||||
* @param maxRetries - Maximum number of retry attempts (default: 5)
|
||||
* @param retryDelay - Delay between retries in milliseconds (default: 1000)
|
||||
* @param initialDelay - Initial delay before first attempt in milliseconds (default: 5000)
|
||||
* @returns The room ID of the successfully joined room
|
||||
* @throws Error if client is not initialized or all retry attempts fail
|
||||
*/
|
||||
async acceptInvitationForRoomName(roomName: string, maxRetries = 5, retryDelay = 1000, initialDelay = 5000): Promise<string> {
|
||||
if (!this.matrixClient) {
|
||||
throw new Error('Matrix client is not initialized');
|
||||
}
|
||||
if (initialDelay) {
|
||||
await wait(initialDelay);
|
||||
}
|
||||
const retries = Math.max(1, maxRetries);
|
||||
let lastError: Error | null = null;
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const roomId = this.getRoomIdByRoomNameAndMembership(roomName, KnownMembership.Invite);
|
||||
await this.matrixClient.joinRoom(roomId);
|
||||
return roomId;
|
||||
} catch (error) {
|
||||
if (attempt < retries) {
|
||||
await wait(retryDelay);
|
||||
}
|
||||
lastError = error as Error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to accept invitation for room ${roomName} after ${retries} attempts${lastError ? `: ${lastError.message}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all members of a room.
|
||||
*
|
||||
* Gets the complete list of room members, which is essential
|
||||
* for verifying federation state and member synchronization.
|
||||
*
|
||||
* @param roomName - The display name of the room
|
||||
* @returns Array of room member objects
|
||||
* @throws Error if client is not initialized or room is not found
|
||||
*/
|
||||
async getRoomMembers(roomName: string): Promise<RoomMember[]> {
|
||||
const room = this.getRoom(roomName);
|
||||
|
||||
return room.getMembers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a specific room member with retry logic.
|
||||
*
|
||||
* Searches for a member in a room by username or user ID, with configurable
|
||||
* retry logic to handle eventual consistency in federated systems.
|
||||
* This is crucial for federation testing where member synchronization
|
||||
* may take time to propagate across homeservers.
|
||||
*
|
||||
* @param roomName - The display name of the room to search
|
||||
* @param username - The username or user ID to find
|
||||
* @param options - Retry configuration options
|
||||
* @param options.maxRetries - Maximum number of retry attempts (default: 3)
|
||||
* @param options.delay - Delay between retries in milliseconds (default: 1000)
|
||||
* @param options.initialDelay - Initial delay before first attempt in milliseconds (default: 0)
|
||||
* @returns The room member if found, null otherwise
|
||||
*/
|
||||
async findRoomMember(
|
||||
roomName: string,
|
||||
username: string,
|
||||
options: { maxRetries?: number; delay?: number; initialDelay?: number } = {},
|
||||
): Promise<RoomMember | null> {
|
||||
const { maxRetries = 3, delay = 1000, initialDelay = 0 } = options;
|
||||
|
||||
if (initialDelay > 0) {
|
||||
await wait(initialDelay);
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const members = await this.getRoomMembers(roomName);
|
||||
const member = members.find((member: RoomMember) => member.name === username || member.userId === username);
|
||||
|
||||
if (member) {
|
||||
return member;
|
||||
}
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
await wait(delay);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Attempt ${attempt} to find room member failed:`, error);
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
await wait(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a text message to a room.
|
||||
*
|
||||
* Sends a plain text message to the specified room using the Matrix JS SDK.
|
||||
* The room is identified by its display name, which is common in federation
|
||||
* testing scenarios.
|
||||
*
|
||||
* @param roomName - The display name of the room to send the message to
|
||||
* @param message - The text message content to send
|
||||
* @returns Promise resolving to the Matrix event ID of the sent message
|
||||
* @throws Error if client is not initialized or room is not found
|
||||
*/
|
||||
async sendTextMessage(roomName: string, message: string): Promise<string> {
|
||||
if (!this.matrixClient) {
|
||||
throw new Error('Matrix client is not initialized');
|
||||
}
|
||||
const room = this.getRoom(roomName);
|
||||
const response = await this.matrixClient.sendTextMessage(room.roomId, message);
|
||||
return response.event_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an HTML-formatted message to a room.
|
||||
*
|
||||
* Sends a message with HTML formatting to the specified room using the Matrix JS SDK.
|
||||
* This allows sending formatted text (bold, italic, underline) that will be properly
|
||||
* rendered in Element and other Matrix clients that support HTML formatting.
|
||||
*
|
||||
* @param roomName - The display name of the room to send the message to
|
||||
* @param body - The plain text version of the message (required by Matrix spec)
|
||||
* @param formattedBody - The HTML-formatted version of the message
|
||||
* @returns Promise resolving to the Matrix event ID of the sent message
|
||||
* @throws Error if client is not initialized or room is not found
|
||||
*/
|
||||
async sendHtmlMessage(roomName: string, body: string, formattedBody: string): Promise<string> {
|
||||
if (!this.matrixClient) {
|
||||
throw new Error('Matrix client is not initialized');
|
||||
}
|
||||
const room = this.getRoom(roomName);
|
||||
const content: any = {
|
||||
msgtype: 'm.text',
|
||||
body,
|
||||
format: 'org.matrix.custom.html',
|
||||
formatted_body: formattedBody,
|
||||
};
|
||||
const response = await this.matrixClient.sendMessage(room.roomId, content);
|
||||
return response.event_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all text messages from a room's timeline.
|
||||
*
|
||||
* Gets all text message events from the room's timeline, which is essential
|
||||
* for verifying message synchronization in federation testing. Filters out
|
||||
* non-message events and returns only text messages.
|
||||
*
|
||||
* @param roomName - The display name of the room
|
||||
* @returns Array of text message events from the room's timeline
|
||||
* @throws Error if client is not initialized or room is not found
|
||||
*/
|
||||
getRoomMessages(roomName: string): Array<{ content: { body: string }; event_id: string; sender: string }> {
|
||||
if (!this.matrixClient) {
|
||||
throw new Error('Matrix client is not initialized');
|
||||
}
|
||||
const room = this.getRoom(roomName);
|
||||
const { timeline } = room;
|
||||
const messages: Array<{ content: { body: string }; event_id: string; sender: string }> = [];
|
||||
|
||||
for (const event of timeline) {
|
||||
if (event.getType() === 'm.room.message') {
|
||||
const content = event.getContent();
|
||||
if (content.msgtype === 'm.text' || content.msgtype === 'm.notice') {
|
||||
messages.push({
|
||||
content: {
|
||||
body: content.body || '',
|
||||
},
|
||||
event_id: event.getId() || '',
|
||||
sender: event.getSender() || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a message in a room's timeline by content.
|
||||
*
|
||||
* Searches for a message in the room's timeline that matches the specified
|
||||
* content text. Useful for verifying that messages appear correctly on
|
||||
* the remote side in federation tests.
|
||||
*
|
||||
* @param roomName - The display name of the room to search
|
||||
* @param messageText - The message text to find
|
||||
* @param options - Retry configuration options
|
||||
* @param options.maxRetries - Maximum number of retry attempts (default: 5)
|
||||
* @param options.delay - Delay between retries in milliseconds (default: 1000)
|
||||
* @param options.initialDelay - Initial delay before first attempt in milliseconds (default: 2000)
|
||||
* @returns The message event if found, null otherwise
|
||||
*/
|
||||
async findMessageInRoom(
|
||||
roomName: string,
|
||||
messageText: string,
|
||||
options: { maxRetries?: number; delay?: number; initialDelay?: number } = {},
|
||||
): Promise<{ content: { body: string }; event_id: string; sender: string } | null> {
|
||||
const { maxRetries = 5, delay = 1000, initialDelay = 2000 } = options;
|
||||
|
||||
if (initialDelay > 0) {
|
||||
await wait(initialDelay);
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const messages = this.getRoomMessages(roomName);
|
||||
const message = messages.find((msg) => msg.content.body === messageText);
|
||||
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
await wait(delay);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Attempt ${attempt} to find message in room failed:`, error);
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
await wait(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file to a room using Matrix JS SDK.
|
||||
*
|
||||
* Uploads a file to the specified room and sends it as a file message.
|
||||
* Determines the appropriate msgtype based on file extension and mime type.
|
||||
*
|
||||
* @param roomName - The display name of the room to upload the file to
|
||||
* @param filePath - Path to the file to upload
|
||||
* @param fileName - The file name to use in the message body (used by findFileMessageInRoom)
|
||||
* @returns Promise resolving to the Matrix event ID of the sent file message
|
||||
* @throws Error if client is not initialized or room is not found
|
||||
*/
|
||||
async uploadFile(roomName: string, filePath: string, fileName: string): Promise<string> {
|
||||
if (!this.matrixClient) {
|
||||
throw new Error('Matrix client is not initialized');
|
||||
}
|
||||
const room = this.getRoom(roomName);
|
||||
|
||||
// Read file
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
const fileExtension = path.extname(fileName).toLowerCase().slice(1);
|
||||
|
||||
// Determine mime type based on extension
|
||||
const mimeTypes: Record<string, string> = {
|
||||
webp: 'image/webp',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
pdf: 'application/pdf',
|
||||
webm: 'video/webm',
|
||||
mp4: 'video/mp4',
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
txt: 'text/plain',
|
||||
};
|
||||
|
||||
const mimeType = mimeTypes[fileExtension] || 'application/octet-stream';
|
||||
|
||||
// Determine msgtype based on file type
|
||||
let msgtype: string;
|
||||
if (mimeType.startsWith('image/')) {
|
||||
msgtype = 'm.image';
|
||||
} else if (mimeType.startsWith('video/')) {
|
||||
msgtype = 'm.video';
|
||||
} else if (mimeType.startsWith('audio/')) {
|
||||
msgtype = 'm.audio';
|
||||
} else {
|
||||
msgtype = 'm.file';
|
||||
}
|
||||
|
||||
// Upload file content
|
||||
const uploadResponse = await this.matrixClient.uploadContent(fileBuffer, {
|
||||
name: fileName,
|
||||
type: mimeType,
|
||||
});
|
||||
|
||||
if (!uploadResponse.content_uri) {
|
||||
throw new Error('File upload failed: no content URI returned');
|
||||
}
|
||||
|
||||
// Send file message
|
||||
const content: any = {
|
||||
msgtype,
|
||||
body: fileName,
|
||||
url: uploadResponse.content_uri,
|
||||
info: {
|
||||
mimetype: mimeType,
|
||||
size: fileBuffer.length,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await this.matrixClient.sendMessage(room.roomId, content);
|
||||
return response.event_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all file/media messages from a room's timeline.
|
||||
*
|
||||
* Gets all file message events (images, videos, audio, files) from the room's timeline.
|
||||
* Useful for verifying file synchronization in federation testing.
|
||||
*
|
||||
* @param roomName - The display name of the room
|
||||
* @returns Array of file message events from the room's timeline
|
||||
* @throws Error if client is not initialized or room is not found
|
||||
*/
|
||||
getRoomFileMessages(roomName: string): Array<{
|
||||
content: { body: string; msgtype: string; url?: string; info?: any };
|
||||
event_id: string;
|
||||
sender: string;
|
||||
}> {
|
||||
if (!this.matrixClient) {
|
||||
throw new Error('Matrix client is not initialized');
|
||||
}
|
||||
const room = this.getRoom(roomName);
|
||||
const { timeline } = room;
|
||||
const messages: Array<{
|
||||
content: { body: string; msgtype: string; url?: string; info?: any };
|
||||
event_id: string;
|
||||
sender: string;
|
||||
}> = [];
|
||||
|
||||
for (const event of timeline) {
|
||||
if (event.getType() === 'm.room.message') {
|
||||
const content = event.getContent();
|
||||
if (
|
||||
content.msgtype === 'm.image' ||
|
||||
content.msgtype === 'm.video' ||
|
||||
content.msgtype === 'm.audio' ||
|
||||
content.msgtype === 'm.file'
|
||||
) {
|
||||
messages.push({
|
||||
content: {
|
||||
body: content.body || '',
|
||||
msgtype: content.msgtype,
|
||||
url: content.url,
|
||||
info: content.info,
|
||||
},
|
||||
event_id: event.getId() || '',
|
||||
sender: event.getSender() || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a file message in a room's timeline by file name.
|
||||
*
|
||||
* Searches for a file message in the room's timeline that matches the specified
|
||||
* file name. Useful for verifying that file messages appear correctly on
|
||||
* the remote side in federation tests.
|
||||
*
|
||||
* @param roomName - The display name of the room to search
|
||||
* @param fileName - The file name to find
|
||||
* @param options - Retry configuration options
|
||||
* @param options.maxRetries - Maximum number of retry attempts (default: 5)
|
||||
* @param options.delay - Delay between retries in milliseconds (default: 1000)
|
||||
* @param options.initialDelay - Initial delay before first attempt in milliseconds (default: 2000)
|
||||
* @returns The file message event if found, null otherwise
|
||||
*/
|
||||
async findFileMessageInRoom(
|
||||
roomName: string,
|
||||
fileName: string,
|
||||
options: { maxRetries?: number; delay?: number; initialDelay?: number } = {},
|
||||
): Promise<{
|
||||
content: { body: string; msgtype: string; url?: string; info?: any };
|
||||
event_id: string;
|
||||
sender: string;
|
||||
} | null> {
|
||||
const { maxRetries = 5, delay = 1000, initialDelay = 2000 } = options;
|
||||
|
||||
if (initialDelay > 0) {
|
||||
await wait(initialDelay);
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const messages = this.getRoomFileMessages(roomName);
|
||||
const message = messages.find((msg) => msg.content.body === fileName || msg.content.body?.includes(fileName));
|
||||
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
await wait(delay);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Attempt ${attempt} to find file message in room failed:`, error);
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
await wait(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a file from Matrix and verifies it matches the original file using binary comparison.
|
||||
*
|
||||
* Uses the Matrix JS SDK to download media files from the homeserver and compares
|
||||
* them byte-by-byte with the original file. The MXC URI format is: mxc://serverName/mediaId
|
||||
*
|
||||
* @param mxcUri - The MXC URI of the media to download (e.g., "mxc://serverName/mediaId")
|
||||
* @param originalFilePath - Path to the original file to compare against
|
||||
* @returns Promise resolving to true if files match byte-by-byte
|
||||
* @throws Error if client is not initialized or download fails
|
||||
*/
|
||||
async downloadFileAndCompareBinary(mxcUri: string, originalFilePath: string): Promise<boolean> {
|
||||
if (!this.matrixClient) {
|
||||
throw new Error('Matrix client is not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// Use Matrix JS SDK's mxcUrlToHttp with useAuthentication=true to get the client v1 endpoint
|
||||
// This generates: https://hs1/_matrix/client/v1/media/download/{serverName}/{mediaId}?allow_redirect=true
|
||||
// Parameters: mxcUrl, width, height, resizeMethod, allowDirectLinks, allowRedirects, useAuthentication
|
||||
const downloadUrl = this.matrixClient.mxcUrlToHttp(mxcUri, undefined, undefined, undefined, false, true, true);
|
||||
if (!downloadUrl) {
|
||||
throw new Error(`Failed to convert MXC URI to HTTP URL: ${mxcUri}`);
|
||||
}
|
||||
|
||||
// Add allow_remote=true parameter to ensure Synapse fetches from remote servers
|
||||
const urlWithRemote = new URL(downloadUrl);
|
||||
urlWithRemote.searchParams.set('allow_remote', 'true');
|
||||
const finalDownloadUrl = urlWithRemote.toString();
|
||||
|
||||
const accessToken = this.matrixClient.getAccessToken();
|
||||
if (!accessToken) {
|
||||
throw new Error('Matrix client access token not available');
|
||||
}
|
||||
|
||||
const response = await fetch(finalDownloadUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download media: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const downloadedBuffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// Read the original file
|
||||
const originalBuffer = fs.readFileSync(originalFilePath);
|
||||
|
||||
// Compare buffers byte-by-byte
|
||||
if (downloadedBuffer.length !== originalBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return downloadedBuffer.equals(originalBuffer);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to download and compare media from ${mxcUri}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bans a user from a room on the Synapse homeserver.
|
||||
*
|
||||
* @param roomId - The Matrix room ID
|
||||
* @param userId - The Matrix user ID to ban
|
||||
* @param reason - Optional reason for the ban
|
||||
*/
|
||||
async banUser(roomId: string, userId: string, reason?: string): Promise<void> {
|
||||
if (!this.matrixClient) {
|
||||
throw new Error('Matrix client is not initialized');
|
||||
}
|
||||
await this.matrixClient.ban(roomId, userId, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unbans a user from a room on the Synapse homeserver.
|
||||
*
|
||||
* @param roomId - The Matrix room ID
|
||||
* @param userId - The Matrix user ID to unban
|
||||
*/
|
||||
async unbanUser(roomId: string, userId: string): Promise<void> {
|
||||
if (!this.matrixClient) {
|
||||
throw new Error('Matrix client is not initialized');
|
||||
}
|
||||
await this.matrixClient.unban(roomId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the Matrix client connection and cleans up resources.
|
||||
*
|
||||
* Properly shuts down the Matrix client, clears all data stores,
|
||||
* removes event listeners, and logs out. Essential for preventing
|
||||
* resource leaks during test execution.
|
||||
*
|
||||
* @returns Promise that resolves when cleanup is complete
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (this.matrixClient) {
|
||||
this.matrixClient.stopClient();
|
||||
await this.matrixClient.store?.deleteAllData?.();
|
||||
await this.matrixClient.clearStores?.();
|
||||
this.matrixClient.removeAllListeners();
|
||||
await this.matrixClient.logout(true);
|
||||
this._matrixClient = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
ee/packages/federation-matrix/tests/helper/withTimeout.ts
Normal file
11
ee/packages/federation-matrix/tests/helper/withTimeout.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export function withTimeout<T>(fn: (signal: AbortSignal) => Promise<T>, ms: number): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, ms);
|
||||
|
||||
return fn(controller.signal).finally(() => {
|
||||
clearTimeout(timeoutId);
|
||||
});
|
||||
}
|
||||
BIN
ee/packages/federation-matrix/tests/resources/sample_audio.mp3
Normal file
BIN
ee/packages/federation-matrix/tests/resources/sample_audio.mp3
Normal file
Binary file not shown.
BIN
ee/packages/federation-matrix/tests/resources/sample_image.webp
Normal file
BIN
ee/packages/federation-matrix/tests/resources/sample_image.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
ee/packages/federation-matrix/tests/resources/sample_pdf.pdf
Normal file
BIN
ee/packages/federation-matrix/tests/resources/sample_pdf.pdf
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
||||
sample text file
|
||||
376
ee/packages/federation-matrix/tests/scripts/run-integration-tests.sh
Executable file
376
ee/packages/federation-matrix/tests/scripts/run-integration-tests.sh
Executable file
@@ -0,0 +1,376 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Federation Integration Test Runner
|
||||
# This script builds Rocket.Chat locally and starts the federation services,
|
||||
# then waits for Rocket.Chat to be ready before running the end-to-end tests.
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# Script moved under tests/scripts; package root is two levels up from script dir
|
||||
PACKAGE_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||
DOCKER_COMPOSE_FILE="$PACKAGE_ROOT/docker-compose.test.yml"
|
||||
MAX_WAIT_TIME=240 # 4 minutes
|
||||
CHECK_INTERVAL=5 # Check every 5 seconds
|
||||
|
||||
# Build configuration
|
||||
# Use a temporary directory outside the repo to avoid symlink traversal issues during Meteor build
|
||||
BUILD_DIR="$(mktemp -d "${FEDERATION_TEST_TMPDIR:-/tmp}/rc-federation-build-XXXXXX")"
|
||||
ROCKETCHAT_ROOT="$(cd "$PACKAGE_ROOT/../../.." && pwd)" # Go up to project root
|
||||
|
||||
# Parse command line arguments
|
||||
KEEP_RUNNING=false
|
||||
INCLUDE_ELEMENT=false
|
||||
USE_PREBUILT_IMAGE=false
|
||||
PREBUILT_IMAGE=""
|
||||
INTERRUPTED=false
|
||||
NO_TEST=false
|
||||
CI=false
|
||||
LOGS=false
|
||||
START_CONTAINERS=true
|
||||
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--start-containers-only)
|
||||
NO_TEST=true
|
||||
KEEP_RUNNING=true
|
||||
shift
|
||||
;;
|
||||
--ci)
|
||||
CI=true
|
||||
KEEP_RUNNING=true
|
||||
START_CONTAINERS=false
|
||||
shift
|
||||
;;
|
||||
--logs)
|
||||
LOGS=true
|
||||
NO_TEST=true
|
||||
START_CONTAINERS=false
|
||||
shift
|
||||
;;
|
||||
--keep-running)
|
||||
KEEP_RUNNING=true
|
||||
shift
|
||||
;;
|
||||
--element)
|
||||
INCLUDE_ELEMENT=true
|
||||
shift
|
||||
;;
|
||||
--no-test)
|
||||
NO_TEST=true
|
||||
shift
|
||||
;;
|
||||
--image)
|
||||
USE_PREBUILT_IMAGE=true
|
||||
# If no IMAGE value is provided (or next token is another flag), default to latest
|
||||
if [[ -z "${2:-}" || "$2" == -* ]]; then
|
||||
PREBUILT_IMAGE="rocketchat/rocket.chat:latest"
|
||||
shift 1
|
||||
else
|
||||
PREBUILT_IMAGE="$2"
|
||||
shift 2
|
||||
fi
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo "Options:"
|
||||
echo " --start-containers-only Start containers and skip running tests"
|
||||
echo " --ci Run tests in CI mode (keep containers running, no cleanup)"
|
||||
echo " --logs Show logs of rc1 and hs1 containers"
|
||||
echo " --keep-running Keep Docker containers running after tests complete"
|
||||
echo " --element Include Element web client in the test environment"
|
||||
echo " --no-test Start containers and skip running tests"
|
||||
echo " --image [IMAGE] Use a pre-built Docker image instead of building locally"
|
||||
echo " --help, -h Show this help message"
|
||||
echo ""
|
||||
echo "By default, builds Rocket.Chat locally and runs the 'test' profile"
|
||||
echo "Use --image to test against a pre-built image (e.g., --image rocketchat/rocket.chat:latest)"
|
||||
echo "If --image is provided without a value, defaults to rocketchat/rocket.chat:latest"
|
||||
echo "Use --element to run all services including Element web client"
|
||||
echo "Use --no-test to start containers and skip running tests"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Use --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Logging functions
|
||||
log_info() {
|
||||
echo -e "${BLUE}ℹ️ [$(date '+%Y-%m-%d %H:%M:%S')] $1${NC}"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}✅ [$(date '+%Y-%m-%d %H:%M:%S')] $1${NC}"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}⚠️ [$(date '+%Y-%m-%d %H:%M:%S')] $1${NC}"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}❌ [$(date '+%Y-%m-%d %H:%M:%S')] $1${NC}"
|
||||
}
|
||||
|
||||
docker_logs() {
|
||||
echo ""
|
||||
echo "ROCKET.CHAT (rc1) LOGS:"
|
||||
echo "----------------------------------------"
|
||||
docker compose -f "$DOCKER_COMPOSE_FILE" --profile "$COMPOSE_PROFILE" logs rc1
|
||||
|
||||
echo ""
|
||||
echo "SYNAPSE (hs1) LOGS:"
|
||||
echo "----------------------------------------"
|
||||
docker compose -f "$DOCKER_COMPOSE_FILE" --profile "$COMPOSE_PROFILE" logs hs1
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
}
|
||||
|
||||
# Cleanup function
|
||||
cleanup() {
|
||||
if [ "$CI" = true ]; then
|
||||
# Exit with the test result code
|
||||
if [ -n "${TEST_EXIT_CODE:-}" ]; then
|
||||
exit $TEST_EXIT_CODE
|
||||
fi
|
||||
fi
|
||||
|
||||
# Show container logs if tests failed
|
||||
if [ -n "${TEST_EXIT_CODE:-}" ] && [ "$TEST_EXIT_CODE" -ne 0 ]; then
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "CONTAINER LOGS (Test Failed)"
|
||||
echo "=========================================="
|
||||
|
||||
docker_logs
|
||||
fi
|
||||
|
||||
if [ "$KEEP_RUNNING" = true ]; then
|
||||
log_info "Keeping Docker containers running (--keep-running flag set)"
|
||||
log_info "Services are available at:"
|
||||
log_info " - Rocket.Chat: https://rc1"
|
||||
log_info " - Synapse: https://hs1"
|
||||
log_info " - MongoDB: localhost:27017"
|
||||
if [ "$INCLUDE_ELEMENT" = true ]; then
|
||||
log_info " - Element: https://element"
|
||||
fi
|
||||
log_info "To stop containers manually, run: docker compose -f \"$DOCKER_COMPOSE_FILE\" --profile \"$COMPOSE_PROFILE\" down -v"
|
||||
else
|
||||
log_info "Cleaning up services..."
|
||||
|
||||
docker compose -f "$DOCKER_COMPOSE_FILE" --profile "$COMPOSE_PROFILE" down -v 2>/dev/null || true
|
||||
|
||||
log_success "Cleanup completed"
|
||||
fi
|
||||
|
||||
# Remove temporary build directory if it exists
|
||||
if [ -n "${BUILD_DIR:-}" ] && [ -d "$BUILD_DIR" ]; then
|
||||
rm -rf "$BUILD_DIR" || true
|
||||
fi
|
||||
|
||||
# Exit with the test result code
|
||||
if [ -n "${TEST_EXIT_CODE:-}" ]; then
|
||||
exit $TEST_EXIT_CODE
|
||||
fi
|
||||
}
|
||||
|
||||
# Setup signal handlers for cleanup
|
||||
trap cleanup EXIT TERM
|
||||
|
||||
# Handle interrupt signal (Ctrl+C) immediately
|
||||
trap 'INTERRUPTED=true; log_info "Received interrupt signal (Ctrl+C), stopping..."; cleanup; exit 130' INT
|
||||
|
||||
# Check if docker-compose.test.yml exists
|
||||
if [ ! -f "$DOCKER_COMPOSE_FILE" ]; then
|
||||
log_error "docker-compose.test.yml not found at $DOCKER_COMPOSE_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$INCLUDE_ELEMENT" = true ]; then
|
||||
COMPOSE_PROFILE="element"
|
||||
else
|
||||
COMPOSE_PROFILE="test"
|
||||
fi
|
||||
|
||||
if [ "$START_CONTAINERS" = true ]; then
|
||||
# Build Rocket.Chat locally if not using pre-built image
|
||||
if [ "$USE_PREBUILT_IMAGE" = false ]; then
|
||||
log_info "🚀 Building Rocket.Chat locally..."
|
||||
log_info "====================================="
|
||||
|
||||
# Clean up any existing build
|
||||
log_info "Cleaning up previous build..."
|
||||
rm -rf "$BUILD_DIR"
|
||||
|
||||
# Build the project
|
||||
log_info "Building packages from project root..."
|
||||
cd "$ROCKETCHAT_ROOT"
|
||||
yarn build
|
||||
|
||||
# Build the Meteor bundle (must be run from the meteor directory)
|
||||
log_info "Building Meteor bundle..."
|
||||
cd "$ROCKETCHAT_ROOT/apps/meteor"
|
||||
METEOR_DISABLE_OPTIMISTIC_CACHING=1 meteor build --server-only --directory "$BUILD_DIR"
|
||||
|
||||
log_success "Build completed!"
|
||||
else
|
||||
log_info "🚀 Using pre-built image: $PREBUILT_IMAGE"
|
||||
log_info "====================================="
|
||||
fi
|
||||
|
||||
log_info "🚀 Starting Federation Integration Tests"
|
||||
log_info "====================================="
|
||||
|
||||
BUILD_PARAM=""
|
||||
|
||||
# Set environment variables for Docker Compose
|
||||
if [ "$USE_PREBUILT_IMAGE" = true ]; then
|
||||
export ROCKETCHAT_IMAGE="$PREBUILT_IMAGE"
|
||||
log_info "Using pre-built image: $PREBUILT_IMAGE"
|
||||
else
|
||||
export ROCKETCHAT_BUILD_CONTEXT="$BUILD_DIR"
|
||||
export ROCKETCHAT_DOCKERFILE="$ROCKETCHAT_ROOT/apps/meteor/.docker/Dockerfile.alpine"
|
||||
BUILD_PARAM="--build"
|
||||
log_info "Building from local context: $BUILD_DIR"
|
||||
fi
|
||||
|
||||
# Start services
|
||||
if [ "$INCLUDE_ELEMENT" = true ]; then
|
||||
log_info "Starting all federation services including Element web client..."
|
||||
else
|
||||
log_info "Starting federation services (test profile only)..."
|
||||
fi
|
||||
|
||||
docker compose -f "$DOCKER_COMPOSE_FILE" --profile "$COMPOSE_PROFILE" up -d $BUILD_PARAM
|
||||
|
||||
# Wait for rc1 container to be running
|
||||
log_info "Waiting for rc1 container to start..."
|
||||
timeout=60
|
||||
while [ $timeout -gt 0 ] && [ "$INTERRUPTED" = false ]; do
|
||||
if docker compose -f "$DOCKER_COMPOSE_FILE" ps rc1 --filter "status=running" | grep -q "rc1"; then
|
||||
log_success "rc1 container is running"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
timeout=$((timeout - 2))
|
||||
done
|
||||
|
||||
if [ "$INTERRUPTED" = true ]; then
|
||||
log_info "Container startup interrupted by user"
|
||||
exit 130
|
||||
fi
|
||||
|
||||
if [ $timeout -le 0 ]; then
|
||||
log_error "rc1 container failed to start within 60 seconds"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Wait for both Rocket.Chat and Synapse to be ready
|
||||
log_info "Waiting for Rocket.Chat and Synapse servers to be ready..."
|
||||
|
||||
# Function to wait for a service to be ready
|
||||
wait_for_service() {
|
||||
local url=$1
|
||||
local name=$2
|
||||
local host=$3
|
||||
local elapsed=0
|
||||
local ca_cert="${CA_CERT:-$PACKAGE_ROOT/docker-compose/traefik/certs/ca/rootCA.crt}"
|
||||
|
||||
# Derive host/port from URL when not explicitly provided
|
||||
local host_with_port="${url#*://}"
|
||||
host_with_port="${host_with_port%%/*}"
|
||||
if [ -z "$host" ]; then
|
||||
host="${host_with_port%%:*}"
|
||||
fi
|
||||
local port
|
||||
if [[ "$host_with_port" == *:* ]]; then
|
||||
port="${host_with_port##*:}"
|
||||
else
|
||||
if [[ "$url" == https://* ]]; then
|
||||
port=443
|
||||
else
|
||||
port=80
|
||||
fi
|
||||
fi
|
||||
|
||||
log_info "Checking $name at $url (host $host -> 127.0.0.1:$port)..."
|
||||
|
||||
while [ $elapsed -lt $MAX_WAIT_TIME ] && [ "$INTERRUPTED" = false ]; do
|
||||
# Capture curl output and error for debugging
|
||||
curl_output=$(curl -fsS --cacert "$ca_cert" --resolve "${host}:${port}:127.0.0.1" "$url" 2>&1)
|
||||
curl_exit_code=$?
|
||||
|
||||
if [ $curl_exit_code -eq 0 ]; then
|
||||
log_success "$name is ready!"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "$name not ready yet, waiting... (${elapsed}s/${MAX_WAIT_TIME}s)"
|
||||
log_info "Curl error: $curl_output"
|
||||
sleep $CHECK_INTERVAL
|
||||
elapsed=$((elapsed + CHECK_INTERVAL))
|
||||
done
|
||||
|
||||
if [ "$INTERRUPTED" = true ]; then
|
||||
log_info "Service check interrupted by user"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_error "$name failed to become ready within ${MAX_WAIT_TIME} seconds"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Wait for Rocket.Chat
|
||||
if ! wait_for_service "https://rc1/api/info" "Rocket.Chat" "rc1"; then
|
||||
log_error "Last 50 lines of rc1 logs:"
|
||||
docker compose -f "$DOCKER_COMPOSE_FILE" logs --tail 50 rc1
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Wait for Synapse
|
||||
if ! wait_for_service "https://hs1/_matrix/client/versions" "Synapse" "hs1"; then
|
||||
log_error "Last 50 lines of hs1 logs:"
|
||||
docker compose -f "$DOCKER_COMPOSE_FILE" logs --tail 50 hs1
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run the end-to-end tests
|
||||
if [ "$NO_TEST" = false ]; then
|
||||
log_info "Running end-to-end tests..."
|
||||
cd "$PACKAGE_ROOT"
|
||||
|
||||
set +e
|
||||
IS_EE=true NODE_EXTRA_CA_CERTS=$(pwd)/docker-compose/traefik/certs/ca/rootCA.crt yarn test:federation
|
||||
TEST_EXIT_CODE=$?
|
||||
set -e
|
||||
elif [ "$LOGS" = true ]; then
|
||||
docker_logs
|
||||
exit 0
|
||||
else
|
||||
log_info "No-test mode: skipping test execution"
|
||||
log_info "Services are ready and running. You can now:"
|
||||
log_info " - Access Rocket.Chat at: https://rc1"
|
||||
log_info " - Access Synapse at: https://hs1"
|
||||
log_info " - Access MongoDB at: localhost:27017"
|
||||
if [ "$INCLUDE_ELEMENT" = true ]; then
|
||||
log_info " - Access Element at: https://element"
|
||||
fi
|
||||
log_info ""
|
||||
log_info "To run tests manually, execute: yarn testend-to-end"
|
||||
log_info "To stop containers, use: docker compose -f $DOCKER_COMPOSE_FILE down"
|
||||
TEST_EXIT_CODE=0
|
||||
fi
|
||||
20
ee/packages/federation-matrix/tests/teardown.ts
Normal file
20
ee/packages/federation-matrix/tests/teardown.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Global teardown for Jest federation tests.
|
||||
*
|
||||
* Ensures that any open connections or handles are properly closed
|
||||
* to prevent Jest from hanging. This is particularly important for
|
||||
* Matrix SDK connections and other long-lived resources that may
|
||||
* prevent Jest from exiting cleanly.
|
||||
*
|
||||
* @returns Promise that resolves when cleanup is complete
|
||||
*/
|
||||
export default async function globalTeardown() {
|
||||
// Force close any remaining open handles
|
||||
// This is particularly important for Matrix SDK connections
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
}
|
||||
|
||||
// Give a small delay to allow cleanup
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
10
ee/packages/federation-matrix/tsconfig.build.json
Normal file
10
ee/packages/federation-matrix/tsconfig.build.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"],
|
||||
}
|
||||
9
ee/packages/federation-matrix/tsconfig.json
Normal file
9
ee/packages/federation-matrix/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@rocket.chat/tsconfig/server.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"strictPropertyInitialization": false,
|
||||
"experimentalDecorators": true,
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
}
|
||||
Reference in New Issue
Block a user