Compare commits

..

26 Commits

Author SHA1 Message Date
Jade Ellis a72eda19f1 chore: Release 2026-06-17 15:12:50 +01:00
Jade Ellis 4fc808114f chore: Release 2026-06-17 15:05:23 +01:00
Ginger e3a9549824 fix: Correctly sync newly created rooms 2026-06-16 23:41:51 -04:00
Ginger 073c033ab8 fix: Don't panic on missing SSH in sliding sync 2026-06-16 23:41:51 -04:00
Ginger 6e42be95bc fix: Additional sync logic fixes 2026-06-16 23:41:51 -04:00
Ginger 1a77f57af5 fix: Upgrade warning on room load failures to error 2026-06-16 23:41:51 -04:00
Ginger d427df0238 fix: Don't panic on missing SSH 2026-06-16 23:41:51 -04:00
Ginger f9f3ebe571 fix: Calculate state at end of last sync correctly 2026-06-16 23:41:51 -04:00
Ginger 5969c1ae94 chore: News fragments 2026-06-16 23:41:51 -04:00
Ginger 523016a42b feat: Add support for state_after 2026-06-16 23:41:51 -04:00
Ginger 7c42f6075b feat: Remove all uses of roomsynctoken_shortstatehash 2026-06-16 23:41:51 -04:00
Renovate Bot bc37f7fc5b chore(deps): update rust crate serde-saphyr to 0.0.27 2026-06-16 18:29:02 +00:00
Ginger 0932c929c3 chore: Clippy fixes >:( 2026-06-16 12:36:24 -04:00
Ginger 8f4e95b4b9 fix: Fix compile errors caused by hickory upgrade 2026-06-16 12:19:37 -04:00
Renovate Bot e84d1f02af chore(deps): lock file maintenance 2026-06-16 12:57:54 +00:00
Renovate Bot 772a326ac1 chore(deps): update hickory-dns monorepo to 0.26.0 2026-06-16 12:51:12 +00:00
Renovate Bot 6276a632cc chore(deps): update github-actions-digest 2026-06-16 05:02:02 +00:00
ginger bb48bd50bb chore: Prek fixes 2026-06-15 16:59:57 +00:00
Cease 3fb7586875 docs: remove network tags from config and improve clarity on comments 2026-06-15 16:59:57 +00:00
Cease e8cfde49ae docs: additional livekit caddy-docker-proxy cleanup 2026-06-15 16:59:57 +00:00
Cease 7af4b392b3 docs: clean up caddy-docker-proxy section as requested 2026-06-15 16:59:57 +00:00
Cease 2bdc498f18 docs: add example caddy-docker-proxy configuration 2026-06-15 16:59:57 +00:00
Renovate Bot 9dfd143cc6 chore(deps): update dependency cargo-bins/cargo-binstall to v1.20.0 2026-06-15 13:01:48 +00:00
Renovate Bot 721ebbf340 chore(deps): update rust-non-major 2026-06-15 13:01:35 +00:00
Renovate Bot 0a5d136a32 chore(deps): update github-actions-non-major to v43.222.1 2026-06-15 05:03:07 +00:00
Jonathan Bouligny 0ece17b6a0 fix: Trim whitespace in is_admin_command
Lets users run commands even with a space before the !admin prefix.
2026-06-11 15:20:34 +00:00
171 changed files with 2343 additions and 8135 deletions
+1 -1
View File
@@ -71,7 +71,7 @@ runs:
- name: Install timelord-cli and git-warp-time
if: steps.check-binaries.outputs.need-install == 'true'
uses: https://github.com/taiki-e/install-action@e49978b799e49ff429d162b7a30601a569ab6538 # v2
uses: https://github.com/taiki-e/install-action@15449e3094499af05d8d964a1c884208e4b8b595 # v2
with:
tool: git-warp-time,timelord-cli@3.0.1
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
fi
- name: Checkout repository with full history
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
ref: ${{ github.ref_name }}
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
echo "Fedora version: $VERSION"
- name: Checkout repository with full history
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
ref: ${{ github.ref_name }}
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Sync repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
fetch-depth: 0
+2 -2
View File
@@ -41,7 +41,7 @@ jobs:
DOCKER_MIRROR_TOKEN: ${{ secrets.DOCKER_MIRROR_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
@@ -55,7 +55,7 @@ jobs:
# repositories: continuwuity
- name: Install regsync
uses: https://github.com/regclient/actions/regsync-installer@c70ad64367908075211b10dcd2ab9fad4bfa1816 # main
uses: https://github.com/regclient/actions/regsync-installer@14f9d37db17b5dc41fefd1ffdd1af4b9e2490560 # main
- name: Check what images need mirroring
run: |
+3 -3
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
@@ -48,7 +48,7 @@ jobs:
rust: ${{ steps.filter.outputs.rust }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
@@ -70,7 +70,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
+4 -4
View File
@@ -46,7 +46,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Prepare Docker build environment
@@ -100,7 +100,7 @@ jobs:
needs: build-release
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Create multi-platform manifest
@@ -133,7 +133,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Prepare max-perf Docker build environment
@@ -187,7 +187,7 @@ jobs:
needs: build-maxperf
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Create max-perf manifest
+2 -2
View File
@@ -43,11 +43,11 @@ jobs:
name: Renovate
runs-on: ubuntu-latest
container:
image: ghcr.io/renovatebot/renovate:43.214.6@sha256:fd228b92f067204e444ddea1ec2fefb007592f9a46845e966f9334d5bd4bb52c
image: ghcr.io/renovatebot/renovate:43.222.1@sha256:b9af3f59f3f4d92b2c41e9f4ca3ffe92400503f20158d0bd67d07a3fdbe781d2
options: --tmpfs /tmp:exec
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
show-progress: false
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
update-flake-hashes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: true
token: ${{ secrets.FORGEJO_TOKEN }}
Generated
+144 -268
View File
File diff suppressed because it is too large Load Diff
+5 -9
View File
@@ -12,7 +12,7 @@ license = "Apache-2.0"
# See also `rust-toolchain.toml`
readme = "README.md"
repository = "https://forgejo.ellis.link/continuwuation/continuwuity"
version = "0.5.9"
version = "26.6.0-alpha.1"
[workspace.metadata.crane]
name = "conduwuit"
@@ -164,7 +164,7 @@ features = ["raw_value"]
# Used for appservice registration files
[workspace.dependencies.serde-saphyr]
version = "0.0.26"
version = "0.0.27"
# Used to load forbidden room/user regex from config
[workspace.dependencies.serde_regex]
@@ -296,7 +296,7 @@ default-features = false
features = ["env", "toml"]
[workspace.dependencies.hickory-resolver]
version = "0.25.2"
version = "0.26.0"
default-features = false
features = [
"serde",
@@ -343,8 +343,8 @@ version = "1.1.1"
# Used for matrix spec type definitions and helpers
[workspace.dependencies.ruma]
# version = "0.14.1"
git = "https://github.com/gingershaped/ruwuma.git"
rev = "a0178c4e5e1729d27cf2f1c4dacf77b763987749"
git = "https://github.com/ruma/ruma.git"
rev = "3ecd80b92794d2d93f657a7b3db62d4be237526b"
features = [
"appservice-api-c",
"client-api",
@@ -379,7 +379,6 @@ features = [
"unstable-msc4406",
"unstable-msc4439",
"unstable-msc4466",
"unstable-msc4484",
"unstable-extensible-events",
]
@@ -560,9 +559,6 @@ features = ["std"]
[workspace.dependencies.nonzero_ext]
version = "0.3.0"
[workspace.dependencies.serde_urlencoded]
version = "0.7.1"
#
# Patches
#
-1
View File
@@ -1 +0,0 @@
Users may now be forbidden from deactivating their own accounts with the new `allow_deactivation` config option. Contributed by @ginger.
+1
View File
@@ -0,0 +1 @@
Added support for Matrix 1.16's `state_after` feature, allowing clients which understand it to sync room state changes more reliably. Contributed by @ginger.
-1
View File
@@ -1 +0,0 @@
Added support for authenticating clients using the new OAuth 2.0 login API. Contributed by @ginger.
+1
View File
@@ -0,0 +1 @@
Added example configuration using caddy-docker-proxy in the livekit setup section of the docs. Contributed by @Cease
+1
View File
@@ -0,0 +1 @@
Fixed admin commands being ignored when they had leading whitespace before admin commands. Contributed by @kitvonsnookerz.
+1
View File
@@ -0,0 +1 @@
Adjusted legacy sync logic to no longer use the `roomsynctoken_shortstatehash` database column. Once this change has been confirmed to be stable and reliable, a future update will remove it entirely, significantly decreasing database sizes. Contributed by @ginger.
+9 -37
View File
@@ -521,15 +521,17 @@
#
#recaptcha_private_site_key =
# Controls whether users are allowed to deactivate their own accounts
# through the account management panel or their Matrix clients. Server
# admins can always deactivate users using the relevant admin commands.
# Policy documents, such as terms and conditions or a privacy policy,
# which users must agree to when registering an account.
#
# Note that, in some jurisdictions, you may be legally required to honor
# users who request to deactivate their accounts if you set this option
# to `false`.
# Example:
# ```ignore
# [global.registration_terms.privacy_policy]
# en = { name = "Privacy Policy", url = "https://homeserver.example/en/privacy_policy.html" }
# es = { name = "Política de Privacidad", url = "https://homeserver.example/es/privacy_policy.html" }
# ```
#
#allow_deactivation = true
#registration_terms = {}
# Controls whether encrypted rooms and events are allowed.
#
@@ -1985,33 +1987,3 @@
# `require_email_for_registration`.
#
#require_email_for_token_registration = false
#[global.registration_terms]
# The language code to provide to clients along with the policy documents.
#
#language = "en"
# Policy documents, such as terms and conditions or a privacy policy,
# which users must agree to when registering an account.
#
# Example:
# ```ignore
# [global.registration_terms.documents]
# privacy_policy = { name = "Privacy Policy", url = "https://homeserver.example/en/privacy_policy.html" }
# ```
#
#documents = {}
#[global.oauth]
# The compatibility mode to use for OAuth.
#
# - "disabled": OAuth will be unavailable. Users will only be able to log
# in using legacy authentication.
# - "hybrid": OAuth and legacy authentication will both be available. Some
# clients may only use one or the other.
# - "exclusive": Only OAuth will be available. Clients which require
# legacy authentication will be unable to log in.
#
#compatibility_mode = "hybrid"
+1 -1
View File
@@ -50,7 +50,7 @@ EOF
# Developer tool versions
# renovate: datasource=github-releases depName=cargo-bins/cargo-binstall
ENV BINSTALL_VERSION=1.19.1
ENV BINSTALL_VERSION=1.20.0
# renovate: datasource=github-releases depName=psastras/sbom-rs
ENV CARGO_SBOM_VERSION=0.9.1
# renovate: datasource=crate depName=lddtree
+1 -1
View File
@@ -18,7 +18,7 @@ RUN --mount=type=cache,target=/etc/apk/cache apk add \
# Developer tool versions
# renovate: datasource=github-releases depName=cargo-bins/cargo-binstall
ENV BINSTALL_VERSION=1.19.1
ENV BINSTALL_VERSION=1.20.0
# renovate: datasource=github-releases depName=psastras/sbom-rs
ENV CARGO_SBOM_VERSION=0.9.1
# renovate: datasource=crate depName=lddtree
+69
View File
@@ -187,6 +187,75 @@ ### 4. Configure your Reverse Proxy
```
</details>
<details>
<summary>Example docker compose file with caddy-docker-proxy labels</summary>
```yaml
# This setup assumes all containers share the same bridge network
services:
lk-jwt-service:
image: ghcr.io/element-hq/lk-jwt-service:latest
container_name: lk-jwt-service
# lk-jwt-service environment config here..
labels:
caddy: livekit.example.com
caddy.@lk-jwt-service.path: "/sfu/get* /healthz* /get_token*"
caddy.reverse_proxy: "@lk-jwt-service {{upstreams 8081}}"
livekit:
image: livekit/livekit-server:latest
container_name: livekit
command: --config /etc/livekit.yaml
restart: unless-stopped
labels:
caddy: livekit.example.com
caddy.reverse_proxy: "{{upstreams 7880}}"
volumes:
- ./livekit.yaml:/etc/livekit.yaml:ro
ports:
- "127.0.0.1:7880:7880/tcp"
- "7881:7881/tcp"
- "50100-50200:50100-50200/udp"
caddy:
image: lucaslorentz/caddy-docker-proxy:ci-alpine
ports:
- 80:80
- 443:443
environment:
- CADDY_INGRESS_NETWORKS=caddy
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./data:/data
restart: unless-stopped
labels:
# If you already configured `[global.well_known]` with Continuwuity,
# comment out the *_respond labels and add this line
# caddy.reverse_proxy: /.well-known/matrix/* homeserver:8008
caddy.1_respond: /.well-known/matrix/server {"m.server":"matrix.example.com:443"}
caddy.2_respond: /.well-known/matrix/client {"m.server":{"base_url":"https://matrix.example.com"},"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://livekit.example.com"}]}
# If you are having problems with continuwuity serving headers uncomment
# the header section below.
# caddy: example.com
# caddy.0_header: "*"
# caddy.0_header.Access-Control-Allow-Origin: "*"
# caddy.0_header.Access-Control-Allow-Methods: "GET, POST, OPTIONS"
# caddy.0_header.Access-Control-Allow-Headers: "Authorization"
# caddy.0_header.Content-Type: "application/json"
homeserver:
image: forgejo.ellis.link/continuwuation/continuwuity:latest
restart: unless-stopped
# add additional environment, volume, and network config here...
labels:
caddy: matrix.example.com
caddy.reverse_proxy: "{{upstreams 8008}}"
```
</details>
### 6. Start Everything
Generated
+18 -18
View File
@@ -3,11 +3,11 @@
"advisory-db": {
"flake": false,
"locked": {
"lastModified": 1779575509,
"narHash": "sha256-wXKYURZz76ZC5lbuDA1oVQA/MxSB3pSJ1raF1HG0oIc=",
"lastModified": 1781566179,
"narHash": "sha256-Tqv8I586fYzWpEW/Smq/JqESFa3DVVzVWsnAMtvhy/I=",
"owner": "rustsec",
"repo": "advisory-db",
"rev": "831c50f4a4304068f125e603add6a8839f08b3eb",
"rev": "74e084413d979d52d2f93b1d93b1ab7b9ee648f5",
"type": "github"
},
"original": {
@@ -18,11 +18,11 @@
},
"crane": {
"locked": {
"lastModified": 1779130139,
"narHash": "sha256-BLrtr42azquO7MdGFU5a7KiMl3YpFlTeIXqy1fT5GlQ=",
"lastModified": 1780532242,
"narHash": "sha256-D+BsdpxmtUwtqGoY0IXPhHgTlmqgcZKCEo1oMyn7ep0=",
"owner": "ipetkov",
"repo": "crane",
"rev": "edb38893982a3338972bb4a2ec7ce7c29ba10fd9",
"rev": "59a82a1222dd3b2080b5cc52a1a2e8d5f1b77f37",
"type": "github"
},
"original": {
@@ -39,11 +39,11 @@
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1779612045,
"narHash": "sha256-+7lfNVnmXJDkiRYHd5NoNwYoyUcc0LcXPaIJqjO7VWM=",
"lastModified": 1781527054,
"narHash": "sha256-1fX9ev2Fh5QoKQ41G9dYutjo5j/jywu6tZse5Eb1Ck4=",
"owner": "nix-community",
"repo": "fenix",
"rev": "d7be747f0a65af378de515fc3cee131bf99a008f",
"rev": "8c2e51dffefc040a21975da7abf6f252c8c9b783",
"type": "github"
},
"original": {
@@ -89,11 +89,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1779508470,
"narHash": "sha256-Ap9KJX+5xHIn3bPIpfNgT6MEXdAECECwo4/rmlQD74M=",
"lastModified": 1781074563,
"narHash": "sha256-md8WlXOlfnIeHeOScMTTHFyf2d6iaTwPl2apR5EQ3P4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "29916453413845e54a65b8a1cf996842300cd299",
"rev": "9ae611a455b90cf061d8f332b977e387bda8e1ca",
"type": "github"
},
"original": {
@@ -132,11 +132,11 @@
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1779569060,
"narHash": "sha256-NSnk5D+3KEfRdbgPijs33N2RAKSG6A74SwfnynLcouo=",
"lastModified": 1781453968,
"narHash": "sha256-+V3nK4pCngbmgyVGXY6Kkrlevp4ocPkJJLf2aqwkDNA=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "987ea33645ab1c709b1df6823038abcb2fe8973e",
"rev": "cc272809a173c2c11d0e479d639c811c1eacf049",
"type": "github"
},
"original": {
@@ -153,11 +153,11 @@
]
},
"locked": {
"lastModified": 1775636079,
"narHash": "sha256-pc20NRoMdiar8oPQceQT47UUZMBTiMdUuWrYu2obUP0=",
"lastModified": 1780220602,
"narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "790751ff7fd3801feeaf96d7dc416a8d581265ba",
"rev": "db947814a175b7ca6ded66e21383d938df01c227",
"type": "github"
},
"original": {
+174 -125
View File
@@ -125,13 +125,13 @@
}
},
"node_modules/@rsbuild/core": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@rsbuild/core/-/core-2.0.11.tgz",
"integrity": "sha512-Mpp/viUSkVdSWJkFipdZxM2nUztrBwSnMm6Q86bPzLHtHnXqQ3VFpSMlA4wWRyySNddP6s6efKiVpx0ZOCf7Gg==",
"version": "2.0.15",
"resolved": "https://registry.npmjs.org/@rsbuild/core/-/core-2.0.15.tgz",
"integrity": "sha512-O8vmMhZu1YImO6jOqt/K/vlJSvkq7UtSq5YM1DIlcEd9LW8Gf6/dkQ1B2KPI6F+hSMFBnTTTumdcIowSLCw97g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rspack/core": "~2.0.6",
"@rspack/core": "~2.0.8",
"@swc/helpers": "^0.5.23"
},
"bin": {
@@ -169,28 +169,28 @@
}
},
"node_modules/@rspack/binding": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-2.0.6.tgz",
"integrity": "sha512-z5EO9mPlmYNpHAlRGub0Chr6D+Klgy+tX36n7tCm7VRGRlwTmTU9wSENrYbHcCpFbegtrE0s30rDeTBeOu+JiQ==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-2.0.8.tgz",
"integrity": "sha512-3uZ+y8aQxq33ty2srMxg2Nu0XuBI6vVrG50rkDaXqwWqOohfgGUSfFuQK7EnSUNy4aFUQlCG6NHialQHJov0wg==",
"dev": true,
"license": "MIT",
"optionalDependencies": {
"@rspack/binding-darwin-arm64": "2.0.6",
"@rspack/binding-darwin-x64": "2.0.6",
"@rspack/binding-linux-arm64-gnu": "2.0.6",
"@rspack/binding-linux-arm64-musl": "2.0.6",
"@rspack/binding-linux-x64-gnu": "2.0.6",
"@rspack/binding-linux-x64-musl": "2.0.6",
"@rspack/binding-wasm32-wasi": "2.0.6",
"@rspack/binding-win32-arm64-msvc": "2.0.6",
"@rspack/binding-win32-ia32-msvc": "2.0.6",
"@rspack/binding-win32-x64-msvc": "2.0.6"
"@rspack/binding-darwin-arm64": "2.0.8",
"@rspack/binding-darwin-x64": "2.0.8",
"@rspack/binding-linux-arm64-gnu": "2.0.8",
"@rspack/binding-linux-arm64-musl": "2.0.8",
"@rspack/binding-linux-x64-gnu": "2.0.8",
"@rspack/binding-linux-x64-musl": "2.0.8",
"@rspack/binding-wasm32-wasi": "2.0.8",
"@rspack/binding-win32-arm64-msvc": "2.0.8",
"@rspack/binding-win32-ia32-msvc": "2.0.8",
"@rspack/binding-win32-x64-msvc": "2.0.8"
}
},
"node_modules/@rspack/binding-darwin-arm64": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-2.0.6.tgz",
"integrity": "sha512-0giCKiWlBfcM4i2scv1j2k9HlSecO9Ybhaa5wsMUyvcFeKr9HbNHh7C2eDFlC6zaI85IUdY71TXF/g/Tcxr9MA==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-2.0.8.tgz",
"integrity": "sha512-vCgbgH7B7qom+uID+RCZsTCOYFb9wC4/4+1U6rMfytrXGVJ72eNQs2tbdjOl0lb18CT3N/n+VkWynUiLk84GwA==",
"cpu": [
"arm64"
],
@@ -202,9 +202,9 @@
]
},
"node_modules/@rspack/binding-darwin-x64": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-2.0.6.tgz",
"integrity": "sha512-/mMo2IpI02aOKMlHbVbZue3TJxFqHGX+ibVTdEO+6bzRSuHs7+R9KM5U3XH2YxcWJy5Sid1X1T1pJAjsXcE3rA==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-2.0.8.tgz",
"integrity": "sha512-satPm2PD4B7jDTVlVAdvMVdUszwLvWUEnUDzLb77mvVkezKNDZmuhb+e8s+FfKs8hJpNbZ9VAejuA2rr8o985w==",
"cpu": [
"x64"
],
@@ -216,9 +216,9 @@
]
},
"node_modules/@rspack/binding-linux-arm64-gnu": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-2.0.6.tgz",
"integrity": "sha512-H6ACzeM1KBxYDEF8YAim3501Jb1aCsSG79Gjm1M4pwJ5OJPK2ydiJEa438ugXmh0962eKYMHI2yZY0sQq8txaw==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-2.0.8.tgz",
"integrity": "sha512-pSI+npPQE/uDtiboqvcOIRJbEV2+B+H1xffmko/gw50la92oTUW60kVULFwsb6L0+GVCzIcwX3yq60GtYIn+Ug==",
"cpu": [
"arm64"
],
@@ -233,9 +233,9 @@
]
},
"node_modules/@rspack/binding-linux-arm64-musl": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-2.0.6.tgz",
"integrity": "sha512-QTFmBg0n+L397Wi8CIjbd5pe/hxpHnqCDaG1A7e2NWX8Fj9zulAoKLiKflQa1ELEhAY4Foq88aX75+Ilt2tHcw==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-2.0.8.tgz",
"integrity": "sha512-igjJ43yxWQ72GZqjDDZSSHax9/Vg+6rLMmOvFglTJUkQpB4Tyvu/YjW+WRjYj2xRw6blOjLxUSJWASvuSqqlvg==",
"cpu": [
"arm64"
],
@@ -250,9 +250,9 @@
]
},
"node_modules/@rspack/binding-linux-x64-gnu": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-2.0.6.tgz",
"integrity": "sha512-rerCAz022zf0ewxI+7n3SrqLEaxCL+MXRxKjK5FLUGFa8UkIrivq+VUP/1OB6JLh2Bucebc7Y9WoWHvtk22mLA==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-2.0.8.tgz",
"integrity": "sha512-zrkoEOnqj1hOEBO5T2I/2Ts2HSJsYFh1qXwMpK4dMJFGGNWDfNeUa6/LF5uq3VINF3JUl7RL47AgrucoSZJXPA==",
"cpu": [
"x64"
],
@@ -267,9 +267,9 @@
]
},
"node_modules/@rspack/binding-linux-x64-musl": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-2.0.6.tgz",
"integrity": "sha512-96IgOFXQjX6Wbxd+DCYJFy2r/VMu1OoHifW4Cr3kGTYDKoQOIMLwb0ieu/ILp2dGWFMZo5S8odiByAmNICAOIA==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-2.0.8.tgz",
"integrity": "sha512-6CtDaGZjNDvJd9TBp7a9zABbrPORO21W96+3ZcGBn0YNUPUk4ARxIxrTTpeJ/1F41QDM8AYIkGDdqEYMqTYBsA==",
"cpu": [
"x64"
],
@@ -284,9 +284,9 @@
]
},
"node_modules/@rspack/binding-wasm32-wasi": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-2.0.6.tgz",
"integrity": "sha512-0aWiF+qmdb0csp1x+MaR2o1pscoquLaEbLTVdKjmoTRs6sguMemtB1ObnVTahAUL73P66WePuNpFAJ81zNdqzQ==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-2.0.8.tgz",
"integrity": "sha512-Yf4SiqTUroT5Ju+te0YAY2xxKOb35tECsO21v7hYyGa705wrgoAK/MmF7enOvs9GR1iZIqgiLD/wxsIxl8GjJw==",
"cpu": [
"wasm32"
],
@@ -300,9 +300,9 @@
}
},
"node_modules/@rspack/binding-win32-arm64-msvc": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-2.0.6.tgz",
"integrity": "sha512-BX638A1MXsjc2E3tUskVh3X/WBIHjLKK+lo395v7MmEL9u2BA6l3F6RyW+YaJOt5aEOOv83iA7iCZsviVZ49Uw==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-2.0.8.tgz",
"integrity": "sha512-8NCuiQsAhXrwRBy57QZoypqrws/zLBkaQVGiB8hksr6v++8hNigNjqpQARLbd0iyMuHsQQ++8+auGk6xlDXmzw==",
"cpu": [
"arm64"
],
@@ -314,9 +314,9 @@
]
},
"node_modules/@rspack/binding-win32-ia32-msvc": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-2.0.6.tgz",
"integrity": "sha512-DCK/+MlN35uvH7tp4j0hbg8wIs9MHArMIrNZXtiD8xP6DNw2wrXcGC1VaxxR5apyWpqXAfIL/KsXBiWS3ygCvg==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-2.0.8.tgz",
"integrity": "sha512-bxiekytbX7V9KFAra+HkwtNWC6pYfHEBBZFpiT0xUs3mCFOmAAFVBsBSQsoCP9AdCEXoMAvNdnrHNw3iov4OZw==",
"cpu": [
"ia32"
],
@@ -328,9 +328,9 @@
]
},
"node_modules/@rspack/binding-win32-x64-msvc": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-2.0.6.tgz",
"integrity": "sha512-TxutgzdEX9BkAU/5liKxdQmggJ23INz7EZDWtzSJO6C2SiSYzTJdyPQDIJi1ddkM5TX/drzH184gAJMVOQefng==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-2.0.8.tgz",
"integrity": "sha512-7zPs8YCe/ZVJTwd+5lpB0CP0tkn2pONf/T1ycmVY76u21Nrwt8mXQGc/2yH2eWP4B7fikYBr3hGr7mpR2fajqQ==",
"cpu": [
"x64"
],
@@ -342,13 +342,13 @@
]
},
"node_modules/@rspack/core": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@rspack/core/-/core-2.0.6.tgz",
"integrity": "sha512-ronRqH1T2dYdMFVOQbGvDNxYaLugQK8qhNYYtS2DbOvPKQYvdIYWDenL9k/WV+hLoknnPWMn2ME2cKJcK3Po+g==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@rspack/core/-/core-2.0.8.tgz",
"integrity": "sha512-+NLGJf8gZxihDmMFzjlly3toc2SMjeDmuvz0/Cai9AMdV4F+Pqcnt2BA9V4e3SY2jmhJQtPwgyyLtR1RiJO77g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rspack/binding": "2.0.6"
"@rspack/binding": "2.0.8"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
@@ -474,14 +474,14 @@
}
},
"node_modules/@shikijs/core": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.1.0.tgz",
"integrity": "sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.2.0.tgz",
"integrity": "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@shikijs/primitive": "4.1.0",
"@shikijs/types": "4.1.0",
"@shikijs/primitive": "4.2.0",
"@shikijs/types": "4.2.0",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4",
"hast-util-to-html": "^9.0.5"
@@ -491,13 +491,13 @@
}
},
"node_modules/@shikijs/engine-javascript": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.1.0.tgz",
"integrity": "sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.2.0.tgz",
"integrity": "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og==",
"dev": true,
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.1.0",
"@shikijs/types": "4.2.0",
"@shikijs/vscode-textmate": "^10.0.2",
"oniguruma-to-es": "^4.3.6"
},
@@ -506,13 +506,13 @@
}
},
"node_modules/@shikijs/engine-oniguruma": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.1.0.tgz",
"integrity": "sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.2.0.tgz",
"integrity": "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.1.0",
"@shikijs/types": "4.2.0",
"@shikijs/vscode-textmate": "^10.0.2"
},
"engines": {
@@ -520,26 +520,26 @@
}
},
"node_modules/@shikijs/langs": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.1.0.tgz",
"integrity": "sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.2.0.tgz",
"integrity": "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.1.0"
"@shikijs/types": "4.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/primitive": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.1.0.tgz",
"integrity": "sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.2.0.tgz",
"integrity": "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.1.0",
"@shikijs/types": "4.2.0",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4"
},
@@ -548,16 +548,16 @@
}
},
"node_modules/@shikijs/rehype": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@shikijs/rehype/-/rehype-4.1.0.tgz",
"integrity": "sha512-HQwltCcO2/UiFz44/8whyji4rP1VghLu++MgvQn+lQA8/gvuycGkay8DH8o8VAOvLBDKGOkBEw7cC1Cm33GObQ==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@shikijs/rehype/-/rehype-4.2.0.tgz",
"integrity": "sha512-ST3EWye/dwF1gWskczJNBnwFtDzEQ9ceytXZtyc/GfwR5V0qJrkoSGZO55O3SAKDDsXkTDcsfwd9pVe7ROlAHg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.1.0",
"@shikijs/types": "4.2.0",
"@types/hast": "^3.0.4",
"hast-util-to-string": "^3.0.1",
"shiki": "4.1.0",
"shiki": "4.2.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0"
},
@@ -566,22 +566,22 @@
}
},
"node_modules/@shikijs/themes": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.1.0.tgz",
"integrity": "sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.2.0.tgz",
"integrity": "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.1.0"
"@shikijs/types": "4.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/types": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.1.0.tgz",
"integrity": "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.2.0.tgz",
"integrity": "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -668,9 +668,9 @@
}
},
"node_modules/@types/mdx": {
"version": "2.0.13",
"resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz",
"integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==",
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.14.tgz",
"integrity": "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==",
"dev": true,
"license": "MIT"
},
@@ -682,9 +682,9 @@
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.15",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
"integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==",
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -723,9 +723,9 @@
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true,
"license": "MIT",
"bin": {
@@ -1821,6 +1821,53 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-to-markdown-cjk-friendly": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-to-markdown-cjk-friendly/-/mdast-util-to-markdown-cjk-friendly-1.0.0.tgz",
"integrity": "sha512-BoaAm8mlJ+LAYz0Qs532Y3ciTuQYgBUPZcSFbvC/ZKmEMAKgulw84YvQK1gI34t/vL2euSfuaWlqczkTBgamkw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdast-util-to-markdown": "^2.1.2",
"micromark-extension-cjk-friendly-util": "3.0.1",
"micromark-util-symbol": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/mdast": "*"
},
"peerDependenciesMeta": {
"@types/mdast": {
"optional": true
}
}
},
"node_modules/mdast-util-to-markdown-cjk-friendly-gfm-strikethrough": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-to-markdown-cjk-friendly-gfm-strikethrough/-/mdast-util-to-markdown-cjk-friendly-gfm-strikethrough-1.0.0.tgz",
"integrity": "sha512-1ePVfB4P/vz3xSsm6H3D32r6VYGErxclnuLLFK02/2ReF+UdEKm7caulK6Vm0LBIp5gPRtB2Z1OYDznCkX3k2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdast-util-gfm-strikethrough": "^2.0.0",
"mdast-util-to-markdown": "^2.1.2",
"micromark-extension-cjk-friendly-util": "3.0.1",
"micromark-util-symbol": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/mdast": "*"
},
"peerDependenciesMeta": {
"@types/mdast": {
"optional": true
}
}
},
"node_modules/mdast-util-to-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
@@ -2742,9 +2789,9 @@
}
},
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
"integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
"integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -2753,9 +2800,9 @@
}
},
"node_modules/react": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2763,16 +2810,16 @@
}
},
"node_modules/react-dom": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.6"
"react": "^19.2.7"
}
},
"node_modules/react-lazy-with-preload": {
@@ -2822,9 +2869,9 @@
}
},
"node_modules/react-router": {
"version": "7.15.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz",
"integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==",
"version": "7.17.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz",
"integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2845,13 +2892,13 @@
}
},
"node_modules/react-router-dom": {
"version": "7.15.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz",
"integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==",
"version": "7.17.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz",
"integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"react-router": "7.15.1"
"react-router": "7.17.0"
},
"engines": {
"node": ">=20.0.0"
@@ -3011,12 +3058,13 @@
}
},
"node_modules/remark-cjk-friendly": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/remark-cjk-friendly/-/remark-cjk-friendly-2.0.1.tgz",
"integrity": "sha512-6WwkoQyZf/4j5k53zdFYrR8Ca+UVn992jXdLUSBDZR4eBpFhKyVxmA4gUHra/5fesjGIxrDhHesNr/sVoiiysA==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/remark-cjk-friendly/-/remark-cjk-friendly-2.1.0.tgz",
"integrity": "sha512-ZWGDfTJNLEZ1gap+pd33K13ZhRAWgVDqxKA7JIlBs5IDu+qvbiWl/pEbeuxzRrWyrrkeFFoTnvNw00iW9mBcow==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdast-util-to-markdown-cjk-friendly": "1.0.0",
"micromark-extension-cjk-friendly": "2.0.1"
},
"engines": {
@@ -3033,12 +3081,13 @@
}
},
"node_modules/remark-cjk-friendly-gfm-strikethrough": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/remark-cjk-friendly-gfm-strikethrough/-/remark-cjk-friendly-gfm-strikethrough-2.0.1.tgz",
"integrity": "sha512-pWKj25O2eLXIL1aBupayl1fKhco+Brw8qWUWJPVB9EBzbQNd7nGLj0nLmJpggWsGLR5j5y40PIdjxby9IEYTuA==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/remark-cjk-friendly-gfm-strikethrough/-/remark-cjk-friendly-gfm-strikethrough-2.1.0.tgz",
"integrity": "sha512-3Kyq2hjY7V7eU8MbVbWW6QQLN81pjJcIvKHvPxr8hZZmcq/9wqm3MJ3iUG34Ch9QTM4WHN+a1JVAVC1fSi5mig==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdast-util-to-markdown-cjk-friendly-gfm-strikethrough": "1.0.0",
"micromark-extension-cjk-friendly-gfm-strikethrough": "2.0.1"
},
"engines": {
@@ -3164,18 +3213,18 @@
"license": "MIT"
},
"node_modules/shiki": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/shiki/-/shiki-4.1.0.tgz",
"integrity": "sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/shiki/-/shiki-4.2.0.tgz",
"integrity": "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@shikijs/core": "4.1.0",
"@shikijs/engine-javascript": "4.1.0",
"@shikijs/engine-oniguruma": "4.1.0",
"@shikijs/langs": "4.1.0",
"@shikijs/themes": "4.1.0",
"@shikijs/types": "4.1.0",
"@shikijs/core": "4.2.0",
"@shikijs/engine-javascript": "4.2.0",
"@shikijs/engine-oniguruma": "4.2.0",
"@shikijs/langs": "4.2.0",
"@shikijs/themes": "4.2.0",
"@shikijs/types": "4.2.0",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4"
},
+1 -1
View File
@@ -16,7 +16,7 @@
};
#[derive(Debug, Parser)]
#[command(name = conduwuit_core::BRANDING, version = conduwuit_core::version())]
#[command(name = conduwuit_core::name(), version = conduwuit_core::version())]
pub enum AdminCommand {
#[command(subcommand)]
/// Commands for managing appservices
+2 -25
View File
@@ -30,37 +30,14 @@ pub(super) async fn issue_token(&self, expires: super::TokenExpires) -> Result {
.issue_token(self.sender_or_service_user().into(), expires);
self.write_str(&format!(
"New registration token issued: `{token}` . {}.",
"New registration token issued: `{token}`. {}.",
if let Some(expires) = info.expires {
format!("{expires}")
} else {
"Never expires".to_owned()
}
))
.await?;
if self
.services
.config
.oauth
.compatibility_mode
.oauth_available()
{
self.write_str(&format!(
"\nInvite link using this token: {}",
self.services
.config
.get_client_domain()
.join(&format!(
"{}/account/register/?flow=trusted&token={token}",
conduwuit::ROUTE_PREFIX
))
.unwrap()
))
.await?;
}
Ok(())
.await
}
#[admin_command]
+149 -13
View File
@@ -1,10 +1,13 @@
use std::collections::{BTreeMap, HashSet};
use std::{
collections::{BTreeMap, HashSet},
fmt::Write as _,
};
use api::client::{
full_user_deactivate, leave_room, recreate_push_rules_and_return, remote_leave_room,
};
use conduwuit::{
Err, Result, debug_warn, info,
Err, Result, debug_warn, error, info,
matrix::{Event, pdu::PartialPdu},
utils::{self, ReadyExt},
warn,
@@ -50,22 +53,130 @@ pub(super) async fn list_users(&self) -> Result {
#[admin_command]
pub(super) async fn create_user(&self, username: String, password: Option<String>) -> Result {
// Validate user id
let user_id = self
.services
let user_id = parse_local_user_id(self.services, &username)?;
if let Err(e) = user_id.validate_strict() {
if self.services.config.emergency_password.is_none() {
return Err!("Username {user_id} contains disallowed characters or spaces: {e}");
}
}
if self.services.users.exists(&user_id).await {
return Err!("User {user_id} already exists");
}
let password = password.unwrap_or_else(|| utils::random_string(AUTO_GEN_PASSWORD_LENGTH));
// Create user
self.services
.users
.determine_registration_user_id(Some(username), None, None)
.create(&user_id, Some(HashedPassword::new(&password)?))
.await?;
let password = HashedPassword::new(
&password.unwrap_or_else(|| utils::random_string(AUTO_GEN_PASSWORD_LENGTH)),
)?;
// Default to pretty displayname
let mut displayname = user_id.localpart().to_owned();
// If `new_user_displayname_suffix` is set, registration will push whatever
// content is set to the user's display name with a space before it
if !self
.services
.server
.config
.new_user_displayname_suffix
.is_empty()
{
write!(displayname, " {}", self.services.server.config.new_user_displayname_suffix)?;
}
self.services
.users
.create_local_account(&user_id, password, None)
.await;
.set_displayname(&user_id, Some(displayname));
self.write_str(&format!("Created user {user_id}")).await
// Initial account data
self.services
.account_data
.update(
None,
&user_id,
ruma::events::GlobalAccountDataEventType::PushRules
.to_string()
.into(),
&serde_json::to_value(ruma::events::push_rules::PushRulesEvent::new(
ruma::events::push_rules::PushRulesEventContent::new(
ruma::push::Ruleset::server_default(&user_id),
),
))
.unwrap(),
)
.await?;
if !self.services.server.config.auto_join_rooms.is_empty() {
for room in &self.services.server.config.auto_join_rooms {
let Ok(room_id) = self.services.rooms.alias.resolve(room).await else {
error!(
%user_id,
"Failed to resolve room alias to room ID when attempting to auto join {room}, skipping"
);
continue;
};
if !self
.services
.rooms
.state_cache
.server_in_room(self.services.globals.server_name(), &room_id)
.await
{
warn!(
"Skipping room {room} to automatically join as we have never joined before."
);
continue;
}
if let Some(room_server_name) = room.server_name() {
match self
.services
.rooms
.membership
.join_room(
&user_id,
&room_id,
Some("Automatically joining this room upon registration".to_owned()),
&[
self.services.globals.server_name().to_owned(),
room_server_name.to_owned(),
],
)
.await
{
| Ok(_response) => {
info!("Automatically joined room {room} for user {user_id}");
},
| Err(e) => {
// don't return this error so we don't fail registrations
error!(
"Failed to automatically join room {room} for user {user_id}: {e}"
);
self.services
.admin
.send_text(&format!(
"Failed to automatically join room {room} for user {user_id}: \
{e}"
))
.await;
},
}
}
}
}
// we dont add a device since we're not the user, just the creator
// Make the first user to register an administrator and disable first-run mode.
self.services.firstrun.empower_first_user(&user_id).await?;
self.write_str(&format!("Created user with user_id: {user_id} and password: `{password}`"))
.await
}
#[admin_command]
@@ -122,7 +233,7 @@ pub(super) async fn suspend(&self, user_id: String) -> Result {
// TODO: Record the actual user that sent the suspension where possible
self.services
.users
.suspend_account(&user_id, self.sender)
.suspend_account(&user_id, self.sender_or_service_user())
.await;
self.write_str(&format!("User {user_id} has been suspended."))
@@ -191,6 +302,31 @@ pub(super) async fn reset_password(
Ok(())
}
#[admin_command]
pub(super) async fn issue_password_reset_link(&self, username: String) -> Result {
use conduwuit_service::password_reset::{PASSWORD_RESET_PATH, RESET_TOKEN_QUERY_PARAM};
self.bail_restricted()?;
let mut reset_url = self
.services
.config
.get_client_domain()
.join(PASSWORD_RESET_PATH)
.unwrap();
let user_id = parse_local_user_id(self.services, &username)?;
let token = self.services.password_reset.issue_token(user_id).await?;
reset_url
.query_pairs_mut()
.append_pair(RESET_TOKEN_QUERY_PARAM, &token.token);
self.write_str(&format!("Password reset link issued for {username}: {reset_url}"))
.await?;
Ok(())
}
#[admin_command]
pub(super) async fn deactivate_all(&self, no_leave_rooms: bool, force: bool) -> Result {
if self.body.len() < 2
@@ -939,7 +1075,7 @@ pub(super) async fn lock(&self, user_id: String) -> Result {
}
self.services
.users
.lock_account(&user_id, self.sender)
.lock_account(&user_id, self.sender_or_service_user())
.await;
self.write_str(&format!("User {user_id} has been locked."))
+6
View File
@@ -29,6 +29,12 @@ pub enum UserCommand {
password: Option<String>,
},
/// Issue a self-service password reset link for a user.
IssuePasswordResetLink {
/// Username of the user who may use the link
username: String,
},
/// Get a user's associated email address.
GetEmail {
user_id: String,
-2
View File
@@ -62,8 +62,6 @@ zstd_compression = [
"reqwest/zstd",
]
admin_api = []
[dependencies]
async-trait.workspace = true
axum-client-ip.workspace = true
+1
View File
@@ -0,0 +1 @@
pub mod rooms;
@@ -6,7 +6,7 @@
use crate::{Ruma, client::leave_room};
/// # `PUT /_continuwuity/admin/v1/rooms/{roomID}/ban`
/// # `PUT /_continuwuity/admin/rooms/{roomID}/ban`
///
/// Bans or unbans a room.
pub(crate) async fn ban_room(
+36
View File
@@ -0,0 +1,36 @@
use axum::extract::State;
use conduwuit::{Err, Result};
use futures::StreamExt;
use ruma::OwnedRoomId;
use ruminuwuity::admin::continuwuity::rooms;
use crate::Ruma;
/// # `GET /_continuwuity/admin/rooms/list`
///
/// Lists all rooms known to this server, excluding banned ones.
pub(crate) async fn list_rooms(
State(services): State<crate::State>,
body: Ruma<rooms::list::v1::Request>,
) -> Result<rooms::list::v1::Response> {
let sender_user = body.identity.expect_sender_user()?;
if !services.users.is_admin(sender_user).await {
return Err!(Request(Forbidden("Only server administrators can use this endpoint")));
}
let mut rooms: Vec<OwnedRoomId> = services
.rooms
.metadata
.iter_ids()
.filter_map(|room_id| async move {
if !services.rooms.metadata.is_banned(&room_id).await {
Some(room_id.clone())
} else {
None
}
})
.collect()
.await;
rooms.sort();
Ok(rooms::list::v1::Response::new(rooms))
}
+2
View File
@@ -0,0 +1,2 @@
pub mod ban;
pub mod list;
+45 -28
View File
@@ -24,7 +24,7 @@
power_levels::RoomPowerLevelsEventContent,
},
};
use service::{mailer::messages, uiaa::UiaaInitiator, users::HashedPassword};
use service::{mailer::messages, uiaa::Identity, users::HashedPassword};
use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH};
use crate::{Ruma, router::ClientIdentity};
@@ -49,16 +49,39 @@ pub(crate) async fn get_register_available_route(
ClientIp(client): ClientIp,
body: Ruma<get_username_availability::v3::Request>,
) -> Result<get_username_availability::v3::Response> {
let _ = services
.users
.determine_registration_user_id(
Some(body.username.clone()),
None,
body.identity
.as_ref()
.and_then(ClientIdentity::appservice_info),
)
.await?;
// Validate user id
let user_id =
match UserId::parse_with_server_name(&body.username, services.globals.server_name()) {
| Ok(user_id) => {
if let Err(e) = user_id.validate_strict() {
return Err!(Request(InvalidUsername(debug_warn!(
"Username {} contains disallowed characters or spaces: {e}",
body.username
))));
}
user_id
},
| Err(e) => {
return Err!(Request(InvalidUsername(debug_warn!(
"Username {} is not valid: {e}",
body.username
))));
},
};
// Check if username is creative enough
if services.users.exists(&user_id).await {
return Err!(Request(UserInUse("User ID is not available.")));
}
if let Some(ClientIdentity::Appservice { appservice_info, .. }) = &body.identity
&& !appservice_info.is_user_match(&user_id)
{
return Err!(Request(Exclusive("Username is not in an appservice namespace.")));
} else if services.appservice.is_exclusive_user_id(&user_id).await {
return Err!(Request(Exclusive("Username is reserved by an appservice.")));
}
Ok(get_username_availability::v3::Response::new(true))
}
@@ -86,7 +109,12 @@ pub(crate) async fn change_password_route(
ClientIp(client): ClientIp,
body: Ruma<change_password::v3::Request>,
) -> Result<change_password::v3::Response> {
let identity = if let Some(identity) = body.identity.as_ref() {
let identity = if let Some(user_id) = body
.identity
.as_ref()
.map(ClientIdentity::expect_sender_user)
.transpose()?
{
// A signed-in user is trying to change their password, prompt them for their
// existing one
@@ -96,10 +124,7 @@ pub(crate) async fn change_password_route(
&body.auth,
vec![AuthFlow::new(vec![AuthType::Password])],
Box::default(),
Some(UiaaInitiator::new(
identity.expect_sender_user()?,
identity.sender_device(),
)),
Some(Identity::from_user_id(user_id)),
)
.await?
} else {
@@ -255,24 +280,16 @@ pub(crate) async fn deactivate_route(
) -> Result<deactivate::v3::Response> {
// Authentication for this endpoint is technically optional,
// but we require the user to be logged in
let identity = body
let sender_user = body
.identity
.as_ref()
.ok_or_else(|| err!(Request(MissingToken("Missing access token."))))?;
let sender_user = identity.expect_sender_user()?;
if !services.config.allow_deactivation {
return Err!(Request(Unauthorized(
"You may not deactivate your own account. Contact your server's administrator for \
assistance."
)));
}
.map(ClientIdentity::expect_sender_user)
.ok_or_else(|| err!(Request(MissingToken("Missing access token."))))??;
// Prompt the user to confirm with their password using UIAA
let _ = services
.uiaa
.authenticate_password(&body.auth, sender_user, identity.sender_device(), None)
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
.await?;
// Remove profile pictures and display name
+295 -59
View File
@@ -1,15 +1,17 @@
use std::collections::HashMap;
use std::{collections::HashMap, fmt::Write};
use axum::extract::State;
use axum_client_ip::ClientIp;
use conduwuit::{
Err, Result, debug_info, info,
Err, Result, debug_info, error, info,
utils::{self},
warn,
};
use conduwuit_service::Services;
use futures::StreamExt;
use futures::{FutureExt, StreamExt};
use lettre::{Address, message::Mailbox};
use ruma::{
OwnedUserId, UserId,
api::client::{
account::{
register::{self, LoginType, RegistrationKind},
@@ -18,6 +20,11 @@
uiaa::{AuthFlow, AuthType},
},
assign,
events::{
GlobalAccountDataEventType, push_rules::PushRulesEvent,
room::message::RoomMessageEventContent,
},
push,
};
use serde_json::value::RawValue;
use service::{mailer::messages, users::HashedPassword};
@@ -25,6 +32,8 @@
use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH};
use crate::Ruma;
const RANDOM_USER_ID_LENGTH: usize = 10;
/// # `POST /_matrix/client/v3/register`
///
/// Register an account on this homeserver.
@@ -43,6 +52,8 @@ pub(crate) async fn register_route(
return Err!(Request(GuestAccessForbidden("Guests may not register on this server.")));
}
let emergency_mode_enabled = services.config.emergency_password.is_some();
// Allow registration if it's enabled in the config file or if this is the first
// run (so the first user account can be created)
let allow_registration =
@@ -60,59 +71,99 @@ pub(crate) async fn register_route(
)));
}
let user_id = if body.body.login_type == Some(LoginType::ApplicationService) {
let Some(appservice_info) = &body.identity else {
return Err!(Request(Forbidden(
"Only appservices can use the appservice login type."
)));
};
let user_id = services
.users
.determine_registration_user_id(body.username.clone(), None, Some(appservice_info))
.await?;
services.users.create(&user_id, None).await?;
user_id
let identity = if body.identity.is_some() {
// Appservices can skip auth
None
} else {
// Perform UIAA to determine the user's identity
let (flows, params) = create_registration_uiaa_session(&services).await?;
let identity = services
.uiaa
.authenticate(&body.auth, flows, params, None)
.await?;
let password = if let Some(password) = &body.password {
HashedPassword::new(password)?
} else {
return Err!(Request(InvalidParam("A password must be provided.")));
};
let user_id = services
.users
.determine_registration_user_id(body.username.clone(), identity.email.as_ref(), None)
.await?;
services
.users
.create_local_account(&user_id, password, identity.email)
.await;
services.users.join_auto_join_rooms(&user_id).await;
user_id
Some(
services
.uiaa
.authenticate(&body.auth, flows, params, None)
.await?,
)
};
let (token, device) = if !body.inhibit_login {
// If UIAA is disabled, we can't create a device. In that case only appservices
// can reach this point in the first place, so we return an error for them.
if !services.config.oauth.compatibility_mode.uiaa_available() {
return Err!(Request(AppserviceLoginUnsupported(
"User-interactive appservice registration is not available on this server."
)));
// If the user didn't supply a username but did supply an email, use
// the email's user as their initial localpart to avoid falling back to
// a randomly generated localpart
let supplied_username = body.username.clone().or_else(|| {
if let Some(identity) = &identity
&& let Some(email) = &identity.email
{
Some(email.user().to_owned())
} else {
None
}
});
// Generate new device id if the user didn't specify one
let user_id =
determine_registration_user_id(&services, supplied_username, emergency_mode_enabled)
.await?;
if body.body.login_type == Some(LoginType::ApplicationService) {
// For appservice logins, make sure that the user ID is in the appservice's
// namespace
match body.identity {
| Some(ref info) =>
if !info.is_user_match(&user_id) && !emergency_mode_enabled {
return Err!(Request(Exclusive(
"Username is not in an appservice namespace."
)));
},
| _ => {
return Err!(Request(MissingToken("Missing appservice token.")));
},
}
} else if services.appservice.is_exclusive_user_id(&user_id).await && !emergency_mode_enabled
{
// For non-appservice logins, ban user IDs which are in an appservice's
// namespace (unless emergency mode is enabled)
return Err!(Request(Exclusive("Username is reserved by an appservice.")));
}
let password = if body.identity.is_some() {
None
} else if let Some(password) = body.password.as_deref() {
Some(HashedPassword::new(password)?)
} else {
return Err!(Request(InvalidParam("A password must be provided")));
};
// Create user
services.users.create(&user_id, password).await?;
// Set an initial display name
let mut displayname = user_id.localpart().to_owned();
// Apply the new user displayname suffix, if it's set
if !services.globals.new_user_displayname_suffix().is_empty() && body.identity.is_none() {
write!(displayname, " {}", services.server.config.new_user_displayname_suffix)?;
}
services
.users
.set_displayname(&user_id, Some(displayname.clone()));
// Initial account data
services
.account_data
.update(
None,
&user_id,
GlobalAccountDataEventType::PushRules.to_string().into(),
&serde_json::to_value(PushRulesEvent::new(
push::Ruleset::server_default(&user_id).into(),
))
.expect("should be able to serialize push rules"),
)
.await?;
// Generate new device id if the user didn't specify one
let (token, device) = if !body.inhibit_login {
let device_id = body
.device_id
.clone()
@@ -128,7 +179,6 @@ pub(crate) async fn register_route(
&user_id,
&device_id,
&new_token,
None,
body.initial_device_display_name.clone(),
Some(client.to_string()),
)
@@ -139,7 +189,118 @@ pub(crate) async fn register_route(
(None, None)
};
debug_info!(%user_id, ?device, "New account created via legacy registration");
debug_info!(%user_id, ?device, "User account was created");
// If the user registered with an email, associate it with their account.
if let Some(identity) = identity
&& let Some(email) = identity.email
{
// This may fail if the email is already in use, but we already check for that
// in `/requestToken`, so ignoring the error is acceptable here in the rare case
// that an email is sniped by another user between the `/requestToken` request
// and the `/register` request.
let _ = services
.threepid
.associate_localpart_email(user_id.localpart(), &email)
.await;
}
let device_display_name = body.initial_device_display_name.as_deref().unwrap_or("");
if body.identity.is_none() {
if !device_display_name.is_empty() {
let notice = format!(
"New user \"{user_id}\" registered on this server from IP {client} and device \
display name \"{device_display_name}\""
);
info!("{notice}");
if services.server.config.admin_room_notices {
services.admin.notice(&notice).await;
}
} else {
let notice = format!("New user \"{user_id}\" registered on this server.");
info!("{notice}");
if services.server.config.admin_room_notices {
services.admin.notice(&notice).await;
}
}
}
// Make the first user to register an administrator and disable first-run mode.
let was_first_user = services.firstrun.empower_first_user(&user_id).await?;
// If the registering user was not the first and we're suspending users on
// register, suspend them.
if !was_first_user && services.config.suspend_on_register {
// Note that we can still do auto joins for suspended users
services
.users
.suspend_account(&user_id, &services.globals.server_user)
.await;
// And send an @room notice to the admin room, to prompt admins to review the
// new user and ideally unsuspend them if deemed appropriate.
if services.server.config.admin_room_notices {
services
.admin
.send_loud_message(RoomMessageEventContent::text_plain(format!(
"User {user_id} has been suspended as they are not the first user on this \
server. Please review and unsuspend them if appropriate."
)))
.await
.ok();
}
}
if body.identity.is_none() && !services.server.config.auto_join_rooms.is_empty() {
for room in &services.server.config.auto_join_rooms {
let Ok(room_id) = services.rooms.alias.resolve(room).await else {
error!(
"Failed to resolve room alias to room ID when attempting to auto join \
{room}, skipping"
);
continue;
};
if !services
.rooms
.state_cache
.server_in_room(services.globals.server_name(), &room_id)
.await
{
warn!(
"Skipping room {room} to automatically join as we have never joined before."
);
continue;
}
if let Some(room_server_name) = room.server_name() {
match services
.rooms
.membership
.join_room(
&user_id,
&room_id,
Some("Automatically joining this room upon registration".to_owned()),
&[services.globals.server_name().to_owned(), room_server_name.to_owned()],
)
.boxed()
.await
{
| Err(e) => {
// don't return this error so we don't fail registrations
error!(
"Failed to automatically join room {room} for user {user_id}: {e}"
);
},
| _ => {
info!("Automatically joined room {room} for user {user_id}");
},
}
}
}
}
Ok(assign!(register::v3::Response::new(user_id), {
access_token: token,
@@ -211,21 +372,21 @@ async fn create_registration_uiaa_session(
// Require all users to agree to the terms and conditions, if configured
let terms = &services.config.registration_terms;
if !terms.documents.is_empty() {
let mut terms_map = HashMap::new();
if !terms.is_empty() {
let mut terms =
serde_json::to_value(terms.clone()).expect("failed to serialize terms");
for (id, document) in &terms.documents {
terms_map.insert(id.to_owned(), serde_json::json!({
terms.language.clone(): serde_json::to_value(document).expect("should be able to serialize document")
}));
// Insert a dummy `version` field
for (_, documents) in terms.as_object_mut().unwrap() {
let documents = documents.as_object_mut().unwrap();
documents.insert("version".to_owned(), "latest".into());
}
terms_map.insert("version".to_owned(), "latest".into());
params.insert(
AuthType::Terms.as_str().to_owned(),
serde_json::json!({
"policies": terms_map,
"policies": terms,
}),
);
@@ -258,6 +419,81 @@ async fn create_registration_uiaa_session(
Ok((flows, params))
}
async fn determine_registration_user_id(
services: &Services,
supplied_username: Option<String>,
emergency_mode_enabled: bool,
) -> Result<OwnedUserId> {
if let Some(supplied_username) = supplied_username {
// The user gets to pick their username. Do some validation to make sure it's
// acceptable.
// Don't allow registration with forbidden usernames.
if services
.globals
.forbidden_usernames()
.is_match(&supplied_username)
&& !emergency_mode_enabled
{
return Err!(Request(Forbidden("Username is forbidden")));
}
// Create and validate the user ID
let user_id = match UserId::parse_with_server_name(
&supplied_username,
services.globals.server_name(),
) {
| Ok(user_id) => {
if let Err(e) = user_id.validate_strict() {
// Unless we are in emergency mode, we should follow synapse's behaviour on
// not allowing things like spaces and UTF-8 characters in usernames
if !emergency_mode_enabled {
return Err!(Request(InvalidUsername(debug_warn!(
"Username {supplied_username} contains disallowed characters or \
spaces: {e}"
))));
}
}
// Don't allow registration with user IDs that aren't local
if !services.globals.user_is_local(&user_id) {
return Err!(Request(InvalidUsername(
"Username {supplied_username} is not local to this server"
)));
}
user_id
},
| Err(e) => {
return Err!(Request(InvalidUsername(debug_warn!(
"Username {supplied_username} is not valid: {e}"
))));
},
};
if services.users.exists(&user_id).await {
return Err!(Request(UserInUse("User ID is not available.")));
}
Ok(user_id)
} else {
// The user didn't specify a username. Generate a username for
// them.
loop {
let user_id = UserId::parse_with_server_name(
utils::random_string(RANDOM_USER_ID_LENGTH).to_lowercase(),
services.globals.server_name(),
)
.unwrap();
if !services.users.exists(&user_id).await {
break Ok(user_id);
}
}
}
}
/// # `POST /_matrix/client/v3/register/email/requestToken`
///
/// Requests a validation email for the purpose of registering a new account.
+4 -7
View File
@@ -11,7 +11,7 @@
},
thirdparty::{Medium, ThirdPartyIdentifierInit},
};
use service::mailer::messages;
use service::{mailer::messages, uiaa::Identity};
use crate::{Ruma, router::ClientIdentity};
@@ -124,18 +124,15 @@ pub(crate) async fn add_3pid_route(
.uiaa
.authenticate_password(
&body.auth,
body.identity.expect_sender_user()?,
body.identity.sender_device(),
None,
Some(Identity::from_user_id(body.identity.expect_sender_user()?)),
)
.await?;
let email = services
.threepid
.get_valid_session(&body.sid, &body.client_secret)
.consume_valid_session(&body.sid, &body.client_secret)
.await
.map_err(|message| err!(Request(ThreepidAuthFailed("{message}"))))?
.consume();
.map_err(|message| err!(Request(ThreepidAuthFailed("{message}"))))?;
services
.threepid
-71
View File
@@ -1,71 +0,0 @@
use axum::extract::State;
use conduwuit::Err;
use ruma::api::client::admin::{is_user_locked, lock_user};
use crate::router::Ruma;
/// # `GET /_matrix/client/v1/admin/lock/{userId}`
///
/// Check the account lock status of a target user
pub(crate) async fn get_locked_status(
State(services): State<crate::State>,
body: Ruma<is_user_locked::v1::Request>,
) -> conduwuit::Result<is_user_locked::v1::Response> {
if !services.users.is_active_local(&body.user_id).await {
return Err!(Request(InvalidParam(
"Can only check the lock status of active local users"
)));
}
Ok(is_user_locked::v1::Response::new(
services.users.is_locked(&body.user_id).await?,
))
}
/// # `PUT /_matrix/client/v1/admin/lock/{userId}`
///
/// Set the account lock status of a target user
pub(crate) async fn put_locked_status(
State(services): State<crate::State>,
body: Ruma<lock_user::v1::Request>,
) -> conduwuit::Result<lock_user::v1::Response> {
if !services.users.is_active_local(&body.user_id).await {
return Err!(Request(InvalidParam(
"Can only set the locked status of active local users"
)));
}
if body.identity.sender_user() == Some(&body.user_id) {
return Err!(Request(Forbidden("You cannot lock yourself")));
}
if services.users.is_admin(&body.user_id).await {
return Err!(Request(Forbidden("You cannot lock another server administrator")));
}
if services.users.is_locked(&body.user_id).await? == body.locked {
// No change
return Ok(lock_user::v1::Response::new(body.locked));
}
let action = if body.locked {
services
.users
.lock_account(&body.user_id, body.identity.sender_user())
.await;
"suspended"
} else {
services.users.unlock_account(&body.user_id).await;
"unsuspended"
};
if services.config.admin_room_notices {
// Notify the admin room that an account has been un/suspended
services
.admin
.send_text(&format!("{} has been {} by {}.", body.user_id, action, body.identity))
.await;
}
Ok(lock_user::v1::Response::new(body.locked))
}
+1 -3
View File
@@ -1,5 +1,3 @@
mod lock;
pub(crate) mod site;
mod suspend;
pub(crate) use self::{lock::*, suspend::*};
pub(crate) use self::suspend::*;
-2
View File
@@ -1,2 +0,0 @@
pub(crate) mod rooms;
pub(crate) mod users;
-178
View File
@@ -1,178 +0,0 @@
use axum::extract::State;
use conduwuit::{
Event, Result,
utils::stream::{BroadbandExt, WidebandExt},
};
use futures::StreamExt;
use ruma::{
OwnedRoomId,
events::{
StateEventType,
room::{
create::RoomCreateEventContent,
encryption::PossiblyRedactedRoomEncryptionEventContent,
tombstone::PossiblyRedactedRoomTombstoneEventContent,
},
},
};
use ruminuwuity::admin::continuwuity::rooms;
use tokio::join;
use crate::Ruma;
/// # `GET /_continuwuity/admin/rooms`
///
/// Lists all room IDs known to this server, excluding banned ones.
///
/// This is the legacy version of the endpoint, which does not support
/// pagination or including banned rooms. It is recommended to use the
/// `/v1/rooms` endpoint instead. This endpoint may be removed in a future
/// release.
pub(crate) async fn legacy_list_rooms_route(
State(services): State<crate::State>,
body: Ruma<rooms::list::unstable::Request>,
) -> Result<rooms::list::unstable::Response> {
let mut rooms: Vec<OwnedRoomId> = services
.rooms
.metadata
.iter_ids()
.filter_map(|room_id| async move {
if !services.rooms.metadata.is_banned(&room_id).await {
Some(room_id.clone())
} else {
None
}
})
.collect()
.await;
rooms.sort();
Ok(rooms::list::unstable::Response::new(rooms))
}
/// # `GET /_continuwuity/admin/v1/rooms`
///
/// Lists rooms known to this server.
pub(crate) async fn list_rooms_route(
State(services): State<crate::State>,
body: Ruma<rooms::list::v1::Request>,
) -> Result<rooms::list::v1::Response> {
let include_banned_rooms = body.include_banned_rooms;
let rooms = services
.rooms
.metadata
.iter_ids()
.wide_filter_map(|room_id| async move {
if include_banned_rooms || !services.rooms.metadata.is_banned(&room_id).await {
Some(room_id.clone())
} else {
None
}
})
.skip(body.offset.unwrap_or_default())
.take(body.limit.unwrap_or(100).min(100))
.broad_filter_map(|room_id| async move {
let (
banned,
disabled,
member_count,
local_member_count,
resident_server_count,
published,
create_event,
encryption_event,
name_event,
topic_event,
canonical_alias_event,
join_rules_event,
history_visibility_event,
tombstone_event,
) = join!(
services.rooms.metadata.is_banned(&room_id),
services.rooms.metadata.is_disabled(&room_id),
services.rooms.state_cache.room_joined_count(&room_id),
services
.rooms
.state_cache
.active_local_users_in_room(&room_id)
.count(),
services.rooms.state_cache.room_servers(&room_id).count(),
services.rooms.directory.is_public_room(&room_id),
services.rooms.state_accessor.room_state_get(
&room_id,
&StateEventType::RoomCreate,
""
),
services
.rooms
.state_accessor
.room_state_get_content::<PossiblyRedactedRoomEncryptionEventContent>(
&room_id,
&StateEventType::RoomEncryption,
""
),
services.rooms.state_accessor.room_state_get_content(
&room_id,
&StateEventType::RoomName,
""
),
services.rooms.state_accessor.room_state_get_content(
&room_id,
&StateEventType::RoomTopic,
""
),
services.rooms.state_accessor.room_state_get_content(
&room_id,
&StateEventType::RoomCanonicalAlias,
""
),
services.rooms.state_accessor.room_state_get_content(
&room_id,
&StateEventType::RoomJoinRules,
""
),
services.rooms.state_accessor.room_state_get_content(
&room_id,
&StateEventType::RoomHistoryVisibility,
""
),
services
.rooms
.state_accessor
.room_state_get_content::<PossiblyRedactedRoomTombstoneEventContent>(
&room_id,
&StateEventType::RoomTombstone,
""
),
);
let Ok(create_event) = create_event else {
return None;
};
let create_content = create_event
.get_content::<RoomCreateEventContent>()
.expect("m.room.create content must be valid");
Some(rooms::list::v1::MinimalRoomInfo {
room_id,
banned,
disabled,
member_count: usize::try_from(member_count.unwrap_or_default())
.expect("u64 should fit in usize"),
local_member_count,
resident_server_count,
creators: vec![create_event.sender],
encrypted: encryption_event.is_ok_and(|c| c.algorithm.is_some()),
federated: create_content.federate,
published,
version: create_content.room_version,
name: name_event.unwrap_or(None),
topic: topic_event.unwrap_or(None),
canonical_alias: canonical_alias_event.unwrap_or(None),
join_rules: join_rules_event.unwrap_or(None),
history_visibility: history_visibility_event.unwrap_or(None),
predecessor: create_content.predecessor.map(|c| c.room_id),
successor: tombstone_event.map_or(None, |c| c.replacement_room),
})
})
.collect()
.await;
Ok(rooms::list::v1::Response::new(rooms))
}
-5
View File
@@ -1,5 +0,0 @@
mod ban;
mod list;
pub(crate) use ban::ban_room;
pub(crate) use list::*;
-119
View File
@@ -1,119 +0,0 @@
use axum::extract::State;
use conduwuit::{
Err, err, error, info,
utils::{IterStream, stream::BroadbandExt},
warn,
};
use futures::{FutureExt, StreamExt};
use ruma::UserId;
use ruminuwuity::admin::continuwuity::users;
use service::users::HashedPassword;
use crate::router::Ruma;
/// # `POST /_continuwuity/admin/v1/users/create`
///
/// Creates a new user.
pub(crate) async fn create_user_route(
State(services): State<crate::State>,
body: Ruma<users::create::v1::Request>,
) -> conduwuit::Result<users::create::v1::Response> {
let email = body
.email
.clone()
.map(lettre::Address::try_from)
.transpose()
.map_err(|e| err!(Request(BadJson("Invalid email address: {e}"))))?;
let ref user_id = services
.users
.determine_registration_user_id(Some(body.localpart.clone()), email.as_ref(), None)
.await?;
services
.users
.create_local_account(user_id, HashedPassword::new(&body.password)?, email)
.await;
if body.suspended {
services
.users
.suspend_account(&user_id, body.identity.sender_user())
.await;
}
if body.locked {
services
.users
.lock_account(user_id, body.identity.sender_user())
.await;
}
if body.login_disabled {
services.users.disable_login(user_id);
}
if let Some(ref value) = body.display_name {
services.users.set_profile_key(
user_id,
"displayname",
Some(serde_json::to_value(value)?),
);
}
if let Some(ref value) = body.avatar_url {
services
.users
.set_profile_key(user_id, "avatar_url", Some(serde_json::to_value(value)?));
}
if body.admin {
services
.admin
.make_user_admin(user_id)
.await
.inspect_err(|e| error!("failed to make new user {user_id} an admin: {e}"))
.ok();
}
if !body.skip_auto_join {
services.users.join_auto_join_rooms(user_id).await;
}
body.auto_join_rooms
.clone()
.into_iter()
.stream()
.broad_filter_map(|room| async move {
services
.rooms
.alias
.resolve_with_servers(&room, None)
.await
.inspect_err(|e| {
warn!(
"Failed to resolve room alias to room ID when attempting to auto join \
{room}: {e}"
);
})
.ok()
})
.for_each_concurrent(None, |(room_id, servers)| async move {
match services
.rooms
.membership
.join_room(
user_id,
&room_id,
Some("Automatically joining this room upon registration".to_owned()),
servers.as_ref(),
)
.boxed()
.await
{
| Err(e) => {
warn!("Failed to automatically join {user_id} to {room_id}: {e}");
},
| _ => {
info!("Automatically joined room {user_id} to {room_id}");
},
}
})
.await;
Ok(users::create::v1::Response::new(user_id.to_owned()))
}
-42
View File
@@ -1,42 +0,0 @@
use axum::extract::State;
use conduwuit::utils::stream::WidebandExt;
use futures::StreamExt;
use ruminuwuity::admin::continuwuity::users;
use tokio::join;
use crate::router::Ruma;
/// # `GET /_continuwuity/admin/v1/users`
///
/// Lists all users on this homeserver.
pub(crate) async fn list_users_route(
State(services): State<crate::State>,
body: Ruma<users::list::v1::Request>,
) -> conduwuit::Result<users::list::v1::Response> {
let users = services
.users
.list_local_users()
.skip(body.offset.unwrap_or_default())
.take(body.limit.unwrap_or(100).min(100))
.wide_filter_map(|user_id| async move {
let (deactivated, suspended, locked, admin, login_disabled) = join!(
services.users.is_deactivated(&user_id),
services.users.is_suspended(&user_id),
services.users.is_locked(&user_id),
services.users.is_admin(&user_id),
services.users.is_login_disabled(&user_id),
);
Some(users::list::v1::User {
user_id: user_id.clone(),
deactivated: deactivated.unwrap_or_default(),
suspended: suspended.unwrap_or_default(),
locked: locked.unwrap_or_default(),
admin,
login_disabled,
})
})
.collect()
.await;
Ok(users::list::v1::Response::new(users))
}
-5
View File
@@ -1,5 +0,0 @@
mod create;
mod list;
pub(crate) use create::*;
pub(crate) use list::*;
+9 -9
View File
@@ -1,7 +1,7 @@
use axum::extract::State;
use conduwuit::{Err, Result};
use futures::future::{join, join3};
use ruma::api::client::admin::{is_user_suspended, suspend_user};
use ruminuwuity::admin::{get_suspended, set_suspended};
use crate::Ruma;
@@ -10,8 +10,8 @@
/// Check the suspension status of a target user
pub(crate) async fn get_suspended_status(
State(services): State<crate::State>,
body: Ruma<is_user_suspended::v1::Request>,
) -> Result<is_user_suspended::v1::Response> {
body: Ruma<get_suspended::v1::Request>,
) -> Result<get_suspended::v1::Response> {
let (admin, active) = join(
services.users.is_admin(body.identity.expect_sender_user()?),
services.users.is_active(&body.user_id),
@@ -26,7 +26,7 @@ pub(crate) async fn get_suspended_status(
if !active {
return Err!(Request(NotFound("Unknown user")));
}
Ok(is_user_suspended::v1::Response::new(
Ok(get_suspended::v1::Response::new(
services.users.is_suspended(&body.user_id).await?,
))
}
@@ -36,8 +36,8 @@ pub(crate) async fn get_suspended_status(
/// Set the suspension status of a target user
pub(crate) async fn put_suspended_status(
State(services): State<crate::State>,
body: Ruma<suspend_user::v1::Request>,
) -> Result<suspend_user::v1::Response> {
body: Ruma<set_suspended::v1::Request>,
) -> Result<set_suspended::v1::Response> {
let sender_user = body.identity.expect_sender_user()?;
let (sender_admin, active, target_admin) = join3(
@@ -64,13 +64,13 @@ pub(crate) async fn put_suspended_status(
}
if services.users.is_suspended(&body.user_id).await? == body.suspended {
// No change
return Ok(suspend_user::v1::Response::new(body.suspended));
return Ok(set_suspended::v1::Response::new(body.suspended));
}
let action = if body.suspended {
services
.users
.suspend_account(&body.user_id, body.identity.sender_user())
.suspend_account(&body.user_id, sender_user)
.await;
"suspended"
} else {
@@ -86,5 +86,5 @@ pub(crate) async fn put_suspended_status(
.await;
}
Ok(suspend_user::v1::Response::new(body.suspended))
Ok(set_suspended::v1::Response::new(body.suspended))
}
+7 -5
View File
@@ -8,6 +8,7 @@
self, delete_device, delete_devices, get_device, get_devices, update_device,
},
};
use service::uiaa::Identity;
use crate::{Ruma, client::DEVICE_ID_LENGTH};
@@ -94,7 +95,6 @@ pub(crate) async fn update_device_route(
&device_id,
&appservice.registration.as_token,
None,
None,
Some(client.to_string()),
)
.await?;
@@ -119,13 +119,14 @@ pub(crate) async fn delete_device_route(
body: Ruma<delete_device::v3::Request>,
) -> Result<delete_device::v3::Response> {
let sender_user = body.identity.expect_sender_user()?;
let appservice = body.identity.appservice_info();
// Appservices get to skip UIAA for this endpoint
if let Some(sender_device) = body.identity.sender_device() {
if appservice.is_none() {
// Prompt the user to confirm with their password using UIAA
let _ = services
.uiaa
.authenticate_password(&body.auth, sender_user, Some(sender_device), None)
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
.await?;
}
@@ -154,13 +155,14 @@ pub(crate) async fn delete_devices_route(
body: Ruma<delete_devices::v3::Request>,
) -> Result<delete_devices::v3::Response> {
let sender_user = body.identity.expect_sender_user()?;
let appservice = body.identity.appservice_info();
// Appservices get to skip UIAA for this endpoint
if let Some(sender_device) = body.identity.sender_device() {
if appservice.is_none() {
// Prompt the user to confirm with their password using UIAA
let _ = services
.uiaa
.authenticate_password(&body.auth, sender_user, Some(sender_device), None)
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
.await?;
}
+2 -7
View File
@@ -26,7 +26,7 @@
serde::Raw,
};
use serde_json::json;
use service::oauth::OAuthTicket;
use service::uiaa::Identity;
use crate::Ruma;
@@ -205,12 +205,7 @@ pub(crate) async fn upload_signing_keys_route(
{
let _ = services
.uiaa
.authenticate_password(
&body.auth,
sender_user,
body.identity.sender_device(),
Some(OAuthTicket::CrossSigningReset),
)
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
.await?;
}
-3
View File
@@ -16,7 +16,6 @@
pub(super) mod membership;
pub(super) mod message;
pub(super) mod mutual_rooms;
pub(super) mod oauth;
pub(super) mod openid;
pub(super) mod presence;
pub(super) mod profile;
@@ -62,7 +61,6 @@
pub use membership::{leave_all_rooms, leave_room, remote_leave_room};
pub(super) use message::*;
pub(super) use mutual_rooms::*;
pub(super) use oauth::*;
pub(super) use openid::*;
pub(super) use presence::*;
pub(super) use profile::*;
@@ -75,7 +73,6 @@
pub(super) use room::*;
pub(super) use search::*;
pub(super) use send::*;
pub use session::handle_login;
pub(super) use session::*;
pub(super) use space::*;
pub(super) use state::*;
-56
View File
@@ -1,56 +0,0 @@
use axum::{
Json, Router,
extract::{Request, State},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::method_routing::{get, post},
};
use const_str::concat;
use http::StatusCode;
use serde_json::json;
pub(crate) use server_metadata::*;
mod register_client;
mod server_metadata;
mod token;
const BASE_PATH: &str = concat!(conduwuit_core::ROUTE_PREFIX, "/oauth2/");
const AUTH_CODE_PATH: &str = "grant/authorization_code";
const JWKS_URI_PATH: &str = "client/keys.json";
const CLIENT_REGISTER_PATH: &str = "client/register";
const TOKEN_REVOKE_PATH: &str = "client/revoke";
const TOKEN_PATH: &str = "grant/token";
const ACCOUNT_MANAGEMENT_PATH: &str = concat!(conduwuit_core::ROUTE_PREFIX, "/account/deeplink");
pub(crate) fn router(state: crate::State) -> Router<crate::State> {
Router::new()
.nest(BASE_PATH, oauth_router())
.route(
"/.well-known/openid-configuration",
get(
// TODO(unspecced): used by old versions of the matrix-js-sdk
async |State(services): State<crate::State>| {
Json(authorization_server_metadata(&services).await)
},
),
)
.layer(middleware::from_fn_with_state(
state,
async |State(state): State<crate::State>, request: Request, next: Next| -> Response {
if state.config.oauth.compatibility_mode.oauth_available() {
next.run(request).await
} else {
(StatusCode::NOT_FOUND, "OAuth is unavailable on this server").into_response()
}
},
))
}
fn oauth_router() -> Router<crate::State> {
Router::new()
.route(concat!("/", CLIENT_REGISTER_PATH), post(register_client::register_client_route))
// TODO(unspecced): used by old versions of the matrix-js-sdk
.route(concat!("/", JWKS_URI_PATH), get(async || Json(json!({"keys": []}))))
.route(concat!("/", TOKEN_PATH), post(token::token_route))
.route(concat!("/", TOKEN_REVOKE_PATH), post(token::revoke_token_route))
}
-28
View File
@@ -1,28 +0,0 @@
use axum::{
Json,
extract::State,
response::{IntoResponse, Response},
};
use http::StatusCode;
use serde::Serialize;
use service::oauth::client_metadata::ClientMetadata;
#[derive(Serialize)]
struct RegisteredClient {
client_id: String,
#[serde(flatten)]
metadata: ClientMetadata,
}
pub(crate) async fn register_client_route(
State(services): State<crate::State>,
Json(metadata): Json<ClientMetadata>,
) -> Result<Response, Response> {
let client_id = services
.oauth
.register_client(&metadata)
.await
.map_err(|err| (StatusCode::BAD_REQUEST, Json(err)).into_response())?;
Ok(Json(RegisteredClient { client_id, metadata }).into_response())
}
-62
View File
@@ -1,62 +0,0 @@
use axum::extract::State;
use conduwuit::{Err, Result};
use ruma::{
api::client::discovery::get_authorization_server_metadata::{
self, v1::AccountManagementAction,
},
serde::Raw,
};
use serde_json::{Value, json};
use service::Services;
use crate::{
Ruma,
client::oauth::{
ACCOUNT_MANAGEMENT_PATH, AUTH_CODE_PATH, CLIENT_REGISTER_PATH, JWKS_URI_PATH, TOKEN_PATH,
TOKEN_REVOKE_PATH,
},
};
pub(crate) async fn get_authorization_server_metadata_route(
State(services): State<crate::State>,
_body: Ruma<get_authorization_server_metadata::v1::Request>,
) -> Result<get_authorization_server_metadata::v1::Response> {
if !services.config.oauth.compatibility_mode.oauth_available() {
return Err!(Request(Unrecognized("OAuth is unavailable on this server")));
}
let metadata = Raw::new(&authorization_server_metadata(&services).await).unwrap();
Ok(get_authorization_server_metadata::v1::Response::new(metadata.cast_unchecked()))
}
pub(crate) async fn authorization_server_metadata(services: &Services) -> Value {
let endpoint_base = services
.config
.get_client_domain()
.join(super::BASE_PATH)
.unwrap();
json!({
"account_management_uri": endpoint_base.join(ACCOUNT_MANAGEMENT_PATH).unwrap(),
"account_management_actions_supported": [
AccountManagementAction::AccountDeactivate,
AccountManagementAction::CrossSigningReset,
AccountManagementAction::DeviceDelete,
AccountManagementAction::DeviceView,
AccountManagementAction::DevicesList,
AccountManagementAction::Profile,
],
"authorization_endpoint": endpoint_base.join(AUTH_CODE_PATH).unwrap(),
"code_challenge_methods_supported": ["S256"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"issuer": services.config.get_client_domain(),
"jwks_uri": endpoint_base.join(JWKS_URI_PATH).unwrap(),
"prompt_values_supported": ["create"],
"registration_endpoint": endpoint_base.join(CLIENT_REGISTER_PATH).unwrap(),
"response_modes_supported": ["query", "fragment"],
"response_types_supported": ["code"],
"revocation_endpoint": endpoint_base.join(TOKEN_REVOKE_PATH).unwrap(),
"token_endpoint": endpoint_base.join(TOKEN_PATH).unwrap(),
})
}
-23
View File
@@ -1,23 +0,0 @@
use axum::{Form, Json, extract::State, response::IntoResponse};
use http::StatusCode;
use service::oauth::grant::{RevokeTokenRequest, TokenRequest};
pub(crate) async fn token_route(
State(services): State<crate::State>,
Form(request): Form<TokenRequest>,
) -> impl IntoResponse {
match services.oauth.issue_token(request).await {
| Ok(response) => Ok(Json(response)),
| Err(err) => Err((StatusCode::BAD_REQUEST, Json(err))),
}
}
pub(crate) async fn revoke_token_route(
State(services): State<crate::State>,
Form(request): Form<RevokeTokenRequest>,
) -> impl IntoResponse {
match services.oauth.revoke_token(request.token).await {
| Ok(()) => Ok(StatusCode::OK),
| Err(err) => Err((StatusCode::BAD_REQUEST, Json(err))),
}
}
-12
View File
@@ -31,12 +31,6 @@ pub(crate) async fn get_profile_route(
State(services): State<crate::State>,
body: Ruma<get_profile::v3::Request>,
) -> Result<get_profile::v3::Response> {
if services.config.require_auth_for_profile_requests && body.identity.is_none() {
return Err!(Request(Unauthorized(
"This server requires authentication to view user profiles."
)));
}
let Some(profile) = fetch_full_profile(&services, &body.user_id).await else {
return Err!(Request(NotFound("This user's profile could not be fetched.")));
};
@@ -48,12 +42,6 @@ pub(crate) async fn get_profile_field_route(
State(services): State<crate::State>,
body: Ruma<get_profile_field::v3::Request>,
) -> Result<get_profile_field::v3::Response> {
if services.config.require_auth_for_profile_requests && body.identity.is_none() {
return Err!(Request(Unauthorized(
"This server requires authentication to view user profiles."
)));
}
let value = fetch_profile_field(&services, &body.user_id, body.field.clone()).await?;
Ok(assign!(get_profile_field::v3::Response::default(), { value }))
+20 -31
View File
@@ -21,7 +21,7 @@
},
login::{
self,
v3::{DiscoveryInfo, HomeserverInfo, LoginInfo},
v3::{DiscoveryInfo, HomeserverInfo},
},
logout, logout_all,
},
@@ -29,6 +29,7 @@
},
assign,
};
use service::uiaa::Identity;
use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH};
use crate::Ruma;
@@ -43,12 +44,6 @@ pub(crate) async fn get_login_types_route(
ClientIp(client): ClientIp,
_body: Ruma<get_login_types::v3::Request>,
) -> Result<get_login_types::v3::Response> {
if !services.config.oauth.compatibility_mode.uiaa_available() {
return Err!(Request(Unrecognized(
"User-interactive authentication is not available on this server."
)));
}
Ok(get_login_types::v3::Response::new(vec![
get_login_types::v3::LoginType::Password(PasswordLoginType::default()),
get_login_types::v3::LoginType::ApplicationService(ApplicationServiceLoginType::default()),
@@ -58,7 +53,7 @@ pub(crate) async fn get_login_types_route(
]))
}
pub async fn handle_login(
pub(crate) async fn handle_login(
services: &Services,
identifier: Option<&UserIdentifier>,
password: &str,
@@ -92,6 +87,10 @@ pub async fn handle_login(
return Err!(Request(InvalidParam("User ID does not belong to this homeserver")));
}
if services.users.is_locked(&user_id).await? {
return Err!(Request(UserLocked("This account has been locked.")));
}
if services.users.is_login_disabled(&user_id).await {
warn!(%user_id, "user attempted to log in with a login-disabled account");
return Err!(Request(Forbidden("This account is not permitted to log in.")));
@@ -120,29 +119,19 @@ pub(crate) async fn login_route(
ClientIp(client): ClientIp,
body: Ruma<login::v3::Request>,
) -> Result<login::v3::Response> {
if !services.config.oauth.compatibility_mode.uiaa_available() {
return match body.login_info {
| LoginInfo::ApplicationService(_) => {
Err!(Request(AppserviceLoginUnsupported(
"User-interactive appservice login is not available on this server."
)))
},
| _ => {
Err!(Request(Unrecognized(
"User-interactive authentication is not available on this server."
)))
},
};
}
let emergency_mode_enabled = services.config.emergency_password.is_some();
// Validate login method
// TODO: Other login methods
let user_id = match &body.login_info {
#[allow(deprecated)]
| LoginInfo::Password(login::v3::Password { identifier, password, user, .. }) =>
handle_login(&services, identifier.as_ref(), password, user.as_ref()).await?,
| LoginInfo::Token(login::v3::Token { token, .. }) => {
| login::v3::LoginInfo::Password(login::v3::Password {
identifier,
password,
user,
..
}) => handle_login(&services, identifier.as_ref(), password, user.as_ref()).await?,
| login::v3::LoginInfo::Token(login::v3::Token { token, .. }) => {
debug!("Got token login type");
if !services.server.config.login_via_existing_session {
return Err!(Request(Unknown("Token login is not enabled.")));
@@ -150,7 +139,7 @@ pub(crate) async fn login_route(
services.users.find_from_login_token(token).await?
},
#[allow(deprecated)]
| LoginInfo::ApplicationService(login::v3::ApplicationService {
| login::v3::LoginInfo::ApplicationService(login::v3::ApplicationService {
identifier,
user,
..
@@ -184,6 +173,7 @@ pub(crate) async fn login_route(
user_id
},
| _ => {
debug!("/login json_body: {:?}", &body.json_body);
return Err!(Request(Unknown(
debug_warn!(?body.login_info, "Invalid or unsupported login type")
)));
@@ -213,7 +203,7 @@ pub(crate) async fn login_route(
if device_exists {
services
.users
.set_token(&user_id, &device_id, &token, None)
.set_token(&user_id, &device_id, &token)
.await?;
} else {
services
@@ -222,7 +212,6 @@ pub(crate) async fn login_route(
&user_id,
&device_id,
&token,
None,
body.initial_device_display_name.clone(),
Some(client.to_string()),
)
@@ -261,7 +250,7 @@ pub(crate) async fn login_token_route(
ClientIp(client): ClientIp,
body: Ruma<get_login_token::v1::Request>,
) -> Result<get_login_token::v1::Response> {
if !services.config.login_via_existing_session {
if !services.server.config.login_via_existing_session {
return Err!(Request(Forbidden("Login via an existing session is not enabled")));
}
@@ -270,7 +259,7 @@ pub(crate) async fn login_token_route(
// Prompt the user to confirm with their password using UIAA
let _ = services
.uiaa
.authenticate_password(&body.auth, sender_user, body.identity.sender_device(), None)
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
.await?;
let login_token = utils::random_string(TOKEN_LENGTH);
+7
View File
@@ -48,6 +48,13 @@ async fn load_timeline(
ending_count: Option<PduCount>,
limit: usize,
) -> Result<TimelinePdus> {
if let (Some(starting_count), Some(ending_count)) = (starting_count, ending_count) {
debug_assert!(
starting_count <= ending_count,
"starting count {starting_count} > ending count {ending_count}"
);
}
let mut pdu_stream = match starting_count {
| Some(starting_count) => {
let last_timeline_count = services
+74 -81
View File
@@ -38,6 +38,7 @@
uint,
};
use service::{account_data::AnyRawAccountDataEvent, rooms::short::ShortStateHash};
use tokio::pin;
use super::{load_timeline, share_encrypted_room};
use crate::client::{
@@ -96,12 +97,19 @@ pub(super) async fn load_joined_room(
);
}
let state_events =
StateEvents::with_events(state_events.into_iter().map(Event::into_format).collect());
let joined_room = assign!(JoinedRoom::new(), {
account_data,
summary: summary.unwrap_or_default(),
unread_notifications: notification_counts.unwrap_or_default(),
timeline,
state: RoomState::Before(StateEvents::with_events(state_events.into_iter().map(Event::into_format).collect())),
state: if sync_context.use_state_after {
RoomState::After(state_events)
} else {
RoomState::Before(state_events)
},
ephemeral,
unread_thread_notifications: BTreeMap::new(),
});
@@ -344,7 +352,7 @@ struct ShortStateHashes {
#[tracing::instrument(level = "debug", skip_all)]
async fn fetch_shortstatehashes(
services: &Services,
SyncContext { last_sync_end_count, current_count, .. }: SyncContext<'_>,
SyncContext { last_sync_end_count, .. }: SyncContext<'_>,
room_id: &RoomId,
) -> Result<ShortStateHashes> {
// the room state currently.
@@ -354,46 +362,45 @@ async fn fetch_shortstatehashes(
.rooms
.state
.get_room_shortstatehash(room_id)
.map_err(|_| err!(Database(error!("Room {room_id} has no state"))));
.map_err(|_| err!(Database(error!("Room {room_id} has no state"))))
.await?;
// the room state as of the end of the last sync.
// this will be None if we are doing an initial sync or if we just joined this
// room.
// The room state as of the end of the last sync.
// This will be None if we are doing an initial sync.
let last_sync_end_shortstatehash =
OptionFuture::from(last_sync_end_count.map(|last_sync_end_count| {
// look up the shortstatehash saved by the last sync's call to
// `associate_token_shortstatehash`
services
.rooms
.user
.get_token_shortstatehash(room_id, last_sync_end_count)
.inspect_err(move |_| {
debug_warn!(
token = last_sync_end_count,
"Room has no shortstatehash for this token"
);
})
.ok()
OptionFuture::from(last_sync_end_count.map(async |last_sync_end_count| {
pin! {
let pdus = services
.rooms
.timeline
.pdus(room_id, Some(PduCount::Normal(last_sync_end_count)))
.ignore_err();
}
match pdus.next().await {
| Some((_, pdu_after_last_sync_end)) => {
trace!(?pdu_after_last_sync_end.event_id, "pdu at last sync end");
Some(
services
.rooms
.state_accessor
.pdu_shortstatehash(&pdu_after_last_sync_end.event_id)
.await
.map_err(|err| {
err!("Last sync end PDU has no shortstatehash: {err}")
}),
)
},
| None => {
// No events have been sent since the last sync, or we just joined this room
None
},
}
}))
.map(Option::flatten)
.map(Ok);
let (current_shortstatehash, last_sync_end_shortstatehash) =
try_join(current_shortstatehash, last_sync_end_shortstatehash).await?;
/*
associate the `current_count` with the `current_shortstatehash`, so we can
use it on the next sync as the `last_sync_end_shortstatehash`.
TODO: the table written to by this call grows extremely fast, gaining one new entry for each
joined room on _every single sync request_. we need to find a better way to remember the shortstatehash
between syncs.
*/
services
.rooms
.user
.associate_token_shortstatehash(room_id, current_count, current_shortstatehash)
.await;
.await
.flatten()
.transpose()?;
Ok(ShortStateHashes {
current_shortstatehash,
@@ -452,6 +459,7 @@ async fn build_state_events(
syncing_user,
last_sync_end_count,
full_state,
use_state_after,
..
} = sync_context;
@@ -460,32 +468,15 @@ async fn build_state_events(
last_sync_end_shortstatehash,
} = shortstatehashes;
// the spec states that the `state` property only includes state events up to
// the beginning of the timeline, so we determine the state of the syncing room
// as of the first timeline event. NOTE: this explanation is not entirely
// accurate; see the implementation of `build_state_incremental`.
let timeline_start_shortstatehash = async {
if let Some((_, pdu)) = timeline.pdus.front() {
if let Ok(shortstatehash) = services
.rooms
.state_accessor
.pdu_shortstatehash(&pdu.event_id)
.await
{
return shortstatehash;
}
}
current_shortstatehash
};
if timeline.pdus.is_empty() {
// If the timeline is empty there can't possibly be any changes to the state
return Ok(vec![]);
}
// the user IDs of members whose membership needs to be sent to the client, if
// lazy-loading is enabled.
let lazily_loaded_members =
prepare_lazily_loaded_members(services, sync_context, room_id, timeline.senders());
let (timeline_start_shortstatehash, lazily_loaded_members) =
join(timeline_start_shortstatehash, lazily_loaded_members).await;
prepare_lazily_loaded_members(services, sync_context, room_id, timeline.senders()).await;
// compute the state delta between the previous sync and this sync.
match (last_sync_end_count, last_sync_end_shortstatehash) {
@@ -494,16 +485,14 @@ async fn build_state_events(
is Some (meaning the syncing user didn't just join this room for the first time ever), and `full_state` is false,
then use `build_state_incremental`.
*/
| (Some(last_sync_end_count), Some(last_sync_end_shortstatehash)) if !full_state =>
| (Some(_), Some(last_sync_end_shortstatehash)) if !full_state =>
build_state_incremental(
services,
syncing_user,
room_id,
PduCount::Normal(last_sync_end_count),
last_sync_end_shortstatehash,
timeline_start_shortstatehash,
current_shortstatehash,
timeline,
use_state_after,
lazily_loaded_members.as_ref(),
)
.boxed()
@@ -517,7 +506,9 @@ async fn build_state_events(
build_state_initial(
services,
syncing_user,
timeline_start_shortstatehash,
current_shortstatehash,
timeline,
use_state_after,
lazily_loaded_members.as_ref(),
)
.boxed()
@@ -598,23 +589,25 @@ async fn check_joined_since_last_sync(
ShortStateHashes { last_sync_end_shortstatehash, .. }: ShortStateHashes,
SyncContext { syncing_user, .. }: SyncContext<'_>,
) -> Result<bool> {
// fetch the syncing user's membership event during the last sync.
// this will be None if `previous_sync_end_shortstatehash` is None.
let membership_during_previous_sync = match last_sync_end_shortstatehash {
| Some(last_sync_end_shortstatehash) => services
.rooms
.state_accessor
.state_get_content(
last_sync_end_shortstatehash,
&StateEventType::RoomMember,
syncing_user.as_str(),
)
.await
.inspect_err(|_| debug_warn!("User has no previous membership"))
.ok(),
| None => None,
let Some(last_sync_end_shortstatehash) = last_sync_end_shortstatehash else {
// For initial syncs always return false, since there's no "last sync" for the
// user to have joined since.
return Ok(false);
};
// Fetch the syncing user's membership event during the last sync.
let membership_during_previous_sync = services
.rooms
.state_accessor
.state_get_content(
last_sync_end_shortstatehash,
&StateEventType::RoomMember,
syncing_user.as_str(),
)
.await
.inspect_err(|_| debug_warn!("User has no previous membership"))
.ok();
// TODO: If the requesting user got state-reset out of the room, this
// will be `true` when it shouldn't be. this function should never be called
// in that situation, but it may be if the membership cache didn't get updated.
+13 -25
View File
@@ -4,7 +4,7 @@
trace,
utils::{self, IterStream, future::ReadyEqExt, stream::WidebandExt as _},
};
use futures::{StreamExt, future::join};
use futures::StreamExt;
use ruma::{
EventId, OwnedRoomId, RoomId,
api::client::sync::sync_events::v3::{
@@ -181,6 +181,9 @@ pub(super) async fn load_left_room(
.collect::<Vec<_>>()
.await;
let state_events =
StateEvents::with_events(state_events.into_iter().map(Event::into_format).collect());
Ok(Some(assign!(LeftRoom::new(), {
account_data: RoomAccountData::new(),
timeline: assign!(Timeline::new(), {
@@ -188,7 +191,11 @@ pub(super) async fn load_left_room(
prev_batch: Some(current_count.to_string()),
events: raw_timeline_pdus,
}),
state: State::Before(StateEvents::with_events(state_events.into_iter().map(Event::into_format).collect())),
state: if sync_context.use_state_after {
State::After(state_events)
} else {
State::Before(state_events)
},
})))
}
@@ -233,29 +240,8 @@ async fn build_left_state_and_timeline(
)
.await?;
let timeline_start_shortstatehash = async {
if let Some((_, pdu)) = timeline.pdus.front() {
if let Ok(shortstatehash) = services
.rooms
.state_accessor
.pdu_shortstatehash(&pdu.event_id)
.await
{
return shortstatehash;
}
}
// the timeline generally should not be empty (see the TODO further down),
// but in case it is we use `leave_shortstatehash` as the state to
// send
leave_shortstatehash
};
let lazily_loaded_members =
prepare_lazily_loaded_members(services, sync_context, room_id, timeline.senders());
let (timeline_start_shortstatehash, lazily_loaded_members) =
join(timeline_start_shortstatehash, lazily_loaded_members).await;
prepare_lazily_loaded_members(services, sync_context, room_id, timeline.senders()).await;
// TODO: calculate incremental state for incremental syncs.
// always calculating initial state _works_ but returns more data and does
@@ -263,7 +249,9 @@ async fn build_left_state_and_timeline(
let mut state = build_state_initial(
services,
syncing_user,
timeline_start_shortstatehash,
leave_shortstatehash,
&timeline,
sync_context.use_state_after,
lazily_loaded_members.as_ref(),
)
.await?;
+7 -4
View File
@@ -11,12 +11,11 @@
use axum::extract::State;
use axum_client_ip::ClientIp;
use conduwuit::{
Err, Result, at, extract_variant,
Err, Result, at, error, extract_variant,
utils::{
ReadyExt, TryFutureExtExt,
stream::{BroadbandExt, Tools, WidebandExt},
},
warn,
};
use conduwuit_service::Services;
use futures::{FutureExt, StreamExt, TryFutureExt, future::OptionFuture};
@@ -111,6 +110,9 @@ struct SyncContext<'a> {
/// The sync filter, which the client uses to specify what data should be
/// included in the sync response.
filter: &'a FilterDefinition,
/// Whether the state at the end of the timeline should be used when
/// calculating state diffs for sync.
use_state_after: bool,
}
impl<'a> SyncContext<'a> {
@@ -264,6 +266,7 @@ pub(crate) async fn build_sync_events(
current_count,
full_state,
filter: &filter,
use_state_after: body.use_state_after,
};
let joined_rooms = services
@@ -276,7 +279,7 @@ pub(crate) async fn build_sync_events(
match joined_room {
| Ok((room, updates)) => Some((room_id, room, updates)),
| Err(err) => {
warn!(?err, %room_id, "error loading joined room");
error!(?err, %room_id, "error loading joined room");
None
},
}
@@ -305,7 +308,7 @@ pub(crate) async fn build_sync_events(
| Ok(Some(left_room)) => Some((room_id, left_room)),
| Ok(None) => None,
| Err(err) => {
warn!(?err, %room_id, "error loading joined room");
error!(?err, %room_id, "error loading joined room");
None
},
}
+63 -146
View File
@@ -1,11 +1,8 @@
use std::{collections::BTreeSet, ops::ControlFlow};
use std::collections::HashSet;
use conduwuit::{
Result, at, is_equal_to,
matrix::{
Event,
pdu::{PduCount, PduEvent},
},
Result, at,
matrix::{Event, pdu::PduEvent},
utils::{
BoolExt, IterStream, ReadyExt, TryFutureExtExt,
stream::{BroadbandExt, TryIgnore},
@@ -16,9 +13,7 @@
rooms::{lazy_loading::MemberSet, short::ShortStateHash},
};
use futures::{FutureExt, StreamExt};
use itertools::Itertools;
use ruma::{OwnedEventId, RoomId, UserId, events::StateEventType};
use service::rooms::short::ShortEventId;
use ruma::{OwnedEventId, UserId, events::StateEventType};
use tracing::trace;
use crate::client::TimelinePdus;
@@ -38,14 +33,22 @@
pub(super) async fn build_state_initial(
services: &Services,
sender_user: &UserId,
timeline_start_shortstatehash: ShortStateHash,
timeline_end_shortstatehash: ShortStateHash,
timeline: &TimelinePdus,
use_state_after: bool,
lazily_loaded_members: Option<&MemberSet>,
) -> Result<Vec<PduEvent>> {
let event_ids_in_timeline: HashSet<_> =
timeline.pdus.iter().map(|pdu| &pdu.1.event_id).collect();
// load the keys and event IDs of the state events at the start of the timeline
let (shortstatekeys, event_ids): (Vec<_>, Vec<_>) = services
.rooms
.state_accessor
.state_full_ids(timeline_start_shortstatehash)
.state_full_ids(timeline_end_shortstatehash)
.ready_filter(|(_, event_id)| {
use_state_after || !event_ids_in_timeline.contains(event_id)
})
.unzip()
.await;
@@ -92,82 +95,32 @@ pub(super) async fn build_state_initial(
pub(super) async fn build_state_incremental<'a>(
services: &Services,
sender_user: &'a UserId,
room_id: &RoomId,
last_sync_end_count: PduCount,
last_sync_end_shortstatehash: ShortStateHash,
timeline_start_shortstatehash: ShortStateHash,
timeline_end_shortstatehash: ShortStateHash,
timeline: &TimelinePdus,
use_state_after: bool,
lazily_loaded_members: Option<&'a MemberSet>,
) -> Result<Vec<PduEvent>> {
/*
NB: a limited sync is one where `timeline.limited == true`. Synapse calls this a "gappy" sync internally.
let mut state_event_ids: HashSet<OwnedEventId> = HashSet::new();
The algorithm implemented in this function is, currently, quite different from the algorithm vaguely described
by the Matrix specification. This is because the specification's description of the `state` property does not accurately
reflect how Synapse behaves, and therefore how client SDKs behave. Notable differences include:
1. We do not compute the delta using the naive approach of "every state event from the end of the last sync
up to the start of this sync's timeline". see below for details.
2. If lazy-loading is enabled, we include lazily-loaded membership events. The specific users to include are determined
elsewhere and supplied to this function in the `lazily_loaded_members` parameter.
*/
trace!(
%use_state_after,
%last_sync_end_shortstatehash,
%timeline_end_shortstatehash,
"computing state for incremental sync"
);
/*
the `state` property of an incremental sync which isn't limited are _usually_ empty.
(note: the specification says that the `state` property is _always_ empty for limited syncs, which is incorrect.)
however, if an event in the timeline (`timeline.pdus`) merges a split in the room's DAG (i.e. has multiple `prev_events`),
the state at the _end_ of the timeline may include state events which were merged in and don't exist in the state
at the _start_ of the timeline. because this is uncommon, we check here to see if any events in the timeline
merged a split in the DAG.
// Fetch lazy-loaded membership events if lazy-loading is enabled
if let Some(lazily_loaded_members) = lazily_loaded_members
&& !lazily_loaded_members.is_empty()
{
trace!("including lazy membership events for members: {:?}", lazily_loaded_members);
see: https://github.com/element-hq/synapse/issues/16941
*/
let timeline_is_linear = timeline.pdus.is_empty() || {
let last_pdu_of_last_sync = services
services
.rooms
.timeline
.pdus_rev(room_id, Some(last_sync_end_count.saturating_add(1)))
.boxed()
.next()
.await
.transpose()
.expect("last sync should have had some PDUs")
.map(at!(1));
// make sure the prev_events of each pdu in the timeline refer only to the
// previous pdu
timeline
.pdus
.iter()
.try_fold(last_pdu_of_last_sync.map(|pdu| pdu.event_id), |prev_event_id, (_, pdu)| {
if let Ok(pdu_prev_event_id) = pdu.prev_events.iter().exactly_one() {
if prev_event_id
.as_ref()
.is_none_or(is_equal_to!(pdu_prev_event_id))
{
return ControlFlow::Continue(Some(pdu_prev_event_id.to_owned()));
}
}
trace!(
"pdu {:?} has split prev_events (expected {:?}): {:?}",
pdu.event_id, prev_event_id, pdu.prev_events
);
ControlFlow::Break(())
})
.is_continue()
};
if timeline_is_linear && !timeline.limited {
// if there are no splits in the DAG and the timeline isn't limited, then
// `state` will always be empty unless lazy loading is enabled.
if let Some(lazily_loaded_members) = lazily_loaded_members {
if !timeline.pdus.is_empty() {
// lazy loading is enabled, so we return the membership events which were
// requested by the caller.
let lazy_membership_events: Vec<_> = lazily_loaded_members
.short
.multi_get_eventid_from_short::<'_, OwnedEventId, _>(
lazily_loaded_members
.iter()
.stream()
.broad_filter_map(|user_id| async move {
@@ -178,71 +131,24 @@ pub(super) async fn build_state_incremental<'a>(
services
.rooms
.state_accessor
.state_get(
timeline_start_shortstatehash,
.state_get_shortid(
timeline_end_shortstatehash,
&StateEventType::RoomMember,
user_id.as_str(),
)
.ok()
.await
})
.collect()
.await;
if !lazy_membership_events.is_empty() {
trace!(
"syncing lazy membership events for members: {:?}",
lazy_membership_events
.iter()
.map(|pdu| pdu.state_key().unwrap())
.collect::<Vec<_>>()
);
}
return Ok(lazy_membership_events);
}
}
// lazy loading is disabled, `state` is empty.
return Ok(vec![]);
}),
)
.ignore_err()
.ready_for_each(|event_id| {
state_event_ids.insert(event_id);
})
.await;
}
/*
at this point, either the timeline is `limited` or the DAG has a split in it. this necessitates
computing the incremental state (which may be empty).
NOTE: this code path does not use the `lazy_membership_events` parameter. any changes to membership will be included
in the incremental state. therefore, the incremental state may include "redundant" membership events,
which we do not filter out because A. the spec forbids lazy-load filtering if the timeline is `limited`,
and B. DAG splits which require sending extra membership state events are (probably) uncommon enough that
the performance penalty is acceptable.
*/
trace!(%timeline_is_linear, %timeline.limited, "computing state for incremental sync");
// fetch the shorteventids of state events in the timeline
let state_events_in_timeline: BTreeSet<ShortEventId> = services
.rooms
.short
.multi_get_or_create_shorteventid(timeline.pdus.iter().filter_map(|(_, pdu)| {
if pdu.state_key().is_some() {
Some(pdu.event_id.as_ref())
} else {
None
}
}))
.collect()
.await;
trace!("{} state events in timeline", state_events_in_timeline.len());
/*
fetch the state events which were added since the last sync.
specifically we fetch the difference between the state at the last sync and the state at the _end_
of the timeline, and then we filter out state events in the timeline itself using the shorteventids we fetched.
this is necessary to account for splits in the DAG, as explained above.
*/
let state_diff = services
// Fetch the state events added since the last sync.
services
.rooms
.short
.multi_get_eventid_from_short::<'_, OwnedEventId, _>(
@@ -252,18 +158,29 @@ pub(super) async fn build_state_incremental<'a>(
.state_added((last_sync_end_shortstatehash, timeline_end_shortstatehash))
.await?
.stream()
.ready_filter_map(|(_, shorteventid)| {
if state_events_in_timeline.contains(&shorteventid) {
None
} else {
Some(shorteventid)
}
}),
.map(at!(1)),
)
.ignore_err();
.ignore_err()
.ready_for_each(|event_id| {
state_event_ids.insert(event_id);
})
.await;
// finally, fetch the PDU contents and collect them into a vec
let state_diff_pdus = state_diff
if !use_state_after {
// If state_after isn't enabled, filter out state events which also exist
// in the timeline. If splits exist in the DAG, this may not be exactly the same
// thing as the state diff ending at the start of the timeline, but Synapse
// also does this and it's technically more useful behavior anyway.
// See: https://github.com/element-hq/synapse/issues/16941
for (_, pdu) in &timeline.pdus {
state_event_ids.remove(pdu.event_id());
}
}
// Finally, fetch the PDU contents and collect them into a vec
let state_diff_pdus = state_event_ids
.stream()
.broad_filter_map(|event_id| async move {
services
.rooms
+28 -7
View File
@@ -15,7 +15,7 @@
BoolExt, FutureBoolExt, IterStream, ReadyExt, TryFutureExtExt,
future::ReadyEqExt,
math::{ruma_from_usize, usize_from_ruma},
stream::WidebandExt,
stream::{TryIgnore, WidebandExt},
},
warn,
};
@@ -41,6 +41,7 @@
uint,
};
use service::account_data::AnyRawAccountDataEvent;
use tokio::pin;
use super::share_encrypted_room;
use crate::{
@@ -69,6 +70,7 @@ pub(crate) async fn sync_events_v5_route(
ClientIp(client_ip): ClientIp,
body: Ruma<sync_events::v5::Request>,
) -> Result<sync_events::v5::Response> {
debug_assert!(DEFAULT_BUMP_TYPES.is_sorted(), "DEFAULT_BUMP_TYPES is not sorted");
let sender_user = body.identity.expect_sender_user()?;
let sender_device = body.identity.expect_sender_device()?;
@@ -857,12 +859,31 @@ async fn collect_e2ee<'a, Rooms>(
continue;
};
let since_shortstatehash = services
.rooms
.user
.get_token_shortstatehash(room_id, globalsince)
.await
.ok();
let since_shortstatehash = async {
pin! {
let pdus_rev = services
.rooms
.timeline
.pdus_rev(room_id, Some(PduCount::Normal(globalsince.saturating_sub(1))))
.ignore_err();
}
let (count, pdu_at_last_sync_end) = pdus_rev.next().await?;
if matches!(count, PduCount::Backfilled(_)) {
None
} else {
Some(
services
.rooms
.state_accessor
.pdu_shortstatehash(&pdu_at_last_sync_end.event_id)
.await
.expect("pdu should have a shortstatehash"),
)
}
}
.await;
let encrypted_room = services
.rooms
+2 -2
View File
@@ -35,8 +35,8 @@ pub(crate) async fn get_supported_versions_route(
/// `/_matrix/federation/v1/version`
pub(crate) async fn conduwuit_server_version() -> Result<impl IntoResponse> {
Ok(Json(serde_json::json!({
"name": conduwuit::BRANDING,
"version": conduwuit::version(),
"name": conduwuit::version::name(),
"version": conduwuit::version::version(),
})))
}
+42 -2
View File
@@ -3,7 +3,8 @@
use ruma::{
api::client::discovery::{
discover_homeserver::{self, HomeserverInfo},
discover_policy_server, discover_support,
discover_policy_server,
discover_support::{self, Contact, ContactRole},
},
assign,
};
@@ -66,7 +67,46 @@ pub(crate) async fn well_known_support(
.as_ref()
.map(ToString::to_string);
let contacts = services.admin.get_support_contacts().await;
let email_address = services.config.well_known.support_email.clone();
let matrix_id = services.config.well_known.support_mxid.clone();
let pgp_key = services.config.well_known.support_pgp_key.clone();
// TODO: support defining multiple contacts in the config
let mut contacts: Vec<Contact> = vec![];
let role = services
.config
.well_known
.support_role
.clone()
.unwrap_or(ContactRole::Admin);
// Add configured contact if at least one contact method is specified
let configured_contact = match (matrix_id, email_address) {
| (Some(matrix_id), email_address) =>
Some(assign!(Contact::with_matrix_id(role, matrix_id), { email_address })),
| (None, Some(email_address)) => Some(Contact::with_email_address(role, email_address)),
| (None, None) => None,
};
if let Some(mut configured_contact) = configured_contact {
configured_contact.pgp_key = pgp_key;
contacts.push(configured_contact);
}
// Try to add admin users as contacts if no contacts are configured
if contacts.is_empty() {
let admin_users = services.admin.get_admins().await;
for user_id in &admin_users {
if *user_id == services.globals.server_user {
continue;
}
contacts.push(Contact::with_matrix_id(ContactRole::Admin, user_id.to_owned()));
}
}
if contacts.is_empty() && support_page.is_none() {
// No admin room, no configured contacts, and no support page
+2 -1
View File
@@ -1,5 +1,4 @@
#![type_length_limit = "16384"] //TODO: reduce me
#![recursion_limit = "256"] // My Giant Async Function
#![allow(clippy::toplevel_ref_arg)]
extern crate conduwuit_core as conduwuit;
@@ -11,6 +10,8 @@
pub mod router;
pub mod server;
pub mod admin;
pub(crate) use self::router::{Ruma, RumaResponse, State};
conduwuit::mod_ctor! {}
+7 -21
View File
@@ -10,18 +10,16 @@
response::{IntoResponse, Redirect},
routing::{any, get, post},
};
use conduwuit::err;
use conduwuit::{Server, err};
pub(super) use conduwuit_service::state::State;
use http::{Uri, uri};
use self::handler::RouterExt;
pub(super) use self::{args::Args as Ruma, auth::ClientIdentity, response::RumaResponse};
#[cfg(feature = "admin_api")]
use crate::client::admin::site as admin_api;
use crate::{client, server};
use crate::{admin, client, server};
pub fn build(router: Router<State>, state: State) -> Router<State> {
let config = &state.server.config;
pub fn build(router: Router<State>, server: &Server) -> Router<State> {
let config = &server.config;
let mut router = router
.ruma_route(&client::appservice_ping)
.ruma_route(&client::get_supported_versions_route)
@@ -183,17 +181,15 @@ pub fn build(router: Router<State>, state: State) -> Router<State> {
.ruma_route(&client::get_room_summary)
.ruma_route(&client::get_suspended_status)
.ruma_route(&client::put_suspended_status)
.ruma_route(&client::get_locked_status)
.ruma_route(&client::put_locked_status)
.ruma_route(&client::well_known_support)
.ruma_route(&client::well_known_client)
.ruma_route(&client::well_known_policy_server)
.ruma_route(&client::get_rtc_transports)
.ruma_route(&client::room_initial_sync_route)
.ruma_route(&client::get_authorization_server_metadata_route)
.merge(client::oauth::router(state))
.route("/_conduwuit/server_version", get(client::conduwuit_server_version))
.route("/_continuwuity/server_version", get(client::conduwuit_server_version));
.route("/_continuwuity/server_version", get(client::conduwuit_server_version))
.ruma_route(&admin::rooms::ban::ban_room)
.ruma_route(&admin::rooms::list::list_rooms);
if config.allow_federation {
router = router
@@ -278,16 +274,6 @@ pub fn build(router: Router<State>, state: State) -> Router<State> {
.route("/_matrix/media/r0/preview_url", any(redirect_legacy_preview));
}
#[cfg(feature = "admin_api")]
{
router = router
.ruma_route(&admin_api::users::list_users_route)
.ruma_route(&admin_api::users::create_user_route)
.ruma_route(&admin_api::rooms::ban_room)
.ruma_route(&admin_api::rooms::legacy_list_rooms_route)
.ruma_route(&admin_api::rooms::list_rooms_route);
};
router
}
+129 -189
View File
@@ -1,28 +1,21 @@
use std::{
any::{Any, TypeId},
fmt::Display,
};
use std::any::{Any, TypeId};
use conduwuit::{Err, Error, Result, err};
use http::StatusCode;
use conduwuit::{Err, Result, err};
use ruma::{
DeviceId, OwnedDeviceId, OwnedServerName, OwnedUserId, UserId,
api::{
IncomingRequest, OAuthScope,
IncomingRequest,
auth_scheme::{
AccessToken, AccessTokenOptional, AppserviceToken, AppserviceTokenOptional,
AuthScheme, NoAccessToken, NoAuthentication,
},
client,
error::{ErrorKind, UnknownTokenErrorData},
federation::authentication::ServerSignatures,
},
assign,
};
use service::{
Services,
server_keys::{PubKeyMap, PubKeys},
users::AccessTokenStatus,
};
use crate::{router::args::AuthQueryParams, service::appservice::RegistrationInfo};
@@ -79,66 +72,68 @@ pub(crate) fn appservice_info(&self) -> Option<&RegistrationInfo> {
pub(crate) fn is_appservice(&self) -> bool { matches!(self, Self::Appservice { .. }) }
}
impl Display for ClientIdentity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
| Self::User { sender_user, sender_device } =>
write!(f, "{sender_user} ({sender_device})"),
| Self::Appservice { sender_user, appservice_info, .. } =>
write!(f, "appservice `{}` using {sender_user}", appservice_info.registration.id),
}
}
}
pub(crate) trait CheckAuth: AuthScheme {
type Identity: Send;
fn authenticate<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
fn authenticate<R: IncomingRequest + Any, B: AsRef<[u8]> + Sync>(
services: &Services,
incoming_request: &hyper::Request<B>,
query: AuthQueryParams,
) -> impl Future<Output = Result<Self::Identity>> + Send {
async move {
let route = TypeId::of::<R>();
let output = Self::extract_authentication(incoming_request).map_err(|err| {
err!(Request(Unauthorized(warn!(
"Failed to extract request authentication: {}",
"Failed to extract authorization: {}",
err.into()
))))
})?;
Self::verify::<R, B>(services, output, incoming_request, query).await
Self::verify(services, output, incoming_request, query, route).await
}
}
fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
fn verify<B: AsRef<[u8]> + Sync>(
services: &Services,
output: Self::Output,
request: &hyper::Request<B>,
query: AuthQueryParams,
route: TypeId,
) -> impl Future<Output = Result<Self::Identity>> + Send;
}
impl CheckAuth for ServerSignatures {
type Identity = OwnedServerName;
async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
async fn verify<B: AsRef<[u8]> + Sync>(
services: &Services,
output: Self::Output,
request: &hyper::Request<B>,
_query: AuthQueryParams,
_route: TypeId,
) -> Result<Self::Identity> {
let destination = services.globals.server_name();
if output
.destination
.as_ref()
.is_some_and(|supplied_destination| supplied_destination != destination)
{
return Err!(Request(Unauthorized("Destination mismatch.")));
}
let key = services
.server_keys
.get_verify_key(&output.origin, &output.key)
.await
.map_err(|err| {
err!(Request(Unauthorized(warn!("Failed to fetch signing keys: {err}"))))
.map_err(|e| {
err!(Request(Unauthorized(warn!("Failed to fetch signing keys: {e}"))))
})?;
let keys: PubKeys = [(output.key.to_string(), key.key)].into();
let keys: PubKeyMap = [(output.origin.as_str().into(), keys)].into();
match output.verify_request(request, services.globals.server_name(), &keys) {
match output.verify_request(request, destination, &keys) {
| Ok(()) => {
if services
.moderation
@@ -160,36 +155,95 @@ async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]>
impl CheckAuth for AccessToken {
type Identity = ClientIdentity;
async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
async fn verify<B: AsRef<[u8]> + Sync>(
services: &Services,
output: Self::Output,
_request: &hyper::Request<B>,
query: AuthQueryParams,
route: TypeId,
) -> Result<Self::Identity> {
verify_access_token(services, output, query, TypeId::of::<R>(), R::required_scopes())
.await
if let Ok((sender_user, sender_device)) = services.users.find_from_token(&output).await {
// Locked users can only use /logout and /logout/all
if services
.users
.is_locked(&sender_user)
.await
.is_ok_and(std::convert::identity)
{
if !(route == TypeId::of::<client::session::logout::v3::Request>()
|| route == TypeId::of::<client::session::logout_all::v3::Request>())
{
return Err!(Request(Unauthorized("Your account is locked.")));
}
}
Ok(ClientIdentity::User { sender_user, sender_device })
} else if let Ok(appservice_info) = services.appservice.find_from_token(&output).await {
let Ok(sender_user) = query.user_id.clone().map_or_else(
|| {
UserId::parse_with_server_name(
appservice_info.registration.sender_localpart.as_str(),
services.globals.server_name(),
)
},
UserId::parse,
) else {
return Err!(Request(InvalidUsername("Username is invalid.")));
};
if !appservice_info.is_user_match(&sender_user) {
return Err!(Request(Exclusive("User is not in namespace.")));
}
// MSC3202/MSC4190: Handle device_id masquerading for appservices.
// The device_id can be provided via `device_id` or
// `org.matrix.msc3202.device_id` query parameter.
let sender_device =
if let Some(device_id) = query.device_id.as_deref().map(Into::into) {
// Verify the device exists for this user
if services
.users
.get_device_metadata(&sender_user, device_id)
.await
.is_err()
{
return Err!(Request(Forbidden(
"Device does not exist for user or appservice cannot masquerade as \
this device."
)));
}
Some(device_id.to_owned())
} else {
None
};
Ok(ClientIdentity::Appservice {
sender_user,
sender_device,
appservice_info: Box::new(appservice_info),
})
} else {
Err!(Request(Unauthorized("Invalid access token.")))
}
}
}
impl CheckAuth for AccessTokenOptional {
type Identity = Option<ClientIdentity>;
async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
async fn verify<B: AsRef<[u8]> + Sync>(
services: &Services,
output: Self::Output,
_request: &hyper::Request<B>,
request: &hyper::Request<B>,
query: AuthQueryParams,
route: TypeId,
) -> Result<Self::Identity> {
match output {
| Some(token) => verify_access_token(
services,
token,
query,
TypeId::of::<R>(),
R::required_scopes(),
)
.await
.map(Some),
| Some(token) =>
<AccessToken as CheckAuth>::verify(services, token, request, query, route)
.await
.map(Some),
| None => Ok(None),
}
}
@@ -198,29 +252,36 @@ async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]>
impl CheckAuth for AppserviceToken {
type Identity = RegistrationInfo;
async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
async fn verify<B: AsRef<[u8]> + Sync>(
services: &Services,
output: Self::Output,
_request: &hyper::Request<B>,
_query: AuthQueryParams,
_route: TypeId,
) -> Result<Self::Identity> {
verify_appservice_access_token(services, output).await
let Ok(appservice_info) = services.appservice.find_from_token(&output).await else {
return Err!(Request(Unauthorized("Invalid appservice token.")));
};
Ok(appservice_info)
}
}
impl CheckAuth for AppserviceTokenOptional {
type Identity = Option<RegistrationInfo>;
async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
async fn verify<B: AsRef<[u8]> + Sync>(
services: &Services,
output: Self::Output,
_request: &hyper::Request<B>,
_query: AuthQueryParams,
request: &hyper::Request<B>,
query: AuthQueryParams,
route: TypeId,
) -> Result<Self::Identity> {
match output {
| Some(token) => verify_appservice_access_token(services, token)
.await
.map(Some),
| Some(token) =>
<AppserviceToken as CheckAuth>::verify(services, token, request, query, route)
.await
.map(Some),
| None => Ok(None),
}
}
@@ -229,11 +290,12 @@ async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]>
impl CheckAuth for NoAuthentication {
type Identity = ();
async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
async fn verify<B: AsRef<[u8]> + Sync>(
_services: &Services,
_output: Self::Output,
_request: &hyper::Request<B>,
_query: AuthQueryParams,
_route: TypeId,
) -> Result<Self::Identity> {
Ok(())
}
@@ -242,153 +304,31 @@ async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]>
impl CheckAuth for NoAccessToken {
type Identity = Option<ClientIdentity>;
async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
async fn verify<B: AsRef<[u8]> + Sync>(
services: &Services,
_output: Self::Output,
request: &hyper::Request<B>,
query: AuthQueryParams,
route: TypeId,
) -> Result<Self::Identity> {
// We handle these the same as AccessTokenOptional
let token = AccessTokenOptional::extract_authentication(request).map_err(|err| {
err!(Request(Unauthorized(warn!("Failed to extract authorization: {}", err))))
})?;
match token {
| Some(token) => verify_access_token(
services,
token,
query,
TypeId::of::<R>(),
// Assume that no scopes are required for these endpoints since
// ostensibly they don't require authentication
&[],
)
.await
.map(Some),
| None => Ok(None),
// Check special access restrictions
if (route == TypeId::of::<client::profile::get_avatar_url::v3::Request>()
|| route == TypeId::of::<client::profile::get_display_name::v3::Request>()
|| route == TypeId::of::<client::profile::get_profile_field::v3::Request>()
|| route == TypeId::of::<client::profile::get_profile::v3::Request>())
&& services.config.require_auth_for_profile_requests
&& token.is_none()
{
return Err!(Request(Unauthorized(
"This server requires authentication to access user profiles."
)));
}
<AccessTokenOptional as CheckAuth>::verify(services, token, request, query, route).await
}
}
async fn verify_access_token(
services: &Services,
output: String,
query: AuthQueryParams,
route: TypeId,
required_scopes: &[OAuthScope],
) -> Result<ClientIdentity> {
if let Some((sender_user, sender_device, status)) =
services.users.find_from_token(&output).await
{
// If the token is expired we return a soft logout
if matches!(status, AccessTokenStatus::Expired) {
return Err(Error::Request(
ErrorKind::UnknownToken(
assign!(UnknownTokenErrorData::new(), { soft_logout: true }),
),
"This access token has expired.".into(),
StatusCode::UNAUTHORIZED,
));
}
// Locked users can only use /logout and /logout/all
if services
.users
.is_locked(&sender_user)
.await
.is_ok_and(std::convert::identity)
{
if !(route == TypeId::of::<client::session::logout::v3::Request>()
|| route == TypeId::of::<client::session::logout_all::v3::Request>())
{
return Err!(Request(UserLocked("Your account is locked.")));
}
}
// If this device is bound to an OAuth session, check its scopes. This will also
// handle admin-only endpoints for OAuth clients.
if let Some(session) = services
.oauth
.get_session_info_for_device(&sender_user, &sender_device)
.await
{
if required_scopes
.iter()
.all(|scope| !session.scopes.contains(scope))
{
return Err!(Request(Forbidden(
"You don't have the necessary scopes to use this endpoint."
)));
}
} else {
// Otherwise, explicitly check if the endpoint is restricted to admins only.
if required_scopes.contains(&OAuthScope::ServerAdministration)
&& !services.users.is_admin(&sender_user).await
{
return Err!(Request(Forbidden(
"Only server administrators can use this endpoint."
)));
}
}
Ok(ClientIdentity::User { sender_user, sender_device })
} else if let Ok(appservice_info) = services.appservice.find_from_token(&output).await {
let Ok(sender_user) = query.user_id.clone().map_or_else(
|| {
UserId::parse_with_server_name(
appservice_info.registration.sender_localpart.as_str(),
services.globals.server_name(),
)
},
UserId::parse,
) else {
return Err!(Request(InvalidUsername("Username is invalid.")));
};
if !appservice_info.is_user_match(&sender_user) {
return Err!(Request(Exclusive("User is not in this appservice's namespace.")));
}
// MSC3202/MSC4190: Handle device_id masquerading for appservices.
// The device_id can be provided via `device_id` or
// `org.matrix.msc3202.device_id` query parameter.
let sender_device = if let Some(device_id) = query.device_id.as_deref().map(Into::into) {
// Verify the device exists for this user
if services
.users
.get_device_metadata(&sender_user, device_id)
.await
.is_err()
{
return Err!(Request(Forbidden("Appservice cannot masquerade as this device.")));
}
Some(device_id.to_owned())
} else {
None
};
Ok(ClientIdentity::Appservice {
sender_user,
sender_device,
appservice_info: Box::new(appservice_info),
})
} else {
Err(Error::Request(
ErrorKind::UnknownToken(UnknownTokenErrorData::new()),
"Invalid access token.".into(),
StatusCode::UNAUTHORIZED,
))
}
}
async fn verify_appservice_access_token(
services: &Services,
output: String,
) -> Result<RegistrationInfo> {
let Ok(appservice_info) = services.appservice.find_from_token(&output).await else {
return Err!(Request(Unauthorized("Invalid appservice token.")));
};
Ok(appservice_info)
}
+2 -2
View File
@@ -11,8 +11,8 @@ pub(crate) async fn get_server_version_route(
) -> Result<get_server_version::v1::Response> {
Ok(assign!(get_server_version::v1::Response::new(), {
server: Some(assign!(get_server_version::v1::Server::new(), {
name: Some(conduwuit::BRANDING.into()),
version: Some(conduwuit::version().into()),
name: Some(conduwuit::version::name().into()),
version: Some(conduwuit::version::version().into()),
})),
}))
}
+12 -79
View File
@@ -4,7 +4,7 @@
pub mod proxy;
use std::{
collections::{BTreeMap, BTreeSet},
collections::{BTreeMap, BTreeSet, HashMap},
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
path::PathBuf,
};
@@ -655,25 +655,19 @@ pub struct Config {
/// even if `recaptcha_site_key` is set.
pub recaptcha_private_site_key: Option<String>,
/// display: nested
#[serde(default)]
pub registration_terms: RegistrationTerms,
/// display: nested
#[serde(default)]
pub oauth: OauthConfig,
/// Controls whether users are allowed to deactivate their own accounts
/// through the account management panel or their Matrix clients. Server
/// admins can always deactivate users using the relevant admin commands.
/// Policy documents, such as terms and conditions or a privacy policy,
/// which users must agree to when registering an account.
///
/// Note that, in some jurisdictions, you may be legally required to honor
/// users who request to deactivate their accounts if you set this option
/// to `false`.
/// Example:
/// ```ignore
/// [global.registration_terms.privacy_policy]
/// en = { name = "Privacy Policy", url = "https://homeserver.example/en/privacy_policy.html" }
/// es = { name = "Política de Privacidad", url = "https://homeserver.example/es/privacy_policy.html" }
/// ```
///
/// default: true
#[serde(default = "true_fn")]
pub allow_deactivation: bool,
/// default: {}
#[serde(default)]
pub registration_terms: HashMap<String, HashMap<String, TermsDocument>>,
/// Controls whether encrypted rooms and events are allowed.
#[serde(default = "true_fn")]
@@ -2357,30 +2351,6 @@ pub struct SmtpConfig {
pub require_email_for_token_registration: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[config_example_generator(
filename = "conduwuit-example.toml",
section = "global.registration_terms",
optional = "true"
)]
pub struct RegistrationTerms {
/// The language code to provide to clients along with the policy documents.
///
/// default: "en"
pub language: String,
/// Policy documents, such as terms and conditions or a privacy policy,
/// which users must agree to when registering an account.
///
/// Example:
/// ```ignore
/// [global.registration_terms.documents]
/// privacy_policy = { name = "Privacy Policy", url = "https://homeserver.example/en/privacy_policy.html" }
/// ```
///
/// default: {}
pub documents: BTreeMap<String, TermsDocument>,
}
/// A policy document for use with a m.login.terms stage.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct TermsDocument {
@@ -2388,43 +2358,6 @@ pub struct TermsDocument {
pub url: String,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[config_example_generator(
filename = "conduwuit-example.toml",
section = "global.oauth",
optional = "true"
)]
pub struct OauthConfig {
/// The compatibility mode to use for OAuth.
///
/// - "disabled": OAuth will be unavailable. Users will only be able to log
/// in using legacy authentication.
/// - "hybrid": OAuth and legacy authentication will both be available. Some
/// clients may only use one or the other.
/// - "exclusive": Only OAuth will be available. Clients which require
/// legacy authentication will be unable to log in.
///
/// default: "hybrid"
pub compatibility_mode: OAuthMode,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OAuthMode {
Disabled,
#[default]
Hybrid,
Exclusive,
}
impl OAuthMode {
#[must_use]
pub fn uiaa_available(&self) -> bool { matches!(self, Self::Disabled | Self::Hybrid) }
#[must_use]
pub fn oauth_available(&self) -> bool { matches!(self, Self::Hybrid | Self::Exclusive) }
}
const DEPRECATED_KEYS: &[&str] = &[
"cache_capacity",
"conduit_cache_capacity_modifier",
+1 -2
View File
@@ -118,7 +118,7 @@ pub enum Error {
#[error(transparent)]
Mxid(#[from] ruma::IdParseError),
#[error("from {0}: {1}")]
Redaction(ruma::OwnedServerName, ruma::canonical_json::CanonicalJsonFieldError),
Redaction(ruma::OwnedServerName, ruma::canonical_json::RedactionError),
#[error("{0:?}: {1}")]
Request(ErrorKind, Cow<'static, str>, http::StatusCode),
#[error(transparent)]
@@ -161,7 +161,6 @@ pub fn message(&self) -> String {
match self {
| Self::Federation(origin, error) => format!("Answer from {origin}: {error}"),
| Self::Ruma(error) => response::ruma_error_message(error),
| Self::Request(_, message, _) => message.clone().into_owned(),
| _ => format!("{self}"),
}
}
+4 -1
View File
@@ -73,8 +73,11 @@ pub(super) fn bad_request_code(kind: &ErrorKind) -> StatusCode {
// 413
| TooLarge => StatusCode::PAYLOAD_TOO_LARGE,
// 405
| Unrecognized => StatusCode::METHOD_NOT_ALLOWED,
// 404
| Unrecognized | NotFound => StatusCode::NOT_FOUND,
| NotFound => StatusCode::NOT_FOUND,
// 403
| GuestAccessForbidden
+9 -6
View File
@@ -7,16 +7,19 @@
use std::sync::OnceLock;
pub const BRANDING: &str = "continuwuity";
pub const ROUTE_PREFIX: &str = "/_continuwuity";
pub const WEBSITE: &str = "https://continuwuity.org";
pub const SEMANTIC: &str = env!("CARGO_PKG_VERSION");
static BRANDING: &str = "continuwuity";
static WEBSITE: &str = "https://continuwuity.org";
static SEMANTIC: &str = env!("CARGO_PKG_VERSION");
static VERSION: OnceLock<String> = OnceLock::new();
static VERSION_UA: OnceLock<String> = OnceLock::new();
static USER_AGENT: OnceLock<String> = OnceLock::new();
static USER_AGENT_MEDIA: OnceLock<String> = OnceLock::new();
#[inline]
#[must_use]
pub fn name() -> &'static str { BRANDING }
#[inline]
pub fn version() -> &'static str { VERSION.get_or_init(init_version) }
@@ -29,10 +32,10 @@ pub fn user_agent() -> &'static str { USER_AGENT.get_or_init(init_user_agent) }
#[inline]
pub fn user_agent_media() -> &'static str { USER_AGENT_MEDIA.get_or_init(init_user_agent_media) }
fn init_user_agent() -> String { format!("{BRANDING}/{} (bot; +{WEBSITE})", version_ua()) }
fn init_user_agent() -> String { format!("{}/{} (bot; +{WEBSITE})", name(), version_ua()) }
fn init_user_agent_media() -> String {
format!("{BRANDING}/{} (embedbot; facebookexternalhit/1.1; +{WEBSITE})", version_ua())
format!("{}/{} (embedbot; facebookexternalhit/1.1; +{WEBSITE})", name(), version_ua())
}
fn init_version_ua() -> String {
+2 -2
View File
@@ -1,4 +1,4 @@
use std::collections::BTreeSet;
use std::{borrow::Borrow, collections::BTreeSet};
use futures::{
Future,
@@ -824,7 +824,7 @@ struct GetThirdPartyInvite {
let prev_event_is_create_event = prev_events
.next()
.is_some_and(|event_id| event_id == create_room.event_id());
.is_some_and(|event_id| event_id.borrow() == create_room.event_id().borrow());
let no_more_prev_events = prev_events.next().is_none();
if prev_event_is_create_event && no_more_prev_events {
+1 -2
View File
@@ -21,7 +21,7 @@ pub fn versions() -> Vec<String> {
"v1.12".to_owned(),
"v1.13".to_owned(),
"v1.14".to_owned(),
"v1.15".to_owned(),
"v1.16".to_owned(),
]
}
@@ -44,6 +44,5 @@ pub fn unstable_features() -> BTreeMap<String, bool> {
("uk.timedout.msc4323".to_owned(), true), /* agnostic suspend (https://github.com/matrix-org/matrix-spec-proposals/pull/4323) */
("org.matrix.msc4155".to_owned(), true), /* invite filtering (https://github.com/matrix-org/matrix-spec-proposals/pull/4155) */
("computer.gingershaped.msc4466".to_owned(), true), /* profile change propagation (https://github.com/matrix-org/matrix-spec-proposals/pull/4466) */
("org.continuwuity.msc4484.unstable".to_owned(), true), /* server admin oauth scope (https://github.com/matrix-org/matrix-spec-proposals/pull/4484) */
])
}
+4 -1
View File
@@ -34,7 +34,10 @@ macro_rules! mod_dtor {
pub use conduwuit_build_metadata as build_metadata;
pub use config::Config;
pub use error::Error;
pub use info::version::*;
pub use info::{
version,
version::{name, version},
};
pub use matrix::{Event, EventTypeExt, Pdu, PduCount, PduEvent, PduId, pdu, state_res};
pub use parking_lot::{Mutex as SyncMutex, RwLock as SyncRwLock};
pub use server::Server;
+10 -16
View File
@@ -61,23 +61,17 @@ pub fn format(ts: SystemTime, str: &str) -> String {
pub fn pretty(d: Duration) -> String {
use Unit::*;
let fmt = |w, u| {
if w == 1 {
format!("{w} {u}")
} else {
format!("{w} {u}s")
}
};
let gen64 = |w, u| fmt(w, u);
let gen128 = |w, u| gen64(u64::try_from(w).expect("u128 to u64"), u);
let fmt = |w, f, u| format!("{w}.{f} {u}");
let gen64 = |w, f, u| fmt(w, (f * 100.0) as u32, u);
let gen128 = |w, f, u| gen64(u64::try_from(w).expect("u128 to u64"), f, u);
match whole_and_frac(d) {
| (Days(whole), _) => gen64(whole, "day"),
| (Hours(whole), _) => gen64(whole, "hour"),
| (Mins(whole), _) => gen64(whole, "minute"),
| (Secs(whole), _) => gen64(whole, "second"),
| (Millis(whole), _) => gen128(whole, "millisecond"),
| (Micros(whole), _) => gen128(whole, "microsecond"),
| (Nanos(whole), _) => gen128(whole, "nanosecond"),
| (Days(whole), frac) => gen64(whole, frac, "days"),
| (Hours(whole), frac) => gen64(whole, frac, "hours"),
| (Mins(whole), frac) => gen64(whole, frac, "minutes"),
| (Secs(whole), frac) => gen64(whole, frac, "seconds"),
| (Millis(whole), frac) => gen128(whole, frac, "milliseconds"),
| (Micros(whole), frac) => gen128(whole, frac, "microseconds"),
| (Nanos(whole), frac) => gen128(whole, frac, "nanoseconds"),
}
}
+1 -26
View File
@@ -49,10 +49,6 @@ pub(super) fn open_list(db: &Arc<Engine>, maps: &[Descriptor]) -> Result<Maps> {
name: "bannedroomids",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "clientid_clientmetadata",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "disabledroomids",
..descriptor::RANDOM_SMALL
@@ -161,10 +157,6 @@ pub(super) fn open_list(db: &Arc<Engine>, maps: &[Descriptor]) -> Result<Maps> {
name: "referencedevents",
..descriptor::RANDOM
},
Descriptor {
name: "refreshtoken_refreshtokeninfo",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "registrationtoken_info",
..descriptor::RANDOM_SMALL
@@ -201,12 +193,7 @@ pub(super) fn open_list(db: &Arc<Engine>, maps: &[Descriptor]) -> Result<Maps> {
},
Descriptor {
name: "roomsynctoken_shortstatehash",
file_shape: 3,
val_size_hint: Some(8),
block_size: 512,
compression_level: 3,
bottommost_level: Some(6),
..descriptor::SEQUENTIAL
..descriptor::DROPPED
},
Descriptor {
name: "roomuserdataid_accountdata",
@@ -379,14 +366,6 @@ pub(super) fn open_list(db: &Arc<Engine>, maps: &[Descriptor]) -> Result<Maps> {
name: "userdevicetxnid_response",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "userdeviceid_oauthsessioninfo",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "userdeviceid_tokenexpires",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "userfilterid_filter",
..descriptor::RANDOM_SMALL
@@ -491,8 +470,4 @@ pub(super) fn open_list(db: &Arc<Engine>, maps: &[Descriptor]) -> Result<Maps> {
name: "userroomid_invitesender",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "websessionid_session",
..descriptor::RANDOM_SMALL
},
];
-1
View File
@@ -68,7 +68,6 @@ full = [
"jemalloc_prof",
"perf_measurements",
"tokio_console",
"conduwuit-api/admin_api",
]
brotli_compression = [
+1 -1
View File
@@ -15,7 +15,7 @@
#[clap(
about,
long_about = None,
name = conduwuit_core::BRANDING,
name = conduwuit_core::name(),
version = conduwuit_core::version(),
)]
pub struct Args {
+1 -1
View File
@@ -110,7 +110,7 @@ pub(crate) fn init(
.with_batch_exporter(exporter)
.build();
let tracer = provider.tracer(conduwuit_core::BRANDING);
let tracer = provider.tracer(conduwuit_core::name());
let telemetry = tracing_opentelemetry::layer().with_tracer(tracer);
+1 -1
View File
@@ -47,7 +47,7 @@ fn options(config: &Config) -> ClientOptions {
traces_sample_rate: config.sentry_traces_sample_rate,
debug: cfg!(debug_assertions),
release: release_name(),
user_agent: conduwuit_core::user_agent().into(),
user_agent: conduwuit_core::version::user_agent().into(),
attach_stacktrace: config.sentry_attach_stacktrace,
before_send: Some(Arc::new(before_send)),
before_breadcrumb: Some(Arc::new(before_breadcrumb)),
+1 -3
View File
@@ -112,9 +112,7 @@ fn handle_result(method: &Method, uri: &Uri, result: Response) -> Result<Respons
}
if status == StatusCode::METHOD_NOT_ALLOWED {
return Ok(
err!(Request(Unrecognized("Method not allowed"), METHOD_NOT_ALLOWED)).into_response()
);
return Ok(err!(Request(Unrecognized("Method Not Allowed"))).into_response());
}
Ok(result)
+2 -2
View File
@@ -9,8 +9,8 @@
pub(crate) fn build(services: &Arc<Services>) -> (Router, Guard) {
let router = Router::<state::State>::new();
let (state, guard) = state::create(services.clone());
let router = conduwuit_api::router::build(router, state)
.merge(conduwuit_web::build(services))
let router = conduwuit_api::router::build(router, &services.server)
.merge(conduwuit_web::build())
.fallback(not_found)
.with_state(state);
@@ -1,2 +1 @@
pub mod rooms;
pub mod users;
@@ -1,7 +1,7 @@
pub mod v1 {
use ruma::{
OwnedRoomAliasId, OwnedRoomId, OwnedUserId,
api::{OAuthScope, auth_scheme::AccessToken, request, response},
api::{auth_scheme::AccessToken, request, response},
metadata,
};
@@ -9,10 +9,8 @@ pub mod v1 {
method: PUT,
rate_limited: false,
authentication: AccessToken,
required_scopes: [OAuthScope::ServerAdministration],
history: {
unstable("org.continuwuity.admin") => "/_continuwuity/admin/rooms/{room_id}/ban",
1.0 => "/_continuwuity/admin/v1/rooms/{room_id}/ban",
1.0 => "/_continuwuity/admin/rooms/{room_id}/ban",
}
}
@@ -31,11 +29,8 @@ pub struct Request {
#[response]
pub struct Response {
/// Users who were successfully kicked from this room.
pub kicked_users: Vec<OwnedUserId>,
/// Users who could not be kicked from the room.
pub failed_kicked_users: Vec<OwnedUserId>,
/// Any local aliases that were removed from the room.
pub local_aliases: Vec<OwnedRoomAliasId>,
}
@@ -1,7 +1,7 @@
pub mod unstable {
pub mod v1 {
use ruma::{
OwnedRoomId,
api::{OAuthScope, auth_scheme::AccessToken, request, response},
api::{auth_scheme::AccessToken, request, response},
metadata,
};
@@ -9,9 +9,8 @@ pub mod unstable {
method: GET,
rate_limited: false,
authentication: AccessToken,
required_scopes: [OAuthScope::ServerAdministration],
history: {
unstable => "/_continuwuity/admin/rooms/list",
1.0 => "/_continuwuity/admin/rooms/list",
}
}
@@ -21,7 +20,6 @@ pub mod unstable {
#[response]
pub struct Response {
/// A list of room IDs known to this server.
pub rooms: Vec<OwnedRoomId>,
}
@@ -35,133 +33,3 @@ impl Response {
pub fn new(rooms: Vec<OwnedRoomId>) -> Self { Self { rooms } }
}
}
pub mod v1 {
use ruma::{
OwnedRoomId, OwnedUserId, RoomVersionId,
api::{auth_scheme::AccessToken, request, response},
events::room::{
canonical_alias::PossiblyRedactedRoomCanonicalAliasEventContent,
history_visibility::PossiblyRedactedRoomHistoryVisibilityEventContent,
join_rules::PossiblyRedactedRoomJoinRulesEventContent,
name::PossiblyRedactedRoomNameEventContent,
topic::PossiblyRedactedRoomTopicEventContent,
},
metadata,
serde::{default_true, is_default},
};
metadata! {
method: GET,
rate_limited: false,
authentication: AccessToken,
history: {
1.0 => "/_continuwuity/admin/v1/rooms",
}
}
#[request]
#[derive(Default)]
pub struct Request {
/// The maximum number of results to return in this page. Maximum (and
/// default) is 100.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "is_default")]
pub limit: Option<usize>,
/// The number of results to skip over before returning results. Default
/// is 0.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "is_default")]
pub offset: Option<usize>,
/// If true, includes banned rooms in the response.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "is_default")]
pub include_banned_rooms: bool,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct MinimalRoomInfo {
/// The room's unique ID.
pub room_id: OwnedRoomId,
/// If true, this room is banned, and cannot be joined by non-admins.
#[serde(default, skip_serializing_if = "is_default")]
pub banned: bool,
/// If true, this room has federation disabled, but can still be locally
/// used.
#[serde(default, skip_serializing_if = "is_default")]
pub disabled: bool,
/// The total number of joined members in this room.
#[serde(default, skip_serializing_if = "is_default")]
pub member_count: usize,
/// The total number of joined members in this room that are local to
/// this server.
#[serde(default, skip_serializing_if = "is_default")]
pub local_member_count: usize,
/// The number of unique homeservers currently joined to this room.
#[serde(default, skip_serializing_if = "is_default")]
pub resident_server_count: usize,
/// The users who created this room.
///
/// The first entry is always the sender of the `m.room.create` event.
/// Any entries thereafter are additional creators in v12+ rooms. An
/// empty vec indicates the room is not known.
#[serde(default, skip_serializing_if = "is_default")]
pub creators: Vec<OwnedUserId>,
/// If true, this room has encryption enabled.
#[serde(default, skip_serializing_if = "is_default")]
pub encrypted: bool,
/// If true, this room is allowed to be federated (`m.federate` is not
/// `false` in `m.room.create`).
#[serde(default = "default_true", skip_serializing_if = "is_default")]
pub federated: bool,
/// If true, this room is published to this server's room directory.
#[serde(default, skip_serializing_if = "is_default")]
pub published: bool,
/// The version of the room.
pub version: RoomVersionId,
/// The event content for the `m.room.name` event, if any is present.
/// May be redacted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<PossiblyRedactedRoomNameEventContent>,
/// The event content for the `m.room.topic` event, if any is present.
/// May be redacted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub topic: Option<PossiblyRedactedRoomTopicEventContent>,
/// The event content for the `m.room.canonical_alias` event, if any is
/// present. May be redacted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canonical_alias: Option<PossiblyRedactedRoomCanonicalAliasEventContent>,
/// The event content for the `m.room.join_rules` event, if any is
/// present. May be redacted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub join_rules: Option<PossiblyRedactedRoomJoinRulesEventContent>,
/// The event content for the `m.room.history_visibility` event, if any
/// is present. May be redacted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub history_visibility: Option<PossiblyRedactedRoomHistoryVisibilityEventContent>,
/// The ID of the room which replaces this one, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub successor: Option<OwnedRoomId>,
/// The ID of the room which preceded this one, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub predecessor: Option<OwnedRoomId>,
}
#[response]
pub struct Response {
/// A list of rooms known to this server.
pub rooms: Vec<MinimalRoomInfo>,
}
impl Request {
#[must_use]
pub fn new() -> Self { Self::default() }
}
impl Response {
#[must_use]
pub fn new(rooms: Vec<MinimalRoomInfo>) -> Self { Self { rooms } }
}
}
@@ -1,105 +0,0 @@
pub mod v1 {
use ruma::{
OwnedMxcUri, OwnedRoomOrAliasId, OwnedUserId,
api::{OAuthScope, auth_scheme::AccessToken, request, response},
metadata,
};
metadata! {
method: POST,
rate_limited: false,
authentication: AccessToken,
required_scopes: [OAuthScope::ServerAdministration],
history: {
1.0 => "/_continuwuity/admin/v1/users/create",
},
}
#[request]
pub struct Request {
/// The user's localpart (the identifier between `@` and `:`). Cannot be
/// blank.
pub localpart: String,
/// The user's desired password. Cannot be blank.
pub password: String,
/// The user's email address, if any.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub email: Option<String>,
/// The display name to set upon creation.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub display_name: Option<String>,
/// The avatar URI to set upon creation.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub avatar_url: Option<OwnedMxcUri>,
/// Suspends the user immediately upon creation. They can still log in.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub suspended: bool,
/// Locks the user immediately upon creation. They will receive
/// M_USER_LOCKED upon login.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub locked: bool,
/// Disables the user's login immediately upon creation.
///
/// The user can still be used if an admin generates an access token for
/// the account, but the user will not be able to use `POST
/// /_matrix/client/v3/login`.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub login_disabled: bool,
/// Promotes the user to a server administrator immediately upon
/// creation.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub admin: bool,
/// Skips joining rooms in the server's configured auto_join_rooms.
///
/// If this is false, all rooms in the config.toml's `auto_join_rooms`
/// will be automatically joined upon creation. If `auto_join_rooms`
/// is supplied in this request too, those rooms will be joined
/// afterwards.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub skip_auto_join: bool,
/// Additional rooms to auto-join the new user to. If `skip_auto_join`
/// is `true`, these rooms will still be joined.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub auto_join_rooms: Vec<OwnedRoomOrAliasId>,
}
#[response]
pub struct Response {
/// The fully qualified user ID of the newly created user.
pub user_id: OwnedUserId,
}
impl Request {
#[must_use]
pub fn new(localpart: String, password: String) -> Self {
Self {
localpart,
password,
email: None,
display_name: None,
avatar_url: None,
suspended: false,
locked: false,
login_disabled: false,
admin: false,
skip_auto_join: false,
auto_join_rooms: Vec::new(),
}
}
}
impl Response {
#[must_use]
pub fn new(user_id: OwnedUserId) -> Self { Self { user_id } }
}
}
@@ -1,139 +0,0 @@
pub mod v1 {
use ruma::{
OwnedUserId,
api::{OAuthScope, auth_scheme::AccessToken, request, response},
metadata,
};
use serde::Deserialize;
metadata! {
method: GET,
rate_limited: false,
authentication: AccessToken,
required_scopes: [OAuthScope::ServerAdministration],
history: {
1.0 => "/_continuwuity/admin/v1/users",
}
}
#[request]
#[derive(Default)]
pub struct Request {
/// If true, includes deactivated users in the response.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub include_deactivated: bool,
/// If true, includes locked users in the response.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub include_locked: bool,
/// If true, includes suspended users in the response.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub include_suspended: bool,
/// The maximum number of results to return in this page. Maximum (and
/// default) is 100.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub limit: Option<usize>,
/// The number of results to skip over before returning results. Default
/// is 0.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub offset: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, serde::Serialize)]
pub struct User {
/// The full user ID of the user.
pub user_id: OwnedUserId,
/// Whether this user is deactivated.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub deactivated: bool,
/// Whether this user is suspended.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub suspended: bool,
/// Whether this user is locked.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub locked: bool,
/// Whether this user is an admin.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub admin: bool,
/// Whether this user has their login disabled.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub login_disabled: bool,
}
impl User {
#[must_use]
pub fn new(user_id: OwnedUserId) -> Self {
Self {
user_id,
deactivated: false,
suspended: false,
locked: false,
admin: false,
login_disabled: false,
}
}
}
#[response]
#[derive(Default)]
pub struct Response {
pub users: Vec<User>,
}
impl Request {
#[must_use]
pub fn new() -> Self { Self::default() }
}
impl Response {
#[must_use]
pub fn new(users: Vec<User>) -> Self { Self { users } }
}
#[cfg(test)]
mod tests {
use assign::assign;
use serde_json::json;
use super::*;
#[test]
fn request_defaults() {
let req = Request::new();
assert!(!req.include_deactivated && !req.include_locked && !req.include_suspended);
}
#[test]
fn user_serialize_omits_default_values() {
let user_id = OwnedUserId::try_from("@alice:example.org".to_owned()).unwrap();
let user = User::new(user_id.clone());
let expected = json!({ "user_id": user_id.to_string() });
assert_eq!(serde_json::to_value(&user).expect("failed to serialize user"), expected);
let suspended_user = assign!(user, {suspended: true});
let expected2 = json!({ "user_id": "@alice:example.org", "suspended": true});
assert_eq!(
serde_json::to_value(&suspended_user).expect("failed to serialize user"),
expected2
);
}
#[test]
fn response_defaults() {
let response = Response::default();
assert!(response.users.is_empty());
}
}
}
@@ -1,2 +0,0 @@
pub mod create;
pub mod list;
+53
View File
@@ -0,0 +1,53 @@
//! `GET /_matrix/client/v1/admin/suspend/{userId}`
//!
//! Check the suspension status of a target user
pub mod v1 {
//! `/_matrix/client/unstable/uk.timedout.msc4323/admin/suspend/{userID}`
//! ([msc])
//!
//! [msc]: https://github.com/matrix-org/matrix-spec-proposals/pull/4323
use ruma::{
OwnedUserId,
api::{auth_scheme::AccessToken, request, response},
metadata,
};
metadata! {
method: GET,
rate_limited: false,
authentication: AccessToken,
history: {
unstable => "/_matrix/client/unstable/uk.timedout.msc4323/admin/suspend/{user_id}",
1.18 => "/_matrix/client/v1/admin/suspend/{user_id}",
}
}
/// Request type for the get & set user suspension status endpoint.
#[request(error = ruma::api::error::Error)]
pub struct Request {
/// The user to look up.
#[ruma_api(path)]
pub user_id: OwnedUserId,
}
/// Response type for the suspension endpoints
#[response(error = ruma::api::error::Error)]
pub struct Response {
/// Whether the user is currently suspended.
pub suspended: bool,
}
impl Request {
/// Creates a new `Request` with the given user id.
#[must_use]
pub fn new(user_id: OwnedUserId) -> Self { Self { user_id } }
}
impl Response {
/// Creates a new `Response` with the given suspension status.
#[must_use]
pub fn new(suspended: bool) -> Self { Self { suspended } }
}
}
+2
View File
@@ -1 +1,3 @@
pub mod continuwuity;
pub mod get_suspended;
pub mod set_suspended;
+55
View File
@@ -0,0 +1,55 @@
//! `PUT /_matrix/client/v1/admin/suspend/{userId}`
//!
//! Set the suspension status of a target user
pub mod v1 {
//! `/_matrix/client/unstable/uk.timedout.msc4323/admin/suspend/{userID}`
//! ([msc])
//!
//! [msc]: https://github.com/matrix-org/matrix-spec-proposals/pull/4323
use ruma::{
OwnedUserId,
api::{auth_scheme::AccessToken, request, response},
metadata,
};
metadata! {
method: PUT,
rate_limited: false,
authentication: AccessToken,
history: {
unstable => "/_matrix/client/unstable/uk.timedout.msc4323/admin/suspend/{user_id}",
1.18 => "/_matrix/client/v1/admin/suspend/{user_id}",
}
}
/// Request type for the set user suspension status endpoint.
#[request(error = ruma::api::error::Error)]
pub struct Request {
/// The user to look up.
#[ruma_api(path)]
pub user_id: OwnedUserId,
pub suspended: bool,
}
/// Response type for the suspension endpoints
#[response(error = ruma::api::error::Error)]
pub struct Response {
/// Whether the user is currently suspended.
pub suspended: bool,
}
impl Request {
/// Creates a new `Request` with the given user id.
#[must_use]
pub fn new(user_id: OwnedUserId, suspended: bool) -> Self { Self { user_id, suspended } }
}
impl Response {
/// Creates a new `Response` with the given suspension status.
#[must_use]
pub fn new(suspended: bool) -> Self { Self { suspended } }
}
}
-1
View File
@@ -119,7 +119,6 @@ recaptcha-verify = { version = "0.2.0", default-features = false }
reqwest_recaptcha = { package = "reqwest", version = "0.12.28", default-features = false, features = ["rustls-tls-native-roots-no-provider"] } # As long as recaptcha-verify's reqwest is outdated
yansi.workspace = true
lettre.workspace = true
serde_urlencoded.workspace = true
[target.'cfg(all(unix, target_os = "linux"))'.dependencies]
sd-notify.workspace = true
+8 -56
View File
@@ -18,8 +18,6 @@
use loole::{Receiver, Sender};
use ruma::{
OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedUserId, RoomId, UInt, UserId,
api::client::discovery::discover_support::{Contact, ContactRole},
assign,
events::{
Mentions,
room::message::{
@@ -30,7 +28,7 @@
use tokio::sync::RwLock;
use crate::{
Dep, account_data, config, globals,
Dep, account_data, globals,
media::{MXC_LENGTH, mxc::Mxc},
rooms::{self, state::RoomMutexGuard},
};
@@ -46,7 +44,6 @@ pub struct Service {
struct Services {
server: Arc<Server>,
config: Dep<config::Service>,
globals: Dep<globals::Service>,
alias: Dep<rooms::alias::Service>,
timeline: Dep<rooms::timeline::Service>,
@@ -118,7 +115,6 @@ fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
Ok(Arc::new(Self {
services: Services {
server: args.server.clone(),
config: args.depend::<config::Service>("config"),
globals: args.depend::<globals::Service>("globals"),
alias: args.depend::<rooms::alias::Service>("rooms::alias"),
timeline: args.depend::<rooms::timeline::Service>("rooms::timeline"),
@@ -553,6 +549,8 @@ pub async fn is_admin_command<E>(
return None;
}
// Trim leading spaces from commands
let trimmed_body: &str = body.trim_start();
if let Some(room_id) = event.room_id()
&& self.is_admin_room(room_id).await
{
@@ -560,7 +558,9 @@ pub async fn is_admin_command<E>(
// Ignore messages which aren't admin commands
let server_user = &self.services.globals.server_user;
if !(body.starts_with("!admin") || body.starts_with(server_user.as_str())) {
if !(trimmed_body.starts_with("!admin")
|| trimmed_body.starts_with(server_user.as_str()))
{
return None;
}
@@ -576,8 +576,8 @@ pub async fn is_admin_command<E>(
// This is a message outside the admin room
// Is it an escaped admin command? i.e. `\!admin --help`
let is_public_escape =
body.starts_with('\\') && body.trim_start_matches('\\').starts_with("!admin");
let is_public_escape = trimmed_body.starts_with('\\')
&& trimmed_body.trim_start_matches('\\').starts_with("!admin");
// Ignore the message if it's not
if !is_public_escape {
@@ -623,52 +623,4 @@ pub(super) fn set_services(&self, services: Option<&Arc<crate::Services>>) {
let weak = services.map(Arc::downgrade);
*receiver = weak;
}
/// Get the server's configured support contacts.
pub async fn get_support_contacts(&self) -> Vec<Contact> {
let email_address = self.services.config.well_known.support_email.clone();
let matrix_id = self.services.config.well_known.support_mxid.clone();
let pgp_key = self.services.config.well_known.support_pgp_key.clone();
// TODO: support defining multiple contacts in the config
let mut contacts: Vec<Contact> = vec![];
let role = self
.services
.config
.well_known
.support_role
.clone()
.unwrap_or(ContactRole::Admin);
// Add configured contact if at least one contact method is specified
let configured_contact = match (matrix_id, email_address) {
| (Some(matrix_id), email_address) =>
Some(assign!(Contact::with_matrix_id(role, matrix_id), { email_address })),
| (None, Some(email_address)) =>
Some(Contact::with_email_address(role, email_address)),
| (None, None) => None,
};
if let Some(mut configured_contact) = configured_contact {
configured_contact.pgp_key = pgp_key;
contacts.push(configured_contact);
}
// Try to add admin users as contacts if no contacts are configured
if contacts.is_empty() {
let admin_users = self.get_admins().await;
for user_id in &admin_users {
if *user_id == self.services.globals.server_user {
continue;
}
contacts.push(Contact::with_matrix_id(ContactRole::Admin, user_id.to_owned()));
}
}
contacts
}
}
+2 -2
View File
@@ -67,7 +67,7 @@ async fn worker(self: Arc<Self>) -> Result {
for (id, registration) in appservices {
// During startup, resolve any token collisions in favour of appservices
// by logging out conflicting user devices
if let Some((user_id, device_id, _)) = self
if let Ok((user_id, device_id)) = self
.services
.users
.find_from_token(&registration.as_token)
@@ -158,7 +158,7 @@ pub async fn register_appservice(
.users
.find_from_token(&registration.as_token)
.await
.is_some()
.is_ok()
{
return Err(err!(Request(InvalidParam(
"Cannot register appservice: The provided token is already in use by a user \
+2 -2
View File
@@ -39,7 +39,7 @@ fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
let url_preview_user_agent = config
.url_preview_user_agent
.clone()
.unwrap_or_else(|| conduwuit::user_agent_media().to_owned());
.unwrap_or_else(|| conduwuit::version::user_agent_media().to_owned());
Ok(Arc::new(Self {
default: base(config)?
@@ -149,7 +149,7 @@ fn base(config: &Config) -> Result<reqwest::ClientBuilder> {
.timeout(Duration::from_secs(config.request_total_timeout))
.pool_idle_timeout(Duration::from_secs(config.request_idle_timeout))
.pool_max_idle_per_host(config.request_idle_per_host.into())
.user_agent(conduwuit::user_agent())
.user_agent(conduwuit::version::user_agent())
.redirect(redirect::Policy::limited(6))
.danger_accept_invalid_certs(config.allow_invalid_tls_certificates_yes_i_know_what_the_fuck_i_am_doing_with_this_and_i_know_this_is_insecure)
.connection_verbose(cfg!(debug_assertions));
+7 -12
View File
@@ -6,7 +6,7 @@
use askama::Template;
use async_trait::async_trait;
use conduwuit::{Result, info, utils::ReadyExt};
use futures::StreamExt;
use futures::{FutureExt, StreamExt};
use ruma::{UserId, events::room::message::RoomMessageEventContent};
use crate::{
@@ -120,7 +120,7 @@ fn disable_first_run(&self) -> bool {
///
/// Returns Ok(true) if the specified user was the first user, and Ok(false)
/// if they were not.
pub async fn empower_first_user(&self, user: &UserId) -> bool {
pub async fn empower_first_user(&self, user: &UserId) -> Result<bool> {
#[derive(Template)]
#[template(path = "welcome.md")]
struct WelcomeMessage<'a> {
@@ -130,14 +130,10 @@ struct WelcomeMessage<'a> {
// If first run mode isn't active, do nothing.
if !self.disable_first_run() {
return false;
return Ok(false);
}
self.services
.admin
.make_user_admin(user)
.await
.expect("should have been able to empower the first user");
self.services.admin.make_user_admin(user).boxed().await?;
// Send the welcome message
let welcome_message = WelcomeMessage {
@@ -150,12 +146,11 @@ struct WelcomeMessage<'a> {
self.services
.admin
.send_loud_message(RoomMessageEventContent::text_markdown(welcome_message))
.await
.expect("should have been able to send welcome message");
.await?;
info!("{user} has been invited to the admin room as the first user.");
true
Ok(true)
}
/// Get the single-use registration token which may be used to create the
@@ -186,7 +181,7 @@ pub fn print_first_run_banner(&self) {
eprintln!(
"Welcome to {} {}!",
"Continuwuity".bold().bright_magenta(),
conduwuit::version().bold()
conduwuit::version::version().bold()
);
eprintln!();
eprintln!(
+2 -4
View File
@@ -92,8 +92,8 @@ pub async fn send<Template: MessageTemplate>(
let message = MessageBuilder::new()
.from(self.sender.clone())
.to(recipient.clone())
.subject(subject.clone())
.to(recipient)
.subject(subject)
.date_now()
.header(ContentType::TEXT_PLAIN)
.body(body)
@@ -104,8 +104,6 @@ pub async fn send<Template: MessageTemplate>(
.await
.map_err(|err: TransportError| err!("Failed to send message: {err}"))?;
info!(recipient = recipient.to_string(), ?subject, "Email sent");
Ok(())
}
}
+1 -1
View File
@@ -27,7 +27,7 @@
pub mod mailer;
pub mod media;
pub mod moderation;
pub mod oauth;
pub mod password_reset;
pub mod presence;
pub mod pusher;
pub mod registration_tokens;
-196
View File
@@ -1,196 +0,0 @@
use std::{collections::BTreeSet, hash::Hash};
use itertools::Itertools;
use serde::{Deserialize, Deserializer, Serialize};
use url::Url;
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct ClientMetadata {
#[serde(default)]
pub application_type: ApplicationType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_name: Option<String>,
pub client_uri: Url,
#[serde(default, deserialize_with = "btreeset_skip_err")]
pub grant_types: BTreeSet<GrantType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logo_uri: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy_uri: Option<Url>,
#[serde(default)]
pub redirect_uris: Vec<Url>,
#[serde(default, deserialize_with = "btreeset_skip_err")]
pub response_types: BTreeSet<ResponseType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_endpoint_auth_method: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tos_uri: Option<Url>,
}
impl ClientMetadata {
pub(super) const ACCEPTABLE_LOCALHOSTS: [&str; 3] = ["localhost", "127.0.0.1", "[::1]"];
pub(super) fn validate(&self) -> Result<(), &'static str> {
let Some(client_domain) = self.client_uri.domain() else {
return Err("Client URI must have a domain.");
};
if self.client_uri.scheme() != "https" {
return Err("Client URI must be HTTPS.");
}
if !self.client_uri.username().is_empty() || self.client_uri.password().is_some() {
return Err("Client URI must not include credentials.");
}
for uri in [&self.logo_uri, &self.policy_uri, &self.tos_uri]
.iter()
.filter_map(|uri| uri.as_ref())
{
if uri.scheme() != "https" {
return Err("All metadata URIs must be HTTPS.");
}
if !uri.username().is_empty() || uri.password().is_some() {
return Err("All metadata URIs must not include credentials.");
}
if !uri
.domain()
.is_some_and(|domain| is_subdomain(domain, client_domain))
{
return Err("All metadata URIs must be subdomains of the client URI.");
}
}
for uri in &self.redirect_uris {
match uri.scheme() {
| "https" => {
// HTTPS URIs are okay for native and web clients
if !uri.username().is_empty() || uri.password().is_some() {
return Err("HTTPS redirect URIs must not contain credentials.");
}
},
| "http" if self.application_type == ApplicationType::Native => {
if uri
.host_str()
.is_none_or(|host| !Self::ACCEPTABLE_LOCALHOSTS.contains(&host))
{
return Err("HTTP redirect URIs for native applications must only \
refer to localhost.");
}
if uri.port().is_some() {
return Err("HTTP redirect URIs for native applications do not need to \
specify a port. All ports will be accepted during \
authorization.");
}
},
| private_scheme if self.application_type == ApplicationType::Native => {
let rdns_client_uri = client_domain.split('.').rev().join(".");
if !private_scheme.starts_with(&rdns_client_uri) {
return Err("Private-use scheme URIs for native applications must \
begin with the application's client URI domain in \
reverse-DNS notation.");
}
if uri.has_authority() {
return Err("Private-use scheme URIs for native applications must not \
have an authority.");
}
},
| _ =>
return Err("A redirect URI's scheme is not valid for this application type."),
}
}
Ok(())
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ApplicationType {
#[default]
Web,
Native,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GrantType {
AuthorizationCode,
RefreshToken,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ResponseType {
Code,
}
/// Deserialize a BTreeSet from a sequence, skipping items which fail to
/// deserialize. This is used as a deserialize helper for ClientMetadata to
/// ignore unknown enum variants in a few fields.
fn btreeset_skip_err<'de, D, V>(de: D) -> Result<BTreeSet<V>, D::Error>
where
D: Deserializer<'de>,
V: Deserialize<'de> + Hash + Eq + Ord,
{
use std::marker::PhantomData;
use serde::de::{SeqAccess, Visitor};
struct BTreeSetVisitor<V> {
item: PhantomData<V>,
}
impl<'de, V> Visitor<'de> for BTreeSetVisitor<V>
where
V: Deserialize<'de> + Hash + Eq + Ord,
{
type Value = BTreeSet<V>;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "a sequence")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
let mut set = BTreeSet::new();
while let Some(element) = seq.next_element().transpose() {
if let Ok(element) = element {
set.insert(element);
}
}
Ok(set)
}
}
de.deserialize_seq(BTreeSetVisitor { item: PhantomData })
}
fn is_subdomain(subdomain: &str, domain: &str) -> bool {
if subdomain == domain {
return true;
}
subdomain.ends_with(&format!(".{domain}"))
}
-231
View File
@@ -1,231 +0,0 @@
use std::{
borrow::Cow,
collections::BTreeSet,
error::Error,
fmt::{Debug, Display},
hash::Hash,
mem::discriminant,
};
use regex::Regex;
use ruma::{OwnedDeviceId, api::OAuthScope};
use serde::{Deserialize, Serialize};
use url::Url;
use super::client_metadata::ResponseType;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AuthorizationCodeQuery {
pub response_type: ResponseType,
pub client_id: String,
pub redirect_uri: Url,
pub scope: RawScopes,
pub state: String,
#[serde(default)]
pub response_mode: ResponseMode,
pub code_challenge: String,
pub code_challenge_method: CodeChallengeMethod,
#[serde(default)]
pub prompt: Option<Prompt>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ResponseMode {
#[default]
// default for `code` response type, see https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#:~:text=Client%2E-,For,encoding%2E,-See
Query,
Fragment,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[non_exhaustive]
pub enum CodeChallengeMethod {
S256,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Prompt {
Create,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialOrd, Ord)]
pub enum RequestedScope {
Device(OwnedDeviceId),
FullAccess,
ServerAdministration,
}
impl RequestedScope {
pub fn as_granted_scope(&self) -> Option<OAuthScope> {
match self {
| Self::FullAccess => Some(OAuthScope::FullAccess),
| Self::ServerAdministration => Some(OAuthScope::ServerAdministration),
| Self::Device(_) => None,
}
}
}
impl PartialEq for RequestedScope {
fn eq(&self, other: &Self) -> bool { discriminant(self) == discriminant(other) }
}
impl Eq for RequestedScope {}
impl Hash for RequestedScope {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) { discriminant(self).hash(state); }
}
impl Display for RequestedScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let urn = match self {
| Self::FullAccess => "urn:matrix:client:api:*".to_owned(),
| Self::Device(device_id) => format!("urn:matrix:client:device:{device_id}"),
| Self::ServerAdministration =>
"urn:matrix:client:cc.c10y.msc4484.server_administration".to_owned(),
};
f.write_str(&urn)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RawScopes(String);
impl RawScopes {
pub fn to_scopes(&self) -> Result<BTreeSet<RequestedScope>, String> {
let full_access_regex =
Regex::new(r"urn:matrix:(client|org.matrix.msc2967.client):api:\*").unwrap();
let device_token_regex = Regex::new(
r"urn:matrix:(client|org.matrix.msc2967.client):device:([a-zA-Z0-9-._~]{5,})",
)
.unwrap();
let server_administration_regex =
Regex::new(r"urn:matrix:client:cc.c10y.msc4484.server_administration").unwrap();
let mut scopes = BTreeSet::new();
for token in self.0.split(' ') {
let scope_was_new = {
if full_access_regex.is_match(token) {
scopes.insert(RequestedScope::FullAccess)
} else if let Some(captures) = device_token_regex.captures(token) {
scopes
.insert(RequestedScope::Device(captures.get(2).unwrap().as_str().into()))
} else if server_administration_regex.is_match(token) {
scopes.insert(RequestedScope::ServerAdministration)
} else if token == "openid" {
// TODO(unspecced): Element sets this scope but doesn't use it for anything
true
} else {
return Err(format!("Invalid scope: {token}"));
}
};
if !scope_was_new {
return Err("Scope was specified more than once".to_owned());
}
}
Ok(scopes)
}
}
#[derive(Serialize, Debug, Clone)]
pub struct OAuthError {
pub error: ErrorCode,
pub error_description: Cow<'static, str>,
}
impl OAuthError {
pub const fn invalid_request(error_description: &'static str) -> Self {
Self {
error: ErrorCode::InvalidRequest,
error_description: Cow::Borrowed(error_description),
}
}
pub const fn invalid_grant(error_description: &'static str) -> Self {
Self {
error: ErrorCode::InvalidGrant,
error_description: Cow::Borrowed(error_description),
}
}
}
impl Display for OAuthError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "OAuth error {:?}: {}", self.error, self.error_description)
}
}
impl Error for OAuthError {}
#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
InvalidRequest,
AccessDenied,
InvalidScope,
InvalidGrant,
InvalidClientMetadata,
}
#[derive(Serialize)]
#[serde(untagged)]
pub enum AuthorizationCodeResponse {
Success {
state: String,
code: String,
},
Error(OAuthError),
}
#[derive(Deserialize)]
#[serde(tag = "grant_type", rename_all = "snake_case")]
pub enum TokenRequest {
AuthorizationCode {
code: String,
redirect_uri: Url,
client_id: String,
code_verifier: String,
},
RefreshToken {
client_id: String,
refresh_token: String,
},
}
impl TokenRequest {
#[must_use]
pub fn client_id(&self) -> &str {
match self {
| Self::AuthorizationCode { client_id, .. }
| Self::RefreshToken { client_id, .. } => client_id,
}
}
}
#[derive(Serialize)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: TokenType,
pub expires_in: u64,
pub refresh_token: String,
pub scope: String,
}
#[derive(Serialize)]
pub enum TokenType {
Bearer,
}
#[derive(Deserialize)]
pub struct RevokeTokenRequest {
pub token: String,
}
-534
View File
@@ -1,534 +0,0 @@
use std::{
collections::{BTreeSet, HashMap},
sync::{Arc, Mutex},
time::{Duration, SystemTime},
};
use base64::Engine;
use conduwuit::{
Result, info,
utils::{self, hash::sha256},
};
use database::{Deserialized, Json, Map};
use itertools::Itertools;
use ruma::{DeviceId, OwnedDeviceId, OwnedUserId, UserId, api::OAuthScope};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::{
Dep,
oauth::{
client_metadata::{ApplicationType, ClientMetadata, ResponseType},
grant::{
AuthorizationCodeQuery, AuthorizationCodeResponse, CodeChallengeMethod, ErrorCode,
OAuthError, RequestedScope, ResponseMode, TokenRequest, TokenResponse, TokenType,
},
},
users,
};
pub mod client_metadata;
pub mod grant;
pub struct Service {
services: Services,
db: Data,
tickets: Mutex<HashMap<String, HashMap<OAuthTicket, SystemTime>>>,
pending_code_grants: tokio::sync::Mutex<HashMap<String, PendingCodeGrant>>,
}
struct Data {
clientid_clientmetadata: Arc<Map>,
userdeviceid_oauthsessioninfo: Arc<Map>,
refreshtoken_refreshtokeninfo: Arc<Map>,
}
struct Services {
users: Dep<users::Service>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct SessionInfo {
pub client_id: String,
pub scopes: BTreeSet<OAuthScope>,
current_refresh_token: String,
}
#[derive(Debug, Deserialize, Serialize)]
struct RefreshTokenInfo {
client_id: String,
user_id: OwnedUserId,
device_id: OwnedDeviceId,
}
struct PendingCodeGrant {
authorizing_user: OwnedUserId,
requested_scopes: BTreeSet<RequestedScope>,
client_name: Option<String>,
expected_client_id: String,
expected_redirect_uri: Url,
code_challenge: String,
requested_at: SystemTime,
}
impl PendingCodeGrant {
const MAX_AGE: Duration = Duration::from_mins(1);
const RANDOM_CODE_LENGTH: usize = 32;
#[must_use]
pub(crate) fn generate_code() -> String { utils::random_string(Self::RANDOM_CODE_LENGTH) }
#[must_use]
pub(crate) fn is_valid_for(&self, client_id: &str) -> bool {
let now = SystemTime::now();
self.expected_client_id == client_id
&& now
.duration_since(self.requested_at)
.is_ok_and(|age| age < Self::MAX_AGE)
}
}
/// A time-limited grant for a client to perform some sensitive action.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum OAuthTicket {
CrossSigningReset,
}
impl OAuthTicket {
const MAX_AGE: Duration = Duration::from_mins(10);
#[must_use]
pub fn ticket_issue_path(&self) -> &'static str {
match self {
| Self::CrossSigningReset => "/account/cross_signing_reset",
}
}
}
impl crate::Service for Service {
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
Ok(Arc::new(Self {
services: Services {
users: args.depend::<users::Service>("users"),
},
db: Data {
clientid_clientmetadata: args.db["clientid_clientmetadata"].clone(),
userdeviceid_oauthsessioninfo: args.db["userdeviceid_oauthsessioninfo"].clone(),
refreshtoken_refreshtokeninfo: args.db["refreshtoken_refreshtokeninfo"].clone(),
},
tickets: Mutex::default(),
pending_code_grants: tokio::sync::Mutex::default(),
}))
}
fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
}
impl Service {
const ACCESS_TOKEN_MAX_AGE: Duration = Duration::from_hours(1);
const RANDOM_TOKEN_LENGTH: usize = 32;
fn generate_token() -> String { utils::random_string(Self::RANDOM_TOKEN_LENGTH) }
pub async fn register_client(&self, metadata: &ClientMetadata) -> Result<String, OAuthError> {
metadata.validate().map_err(|error| OAuthError {
error: ErrorCode::InvalidClientMetadata,
error_description: error.into(),
})?;
let client_id = base64::prelude::BASE64_STANDARD
.encode(sha256::hash(serde_json::to_string(metadata).unwrap().as_bytes()));
if self
.db
.clientid_clientmetadata
.exists(&client_id)
.await
.is_err()
{
self.db
.clientid_clientmetadata
.raw_put(&client_id, Json(metadata.clone()));
}
Ok(client_id)
}
pub async fn get_client_metadata(&self, client_id: &str) -> Option<ClientMetadata> {
self.db
.clientid_clientmetadata
.get(client_id)
.await
.deserialized()
.ok()
}
pub async fn get_session_info_for_device(
&self,
user_id: &UserId,
device_id: &DeviceId,
) -> Option<SessionInfo> {
self.db
.userdeviceid_oauthsessioninfo
.qry(&(user_id, device_id))
.await
.deserialized::<SessionInfo>()
.ok()
}
pub async fn request_authorization_code(
&self,
authorizing_user: OwnedUserId,
query: AuthorizationCodeQuery,
) -> Result<String, String> {
let Some(client_metadata) = self.get_client_metadata(&query.client_id).await else {
return Err("Invalid client ID".to_owned());
};
if !(client_metadata
.response_types
.contains(&query.response_type)
&& matches!(query.response_type, ResponseType::Code))
{
return Err("Invalid response type".to_owned());
}
if !matches!(query.code_challenge_method, CodeChallengeMethod::S256) {
return Err("Invalid code challenge type".to_owned());
}
{
let mut stripped_uri = query.redirect_uri.clone();
if client_metadata.application_type == ApplicationType::Native
&& query
.redirect_uri
.host_str()
.is_some_and(|host| ClientMetadata::ACCEPTABLE_LOCALHOSTS.contains(&host))
{
// Remove the port from localhost redirect URIs for native applications when
// checking if it's valid
stripped_uri.set_port(None).unwrap();
}
if !client_metadata.redirect_uris.contains(&stripped_uri) {
return Err("Invalid redirect URI".to_owned());
}
}
let redirect_uri_query_separator = match query.response_mode {
| ResponseMode::Fragment => '#',
| ResponseMode::Query => '?',
};
let response = 'response: {
let requested_scopes = query.scope.to_scopes()?;
if requested_scopes.contains(&RequestedScope::ServerAdministration) {
// Only server admins can request this scope
if !self.services.users.is_admin(&authorizing_user).await {
break 'response AuthorizationCodeResponse::Error(OAuthError {
error: ErrorCode::AccessDenied,
error_description: "You are not a server administrator.".into(),
});
}
}
let code = PendingCodeGrant::generate_code();
info!(
client_id = &query.client_id,
client_name = &client_metadata.client_name,
?requested_scopes,
?authorizing_user,
"Issuing oauth authorization code"
);
let pending_grant = PendingCodeGrant {
authorizing_user,
requested_scopes,
client_name: client_metadata.client_name,
expected_client_id: query.client_id,
expected_redirect_uri: query.redirect_uri.clone(),
code_challenge: query.code_challenge,
requested_at: SystemTime::now(),
};
self.pending_code_grants
.lock()
.await
.insert(code.clone(), pending_grant);
AuthorizationCodeResponse::Success { state: query.state, code }
};
let redirect_uri = format!(
"{}{}{}",
query.redirect_uri,
redirect_uri_query_separator,
serde_urlencoded::to_string(response).unwrap(),
);
Ok(redirect_uri)
}
pub async fn issue_token(&self, request: TokenRequest) -> Result<TokenResponse, OAuthError> {
match request {
| TokenRequest::AuthorizationCode {
code,
redirect_uri,
client_id,
code_verifier,
} => {
let mut pending_grants = self.pending_code_grants.lock().await;
let Some(pending_grant) = pending_grants
.remove(&code)
.filter(|grant| grant.is_valid_for(&client_id))
else {
return Err(OAuthError::invalid_grant("Invalid authorization code"));
};
if redirect_uri != pending_grant.expected_redirect_uri {
return Err(OAuthError::invalid_grant("Invalid redirect URI"));
}
let expected_code_challenge =
base64::prelude::BASE64_URL_SAFE_NO_PAD.encode(sha256::hash(&code_verifier));
if expected_code_challenge != pending_grant.code_challenge {
return Err(OAuthError::invalid_grant("Invalid code challenge"));
}
self.create_session(
pending_grant.authorizing_user,
pending_grant.requested_scopes,
pending_grant.client_name,
client_id,
)
.await
},
| TokenRequest::RefreshToken { client_id, refresh_token } =>
self.refresh_session(client_id, refresh_token).await,
}
}
pub async fn revoke_token(&self, token: String) -> Result<(), OAuthError> {
let (user_id, device_id) = if let Ok(refresh_token_info) = self
.db
.refreshtoken_refreshtokeninfo
.get(&token)
.await
.deserialized::<RefreshTokenInfo>()
{
(refresh_token_info.user_id, refresh_token_info.device_id)
} else if let Some((user_id, device_id, _)) =
self.services.users.find_from_token(&token).await
{
(user_id, device_id)
} else {
return Err(OAuthError::invalid_grant("Invalid access or refersh token"));
};
// This will also call [`Self::remove_session`]
self.services
.users
.remove_device(&user_id, &device_id)
.await;
Ok(())
}
async fn create_session(
&self,
authorizing_user: OwnedUserId,
requested_scopes: BTreeSet<RequestedScope>,
client_name: Option<String>,
client_id: String,
) -> Result<TokenResponse, OAuthError> {
let access_token = Self::generate_token();
let refresh_token = Self::generate_token();
let device_id = requested_scopes
.iter()
.find_map(|scope| {
if let RequestedScope::Device(device_id) = scope {
Some(device_id)
} else {
None
}
})
.ok_or_else(|| OAuthError::invalid_grant("No device ID scope supplied"))?;
if self
.services
.users
.get_device_metadata(&authorizing_user, device_id)
.await
.is_ok()
{
return Err(OAuthError {
error: ErrorCode::InvalidScope,
error_description: "A device with the supplied ID already exists for this user"
.into(),
});
}
self.services
.users
.create_device(
&authorizing_user,
device_id,
&access_token,
Some(Self::ACCESS_TOKEN_MAX_AGE),
client_name,
None,
)
.await
// This can only panic if the authorizing user suffered a spontaneous existence
// failure during authentication, which should(?) be impossible(?)
.expect("failed to create device");
self.db.userdeviceid_oauthsessioninfo.put(
(&authorizing_user, device_id),
Json(SessionInfo {
client_id: client_id.clone(),
current_refresh_token: refresh_token.clone(),
scopes: requested_scopes
.iter()
.filter_map(RequestedScope::as_granted_scope)
.collect(),
}),
);
self.db.refreshtoken_refreshtokeninfo.raw_put(
&refresh_token,
Json(RefreshTokenInfo {
client_id: client_id.clone(),
user_id: authorizing_user.clone(),
device_id: device_id.to_owned(),
}),
);
info!(
?client_id,
?authorizing_user,
?device_id,
?requested_scopes,
"Created new oauth session"
);
Ok(TokenResponse {
access_token,
token_type: TokenType::Bearer,
expires_in: Self::ACCESS_TOKEN_MAX_AGE.as_secs(),
scope: requested_scopes.iter().join(" "),
refresh_token,
})
}
async fn refresh_session(
&self,
client_id: String,
refresh_token: String,
) -> Result<TokenResponse, OAuthError> {
let Some(refresh_token_info) = self
.db
.refreshtoken_refreshtokeninfo
.get(&refresh_token)
.await
.deserialized::<RefreshTokenInfo>()
.ok()
else {
return Err(OAuthError::invalid_grant("Invalid refresh token"));
};
assert_eq!(&client_id, &refresh_token_info.client_id, "refresh token client id mismatch");
let mut session_info = self
.get_session_info_for_device(
&refresh_token_info.user_id,
&refresh_token_info.device_id,
)
.await
.expect("session info should exist");
assert_eq!(&client_id, &session_info.client_id, "session info client id mismatch");
let new_access_token = Self::generate_token();
let new_refresh_token = Self::generate_token();
let scope = session_info.scopes.iter().join(" ");
session_info
.current_refresh_token
.clone_from(&new_refresh_token);
self.services
.users
.set_token(
&refresh_token_info.user_id,
&refresh_token_info.device_id,
&new_access_token,
Some(Self::ACCESS_TOKEN_MAX_AGE),
)
.await
.expect("should be able to set token");
self.db.userdeviceid_oauthsessioninfo.put(
(&refresh_token_info.user_id, &refresh_token_info.device_id),
Json(session_info),
);
self.db.refreshtoken_refreshtokeninfo.remove(&refresh_token);
drop(refresh_token);
self.db
.refreshtoken_refreshtokeninfo
.raw_put(&new_refresh_token, Json(refresh_token_info));
Ok(TokenResponse {
access_token: new_access_token,
token_type: TokenType::Bearer,
expires_in: Self::ACCESS_TOKEN_MAX_AGE.as_secs(),
scope,
refresh_token: new_refresh_token,
})
}
pub async fn remove_session(&self, user_id: &UserId, device_id: &DeviceId) {
let session_info = self.get_session_info_for_device(user_id, device_id).await;
if let Some(session_info) = session_info {
self.db
.refreshtoken_refreshtokeninfo
.remove(&session_info.current_refresh_token);
self.db
.userdeviceid_oauthsessioninfo
.del((user_id, device_id));
info!(?user_id, ?device_id, "Removed OAuth session");
}
}
/// Issue a ticket for `localpart` to perform some action.
pub fn issue_ticket(&self, localpart: String, ticket: OAuthTicket) {
self.tickets
.lock()
.unwrap()
.entry(localpart)
.or_default()
.insert(ticket, SystemTime::now());
}
/// Try to consume an unexpired ticket for `localpart`.
pub fn try_consume_ticket(&self, localpart: &str, ticket: OAuthTicket) -> bool {
let now = SystemTime::now();
self.tickets
.lock()
.unwrap()
.get_mut(localpart)
.and_then(|tickets| tickets.remove(&ticket))
.is_some_and(|issued| {
now.duration_since(issued)
.is_ok_and(|duration| duration < OAuthTicket::MAX_AGE)
})
}
}

Some files were not shown because too many files have changed in this diff Show More