mirror of
https://forgejo.ellis.link/continuwuation/continuwuity/
synced 2026-07-10 06:31:48 +00:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96c069cd67 | |||
| 9a38395f0a | |||
| fdbb1c86b1 | |||
| 17f8ec21a3 | |||
| c46104ac0c | |||
| 900ba33a4e | |||
| f2e696ae3e | |||
| 1088aa5020 | |||
| 5c2a5de7d3 | |||
| 600b8cb366 | |||
| 3bbbfcdd46 | |||
| e9b387414f | |||
| 426b113c30 | |||
| ddae8af99f | |||
| 769db9b818 | |||
| 27d9d1d78d | |||
| 2e34ac9f59 | |||
| 95632103bc | |||
| d916bb9f21 | |||
| 4e50064740 | |||
| 56bd11013f | |||
| 8893bd1613 | |||
| e7b9446b5a | |||
| bc23071dd4 | |||
| b602be0921 | |||
| 0c333c9a05 | |||
| b9b3a466f4 | |||
| 3803f06392 | |||
| cadcbd7d49 | |||
| 144036f58b | |||
| 2afe656e12 | |||
| c89dfe38da | |||
| 1ce1254514 | |||
| 5bd1bedad0 | |||
| 68ca6eabe3 | |||
| e75f5cbbed | |||
| 97c692b052 | |||
| a586ea390c | |||
| dba528a5e0 | |||
| aafc93f6fb | |||
| cd0c3886fb | |||
| be4ccbc11b |
@@ -17,7 +17,7 @@ inputs:
|
||||
required: false
|
||||
default: ''
|
||||
rust-version:
|
||||
description: 'Rust version to install (e.g. nightly).'
|
||||
description: 'Rust version to install (e.g. nightly). Defaults to 1.87.0'
|
||||
required: false
|
||||
default: ''
|
||||
sccache-cache-limit:
|
||||
@@ -59,7 +59,23 @@ runs:
|
||||
mkdir -p "${{ github.workspace }}/target"
|
||||
mkdir -p "${{ github.workspace }}/.rustup"
|
||||
|
||||
- name: Start cache restore group
|
||||
shell: bash
|
||||
run: echo "::group::📦 Restoring caches (registry, toolchain, build artifacts)"
|
||||
|
||||
- name: Cache Cargo registry and git
|
||||
id: registry-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
.cargo/registry/index
|
||||
.cargo/registry/cache
|
||||
.cargo/git/db
|
||||
# Registry cache saved per workflow, restored from any workflow's cache
|
||||
# Each workflow maintains its own registry that accumulates its needed crates
|
||||
key: continuwuity-cargo-registry-${{ steps.runner-os.outputs.slug }}-${{ steps.runner-os.outputs.arch }}-${{ github.workflow }}
|
||||
restore-keys: |
|
||||
continuwuity-cargo-registry-${{ steps.runner-os.outputs.slug }}-${{ steps.runner-os.outputs.arch }}-
|
||||
|
||||
- name: Cache toolchain binaries
|
||||
id: toolchain-cache
|
||||
@@ -72,6 +88,45 @@ runs:
|
||||
# Shared toolchain cache across all Rust versions
|
||||
key: continuwuity-toolchain-${{ steps.runner-os.outputs.slug }}-${{ steps.runner-os.outputs.arch }}
|
||||
|
||||
|
||||
- name: Setup sccache
|
||||
uses: https://git.tomfos.tr/tom/sccache-action@v1
|
||||
|
||||
- name: Cache dependencies
|
||||
id: deps-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
target/**/.fingerprint
|
||||
target/**/deps
|
||||
target/**/*.d
|
||||
target/**/.cargo-lock
|
||||
target/**/CACHEDIR.TAG
|
||||
target/**/.rustc_info.json
|
||||
/timelord/
|
||||
# Dependencies cache - based on Cargo.lock, survives source code changes
|
||||
key: >-
|
||||
continuwuity-deps-${{ steps.runner-os.outputs.slug }}-${{ steps.runner-os.outputs.arch }}-${{ inputs.rust-version }}${{ inputs.cache-key-suffix && format('-{0}', inputs.cache-key-suffix) || '' }}-${{ hashFiles('rust-toolchain.toml', '**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
continuwuity-deps-${{ steps.runner-os.outputs.slug }}-${{ steps.runner-os.outputs.arch }}-${{ inputs.rust-version }}${{ inputs.cache-key-suffix && format('-{0}', inputs.cache-key-suffix) || '' }}-
|
||||
|
||||
- name: Cache incremental compilation
|
||||
id: incremental-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
target/**/incremental
|
||||
# Incremental cache - based on source code changes
|
||||
key: >-
|
||||
continuwuity-incremental-${{ steps.runner-os.outputs.slug }}-${{ steps.runner-os.outputs.arch }}-${{ inputs.rust-version }}${{ inputs.cache-key-suffix && format('-{0}', inputs.cache-key-suffix) || '' }}-${{ hashFiles('rust-toolchain.toml', '**/Cargo.lock') }}-${{ hashFiles('**/*.rs', '**/Cargo.toml') }}
|
||||
restore-keys: |
|
||||
continuwuity-incremental-${{ steps.runner-os.outputs.slug }}-${{ steps.runner-os.outputs.arch }}-${{ inputs.rust-version }}${{ inputs.cache-key-suffix && format('-{0}', inputs.cache-key-suffix) || '' }}-${{ hashFiles('rust-toolchain.toml', '**/Cargo.lock') }}-
|
||||
continuwuity-incremental-${{ steps.runner-os.outputs.slug }}-${{ steps.runner-os.outputs.arch }}-${{ inputs.rust-version }}${{ inputs.cache-key-suffix && format('-{0}', inputs.cache-key-suffix) || '' }}-
|
||||
|
||||
- name: End cache restore group
|
||||
shell: bash
|
||||
run: echo "::endgroup::"
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -103,71 +158,6 @@ runs:
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Output Rust version
|
||||
id: rust-setup
|
||||
shell: bash
|
||||
run: |
|
||||
RUST_VERSION=$(rustc --version | cut -d' ' -f2)
|
||||
echo "version=$RUST_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "📋 Rust version: $(rustc --version)"
|
||||
|
||||
- name: Setup sccache
|
||||
uses: https://git.tomfos.tr/tom/sccache-action@v1
|
||||
|
||||
|
||||
- name: Start cache restore group
|
||||
shell: bash
|
||||
run: echo "::group::📦 Restoring caches (registry, toolchain, build artifacts)"
|
||||
|
||||
- name: Cache Cargo registry and git
|
||||
id: registry-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
.cargo/registry/index
|
||||
.cargo/registry/cache
|
||||
.cargo/git/db
|
||||
# Registry cache saved per workflow, restored from any workflow's cache
|
||||
# Each workflow maintains its own registry that accumulates its needed crates
|
||||
key: continuwuity-cargo-registry-${{ steps.runner-os.outputs.slug }}-${{ steps.runner-os.outputs.arch }}-${{ github.workflow }}
|
||||
restore-keys: |
|
||||
continuwuity-cargo-registry-${{ steps.runner-os.outputs.slug }}-${{ steps.runner-os.outputs.arch }}-
|
||||
|
||||
- name: Cache dependencies
|
||||
id: deps-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
target/**/.fingerprint
|
||||
target/**/deps
|
||||
target/**/*.d
|
||||
target/**/.cargo-lock
|
||||
target/**/CACHEDIR.TAG
|
||||
target/**/.rustc_info.json
|
||||
/timelord/
|
||||
# Dependencies cache - based on Cargo.lock, survives source code changes
|
||||
key: >-
|
||||
continuwuity-deps-${{ steps.runner-os.outputs.slug }}-${{ steps.runner-os.outputs.arch }}-${{ steps.rust-setup.outputs.version }}${{ inputs.cache-key-suffix && format('-{0}', inputs.cache-key-suffix) || '' }}-${{ hashFiles('rust-toolchain.toml', '**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
continuwuity-deps-${{ steps.runner-os.outputs.slug }}-${{ steps.runner-os.outputs.arch }}-${{ steps.rust-setup.outputs.version }}${{ inputs.cache-key-suffix && format('-{0}', inputs.cache-key-suffix) || '' }}-
|
||||
|
||||
- name: Cache incremental compilation
|
||||
id: incremental-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
target/**/incremental
|
||||
# Incremental cache - based on source code changes
|
||||
key: >-
|
||||
continuwuity-incremental-${{ steps.runner-os.outputs.slug }}-${{ steps.runner-os.outputs.arch }}-${{ steps.rust-setup.outputs.version }}${{ inputs.cache-key-suffix && format('-{0}', inputs.cache-key-suffix) || '' }}-${{ hashFiles('rust-toolchain.toml', '**/Cargo.lock') }}-${{ hashFiles('**/*.rs', '**/Cargo.toml') }}
|
||||
restore-keys: |
|
||||
continuwuity-incremental-${{ steps.runner-os.outputs.slug }}-${{ steps.runner-os.outputs.arch }}-${{ steps.rust-setup.outputs.version }}${{ inputs.cache-key-suffix && format('-{0}', inputs.cache-key-suffix) || '' }}-${{ hashFiles('rust-toolchain.toml', '**/Cargo.lock') }}-
|
||||
continuwuity-incremental-${{ steps.runner-os.outputs.slug }}-${{ steps.runner-os.outputs.arch }}-${{ steps.rust-setup.outputs.version }}${{ inputs.cache-key-suffix && format('-{0}', inputs.cache-key-suffix) || '' }}-
|
||||
|
||||
- name: End cache restore group
|
||||
shell: bash
|
||||
run: echo "::endgroup::"
|
||||
|
||||
- name: Configure PATH and install tools
|
||||
shell: bash
|
||||
env:
|
||||
@@ -235,9 +225,13 @@ runs:
|
||||
echo "📦 Installing target: ${{ inputs.rust-target }}"
|
||||
rustup target add ${{ inputs.rust-target }}
|
||||
|
||||
- name: Output summary
|
||||
- name: Output version and summary
|
||||
id: rust-setup
|
||||
shell: bash
|
||||
run: |
|
||||
RUST_VERSION=$(rustc --version | cut -d' ' -f2)
|
||||
echo "version=$RUST_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "📋 Setup complete:"
|
||||
echo " Rust: $(rustc --version)"
|
||||
echo " Cargo: $(cargo --version)"
|
||||
|
||||
@@ -46,9 +46,6 @@ creds:
|
||||
- registry: ghcr.io
|
||||
user: "{{env \"GH_PACKAGES_USER\"}}"
|
||||
pass: "{{env \"GH_PACKAGES_TOKEN\"}}"
|
||||
- registry: docker.io
|
||||
user: "{{env \"DOCKER_MIRROR_USER\"}}"
|
||||
pass: "{{env \"DOCKER_MIRROR_TOKEN\"}}"
|
||||
|
||||
# Global defaults
|
||||
defaults:
|
||||
@@ -70,7 +67,3 @@ sync:
|
||||
target: ghcr.io/continuwuity/continuwuity
|
||||
type: repository
|
||||
<<: *tags-main
|
||||
- source: *source
|
||||
target: docker.io/jadedblueeyes/continuwuity
|
||||
type: repository
|
||||
<<: *tags-main
|
||||
|
||||
@@ -34,8 +34,6 @@ jobs:
|
||||
N7574_GIT_TOKEN: ${{ secrets.N7574_GIT_TOKEN }}
|
||||
GH_PACKAGES_USER: ${{ vars.GH_PACKAGES_USER }}
|
||||
GH_PACKAGES_TOKEN: ${{ secrets.GH_PACKAGES_TOKEN }}
|
||||
DOCKER_MIRROR_USER: ${{ vars.DOCKER_MIRROR_USER }}
|
||||
DOCKER_MIRROR_TOKEN: ${{ secrets.DOCKER_MIRROR_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
@@ -7,3 +7,6 @@ f419c64aca300a338096b4e0db4c73ace54f23d0
|
||||
5998a0d883d31b866f7c8c46433a8857eae51a89
|
||||
# trailing whitespace and newlines
|
||||
46c193e74b2ce86c48ce802333a0aabce37fd6e9
|
||||
|
||||
# Formatting PRs
|
||||
fd972f114293ea1be9633b750a703edd661e970d
|
||||
|
||||
Generated
+191
-15
@@ -50,6 +50,15 @@ dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.21"
|
||||
@@ -147,6 +156,12 @@ version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
@@ -612,6 +627,17 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2b_simd"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06e903a20b159e944f91ec8499fe1e55651480c541ea0a584f5d967c49ad9d99"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"constant_time_eq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -695,9 +721,9 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
||||
|
||||
[[package]]
|
||||
name = "bytesize"
|
||||
version = "2.2.0"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c99fa31e08a43eaa5913ef68d7e01c37a2bdce6ed648168239ad33b7d30a9cd8"
|
||||
checksum = "f5c434ae3cf0089ca203e9019ebe529c47ff45cefe8af7c85ecb734ef541822f"
|
||||
|
||||
[[package]]
|
||||
name = "bzip2-sys"
|
||||
@@ -777,7 +803,9 @@ version = "0.4.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -966,6 +994,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"conduwuit_core",
|
||||
"conduwuit_service",
|
||||
"conduwuit_web",
|
||||
"const-str",
|
||||
"ctor",
|
||||
"futures",
|
||||
@@ -976,6 +1005,8 @@ dependencies = [
|
||||
"ipaddress",
|
||||
"itertools 0.14.0",
|
||||
"log",
|
||||
"oxide-auth",
|
||||
"percent-encoding",
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"ruma",
|
||||
@@ -1142,6 +1173,8 @@ dependencies = [
|
||||
"log",
|
||||
"loole",
|
||||
"lru-cache",
|
||||
"once_cell",
|
||||
"oxide-auth",
|
||||
"rand 0.8.5",
|
||||
"recaptcha-verify",
|
||||
"regex",
|
||||
@@ -1165,13 +1198,19 @@ name = "conduwuit_web"
|
||||
version = "0.5.0-rc.8"
|
||||
dependencies = [
|
||||
"askama",
|
||||
"async-trait",
|
||||
"axum 0.7.9",
|
||||
"conduwuit_build_metadata",
|
||||
"conduwuit_core",
|
||||
"conduwuit_service",
|
||||
"futures",
|
||||
"oxide-auth",
|
||||
"percent-encoding",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"thiserror 2.0.17",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1235,6 +1274,12 @@ dependencies = [
|
||||
"typewit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.7.1"
|
||||
@@ -2346,6 +2391,30 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.0.0"
|
||||
@@ -3299,6 +3368,27 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oxide-auth"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c136ac668d12ba0b5b8ce159b95c7600fda826dc599a1e1916f8461c0d16a84"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"chrono",
|
||||
"hmac",
|
||||
"once_cell",
|
||||
"rand 0.8.5",
|
||||
"rmp-serde",
|
||||
"rust-argon2",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
@@ -3997,6 +4087,28 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp"
|
||||
version = "0.8.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"num-traits",
|
||||
"paste",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp-serde"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"rmp",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roff"
|
||||
version = "0.2.2"
|
||||
@@ -4006,7 +4118,7 @@ checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3"
|
||||
[[package]]
|
||||
name = "ruma"
|
||||
version = "0.10.1"
|
||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=b96a99ac85264aa6bf102c12e201090c6474c99d#b96a99ac85264aa6bf102c12e201090c6474c99d"
|
||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=50b2a91b2ab8f9830eea80b9911e11234e0eac66#50b2a91b2ab8f9830eea80b9911e11234e0eac66"
|
||||
dependencies = [
|
||||
"assign",
|
||||
"js_int",
|
||||
@@ -4026,7 +4138,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ruma-appservice-api"
|
||||
version = "0.10.0"
|
||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=b96a99ac85264aa6bf102c12e201090c6474c99d#b96a99ac85264aa6bf102c12e201090c6474c99d"
|
||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=50b2a91b2ab8f9830eea80b9911e11234e0eac66#50b2a91b2ab8f9830eea80b9911e11234e0eac66"
|
||||
dependencies = [
|
||||
"js_int",
|
||||
"ruma-common",
|
||||
@@ -4038,7 +4150,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ruma-client-api"
|
||||
version = "0.18.0"
|
||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=b96a99ac85264aa6bf102c12e201090c6474c99d#b96a99ac85264aa6bf102c12e201090c6474c99d"
|
||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=50b2a91b2ab8f9830eea80b9911e11234e0eac66#50b2a91b2ab8f9830eea80b9911e11234e0eac66"
|
||||
dependencies = [
|
||||
"as_variant",
|
||||
"assign",
|
||||
@@ -4061,7 +4173,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ruma-common"
|
||||
version = "0.13.0"
|
||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=b96a99ac85264aa6bf102c12e201090c6474c99d#b96a99ac85264aa6bf102c12e201090c6474c99d"
|
||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=50b2a91b2ab8f9830eea80b9911e11234e0eac66#50b2a91b2ab8f9830eea80b9911e11234e0eac66"
|
||||
dependencies = [
|
||||
"as_variant",
|
||||
"base64 0.22.1",
|
||||
@@ -4093,7 +4205,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ruma-events"
|
||||
version = "0.28.1"
|
||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=b96a99ac85264aa6bf102c12e201090c6474c99d#b96a99ac85264aa6bf102c12e201090c6474c99d"
|
||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=50b2a91b2ab8f9830eea80b9911e11234e0eac66#50b2a91b2ab8f9830eea80b9911e11234e0eac66"
|
||||
dependencies = [
|
||||
"as_variant",
|
||||
"indexmap",
|
||||
@@ -4118,7 +4230,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ruma-federation-api"
|
||||
version = "0.9.0"
|
||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=b96a99ac85264aa6bf102c12e201090c6474c99d#b96a99ac85264aa6bf102c12e201090c6474c99d"
|
||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=50b2a91b2ab8f9830eea80b9911e11234e0eac66#50b2a91b2ab8f9830eea80b9911e11234e0eac66"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"headers",
|
||||
@@ -4140,7 +4252,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ruma-identifiers-validation"
|
||||
version = "0.9.5"
|
||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=b96a99ac85264aa6bf102c12e201090c6474c99d#b96a99ac85264aa6bf102c12e201090c6474c99d"
|
||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=50b2a91b2ab8f9830eea80b9911e11234e0eac66#50b2a91b2ab8f9830eea80b9911e11234e0eac66"
|
||||
dependencies = [
|
||||
"js_int",
|
||||
"thiserror 2.0.17",
|
||||
@@ -4149,7 +4261,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ruma-identity-service-api"
|
||||
version = "0.9.0"
|
||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=b96a99ac85264aa6bf102c12e201090c6474c99d#b96a99ac85264aa6bf102c12e201090c6474c99d"
|
||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=50b2a91b2ab8f9830eea80b9911e11234e0eac66#50b2a91b2ab8f9830eea80b9911e11234e0eac66"
|
||||
dependencies = [
|
||||
"js_int",
|
||||
"ruma-common",
|
||||
@@ -4159,7 +4271,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ruma-macros"
|
||||
version = "0.13.0"
|
||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=b96a99ac85264aa6bf102c12e201090c6474c99d#b96a99ac85264aa6bf102c12e201090c6474c99d"
|
||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=50b2a91b2ab8f9830eea80b9911e11234e0eac66#50b2a91b2ab8f9830eea80b9911e11234e0eac66"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"proc-macro-crate",
|
||||
@@ -4174,7 +4286,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ruma-push-gateway-api"
|
||||
version = "0.9.0"
|
||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=b96a99ac85264aa6bf102c12e201090c6474c99d#b96a99ac85264aa6bf102c12e201090c6474c99d"
|
||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=50b2a91b2ab8f9830eea80b9911e11234e0eac66#50b2a91b2ab8f9830eea80b9911e11234e0eac66"
|
||||
dependencies = [
|
||||
"js_int",
|
||||
"ruma-common",
|
||||
@@ -4186,7 +4298,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ruma-signatures"
|
||||
version = "0.15.0"
|
||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=b96a99ac85264aa6bf102c12e201090c6474c99d#b96a99ac85264aa6bf102c12e201090c6474c99d"
|
||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=50b2a91b2ab8f9830eea80b9911e11234e0eac66#50b2a91b2ab8f9830eea80b9911e11234e0eac66"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"ed25519-dalek",
|
||||
@@ -4199,6 +4311,17 @@ dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-argon2"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d9848531d60c9cbbcf9d166c885316c24bc0e2a9d3eba0956bb6cbbd79bc6e8"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"blake2b_simd",
|
||||
"constant_time_eq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-librocksdb-sys"
|
||||
version = "0.39.0+10.5.1"
|
||||
@@ -4901,9 +5024,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.110"
|
||||
version = "2.0.109"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea"
|
||||
checksum = "2f17c7e013e88258aa9543dcbe81aca68a667a9ac37cd69c9fbc07858bfe0e2f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5866,6 +5989,41 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link 0.1.3",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.1.3"
|
||||
@@ -5878,6 +6036,24 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
|
||||
+13
-1
@@ -103,6 +103,9 @@ features = [
|
||||
"matched-path",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"query",
|
||||
# Needed for debug_handler.
|
||||
#"macros",
|
||||
]
|
||||
|
||||
[workspace.dependencies.axum-extra]
|
||||
@@ -351,7 +354,7 @@ version = "0.1.2"
|
||||
# Used for matrix spec type definitions and helpers
|
||||
[workspace.dependencies.ruma]
|
||||
git = "https://forgejo.ellis.link/continuwuation/ruwuma"
|
||||
rev = "b96a99ac85264aa6bf102c12e201090c6474c99d"
|
||||
rev = "50b2a91b2ab8f9830eea80b9911e11234e0eac66"
|
||||
features = [
|
||||
"compat",
|
||||
"rand",
|
||||
@@ -369,6 +372,7 @@ features = [
|
||||
"unstable-msc2666",
|
||||
"unstable-msc2867",
|
||||
"unstable-msc2870",
|
||||
"unstable-msc2965",
|
||||
"unstable-msc3026",
|
||||
"unstable-msc3061",
|
||||
"unstable-msc3245",
|
||||
@@ -557,6 +561,14 @@ features = ["sync", "tls-rustls", "rustls-provider"]
|
||||
[workspace.dependencies.resolv-conf]
|
||||
version = "0.7.5"
|
||||
|
||||
[workspace.dependencies.oxide-auth]
|
||||
version = "0.6.1"
|
||||
|
||||
[workspace.dependencies.once_cell]
|
||||
version = "1.21.3"
|
||||
|
||||
[workspace.dependencies.percent-encoding]
|
||||
version = "2.3.1"
|
||||
#
|
||||
# Patches
|
||||
#
|
||||
|
||||
+20
-1
@@ -23,7 +23,8 @@
|
||||
# See the docs for reverse proxying and delegation:
|
||||
# https://continuwuity.org/deploying/generic.html#setting-up-the-reverse-proxy
|
||||
#
|
||||
# Also see the `[global.well_known]` config section at the very bottom.
|
||||
# Also see the `[global.auth]` and `[global.well_known]` config sections
|
||||
# at the very bottom.
|
||||
#
|
||||
# Examples of delegation:
|
||||
# - https://puppygock.gay/.well-known/matrix/server
|
||||
@@ -1754,6 +1755,24 @@
|
||||
#
|
||||
#dual_protocol = false
|
||||
|
||||
[global.auth]
|
||||
|
||||
# Use this homeserver as the OIDC authentication reference. It will
|
||||
# advertise itself as the OIDC authentication issuer to new clients,
|
||||
# and use the internal user database to answer on the advertised
|
||||
# endpoints. Note that the legacy Matrix authentication still will be
|
||||
# reachable.
|
||||
# Unset by default.
|
||||
#
|
||||
#enable_oidc_login =
|
||||
|
||||
# Whether this homeserver should provide users with an account management
|
||||
# interface. Only used if `enable_oidc_login` is set. Note that the
|
||||
# endpoint is unimplemented at the moment.
|
||||
# Unset by default.
|
||||
#
|
||||
#enable_oidc_account_management =
|
||||
|
||||
[global.well_known]
|
||||
|
||||
# The server URL that the client well-known file will serve. This should
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ EOF
|
||||
|
||||
# Developer tool versions
|
||||
# renovate: datasource=github-releases depName=cargo-bins/cargo-binstall
|
||||
ENV BINSTALL_VERSION=1.15.11
|
||||
ENV BINSTALL_VERSION=1.15.10
|
||||
# renovate: datasource=github-releases depName=psastras/sbom-rs
|
||||
ENV CARGO_SBOM_VERSION=0.9.1
|
||||
# renovate: datasource=crate depName=lddtree
|
||||
|
||||
@@ -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.15.11
|
||||
ENV BINSTALL_VERSION=1.15.10
|
||||
# renovate: datasource=github-releases depName=psastras/sbom-rs
|
||||
ENV CARGO_SBOM_VERSION=0.9.1
|
||||
# renovate: datasource=crate depName=lddtree
|
||||
|
||||
@@ -63,7 +63,7 @@ Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
TimeoutStopSec=4m
|
||||
TimeoutStartSec=10m
|
||||
TimeoutStartSec=4m
|
||||
|
||||
StartLimitInterval=1m
|
||||
StartLimitBurst=5
|
||||
|
||||
@@ -41,7 +41,7 @@ async fn changes_since(
|
||||
let results: Vec<_> = self
|
||||
.services
|
||||
.account_data
|
||||
.changes_since(room_id.as_deref(), &user_id, Some(since), None)
|
||||
.changes_since(room_id.as_deref(), &user_id, since, None)
|
||||
.collect()
|
||||
.await;
|
||||
let query_time = timer.elapsed();
|
||||
|
||||
@@ -94,6 +94,9 @@ sha1.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
ctor.workspace = true
|
||||
oxide-auth.workspace = true
|
||||
conduwuit-web.workspace = true
|
||||
percent-encoding.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -75,7 +75,9 @@ pub(crate) async fn upload_keys_route(
|
||||
}
|
||||
if deser_device_keys.device_id != sender_device {
|
||||
return Err!(Request(Unknown(
|
||||
"Device ID in keys uploaded does not match your own device ID"
|
||||
"Device ID in keys uploaded ({}) does not match your own device ID ({})",
|
||||
deser_device_keys.device_id,
|
||||
sender_device,
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -389,7 +391,7 @@ pub(crate) async fn get_key_changes_route(
|
||||
device_list_updates.extend(
|
||||
services
|
||||
.users
|
||||
.keys_changed(sender_user, Some(from), Some(to))
|
||||
.keys_changed(sender_user, from, Some(to))
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
.await,
|
||||
@@ -401,7 +403,7 @@ pub(crate) async fn get_key_changes_route(
|
||||
device_list_updates.extend(
|
||||
services
|
||||
.users
|
||||
.room_keys_changed(room_id, Some(from), Some(to))
|
||||
.room_keys_changed(room_id, from, Some(to))
|
||||
.map(|(user_id, _)| user_id)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use conduwuit::{
|
||||
Err, Result, debug, debug_info, debug_warn, err, info,
|
||||
matrix::{
|
||||
event::gen_event_id,
|
||||
event::{Event, gen_event_id},
|
||||
pdu::{PduBuilder, PduEvent},
|
||||
},
|
||||
result::FlatOk,
|
||||
@@ -458,7 +458,7 @@ async fn knock_room_helper_local(
|
||||
.await,
|
||||
};
|
||||
|
||||
services
|
||||
let send_knock_response = services
|
||||
.sending
|
||||
.send_federation_request(&remote_server, send_knock_request)
|
||||
.await?;
|
||||
@@ -477,14 +477,20 @@ async fn knock_room_helper_local(
|
||||
.map_err(|e| err!(BadServerResponse("Invalid knock event PDU: {e:?}")))?;
|
||||
|
||||
info!("Updating membership locally to knock state with provided stripped state events");
|
||||
// TODO: this call does not appear to do anything because `update_membership`
|
||||
// doesn't call `mark_as_knock`. investigate further, ideally with the aim of
|
||||
// removing this call entirely -- Ginger thinks `update_membership` should only
|
||||
// be called from `force_state` and `append_pdu`.
|
||||
services
|
||||
.rooms
|
||||
.state_cache
|
||||
.update_membership(room_id, sender_user, &parsed_knock_pdu, false)
|
||||
.update_membership(
|
||||
room_id,
|
||||
sender_user,
|
||||
parsed_knock_pdu
|
||||
.get_content::<RoomMemberEventContent>()
|
||||
.expect("we just created this"),
|
||||
sender_user,
|
||||
Some(send_knock_response.knock_room_state),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("Appending room knock event locally");
|
||||
@@ -671,11 +677,20 @@ async fn knock_room_helper_remote(
|
||||
.await?;
|
||||
|
||||
info!("Updating membership locally to knock state with provided stripped state events");
|
||||
// TODO: see TODO on the other call to `update_membership`
|
||||
services
|
||||
.rooms
|
||||
.state_cache
|
||||
.update_membership(room_id, sender_user, &parsed_knock_pdu, false)
|
||||
.update_membership(
|
||||
room_id,
|
||||
sender_user,
|
||||
parsed_knock_pdu
|
||||
.get_content::<RoomMemberEventContent>()
|
||||
.expect("we just created this"),
|
||||
sender_user,
|
||||
Some(send_knock_response.knock_room_state),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("Appending room knock event locally");
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
use axum::extract::State;
|
||||
use conduwuit::{
|
||||
Err, Pdu, Result, debug_info, debug_warn, err,
|
||||
Err, Result, debug_info, debug_warn, err,
|
||||
matrix::{event::gen_event_id, pdu::PduBuilder},
|
||||
utils::{self, FutureBoolExt, future::ReadyEqExt},
|
||||
warn,
|
||||
};
|
||||
use futures::{FutureExt, StreamExt, pin_mut};
|
||||
use futures::{FutureExt, StreamExt, TryFutureExt, pin_mut};
|
||||
use ruma::{
|
||||
CanonicalJsonObject, CanonicalJsonValue, OwnedServerName, RoomId, RoomVersionId, UserId,
|
||||
api::{
|
||||
@@ -81,9 +81,42 @@ pub async fn leave_room(
|
||||
room_id: &RoomId,
|
||||
reason: Option<String>,
|
||||
) -> Result {
|
||||
let default_member_content = RoomMemberEventContent {
|
||||
membership: MembershipState::Leave,
|
||||
reason: reason.clone(),
|
||||
join_authorized_via_users_server: None,
|
||||
is_direct: None,
|
||||
avatar_url: None,
|
||||
displayname: None,
|
||||
third_party_invite: None,
|
||||
blurhash: None,
|
||||
redact_events: None,
|
||||
};
|
||||
|
||||
let is_banned = services.rooms.metadata.is_banned(room_id);
|
||||
let is_disabled = services.rooms.metadata.is_disabled(room_id);
|
||||
|
||||
pin_mut!(is_banned, is_disabled);
|
||||
if is_banned.or(is_disabled).await {
|
||||
// the room is banned/disabled, the room must be rejected locally since we
|
||||
// cant/dont want to federate with this server
|
||||
services
|
||||
.rooms
|
||||
.state_cache
|
||||
.update_membership(
|
||||
room_id,
|
||||
user_id,
|
||||
default_member_content,
|
||||
user_id,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let dont_have_room = services
|
||||
.rooms
|
||||
.state_cache
|
||||
@@ -96,41 +129,43 @@ pub async fn leave_room(
|
||||
.is_knocked(user_id, room_id)
|
||||
.eq(&false);
|
||||
|
||||
pin_mut!(is_banned, is_disabled);
|
||||
|
||||
/*
|
||||
there are three possible cases when leaving a room:
|
||||
1. the room is banned or disabled, so we're not federating with it.
|
||||
2. nobody on the homeserver is in the room, which can happen if the user is rejecting an invite
|
||||
to a room that we don't have any members in.
|
||||
3. someone else on the homeserver is in the room. in this case we can leave like normal by sending a PDU over federation.
|
||||
|
||||
in cases 1 and 2, we have to update the state cache using `mark_as_left` directly.
|
||||
otherwise `build_and_append_pdu` will take care of updating the state cache for us.
|
||||
*/
|
||||
|
||||
// `leave_pdu` is the outlier `m.room.member` event which will be synced to the
|
||||
// user. if it's None the sync handler will create a dummy PDU.
|
||||
let leave_pdu = if is_banned.or(is_disabled).await {
|
||||
// case 1: the room is banned/disabled. we don't want to federate with another
|
||||
// server to leave, so we can't create an outlier PDU.
|
||||
None
|
||||
} else if dont_have_room.and(not_knocked).await {
|
||||
// case 2: ask a remote server to assist us with leaving
|
||||
// we always mark the room as left locally, regardless of if the federated leave
|
||||
// failed
|
||||
|
||||
remote_leave_room(services, user_id, room_id, reason.clone())
|
||||
// Ask a remote server if we don't have this room and are not knocking on it
|
||||
if dont_have_room.and(not_knocked).await {
|
||||
if let Err(e) = remote_leave_room(services, user_id, room_id, reason.clone())
|
||||
.boxed()
|
||||
.await
|
||||
.inspect_err(|err| {
|
||||
warn!(%user_id, "Failed to leave room {room_id} remotely: {err}");
|
||||
})
|
||||
.ok()
|
||||
{
|
||||
warn!(%user_id, "Failed to leave room {room_id} remotely: {e}");
|
||||
// Don't tell the client about this error
|
||||
}
|
||||
|
||||
let last_state = services
|
||||
.rooms
|
||||
.state_cache
|
||||
.invite_state(user_id, room_id)
|
||||
.or_else(|_| services.rooms.state_cache.knock_state(user_id, room_id))
|
||||
.or_else(|_| services.rooms.state_cache.left_state(user_id, room_id))
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// We always drop the invite, we can't rely on other servers
|
||||
services
|
||||
.rooms
|
||||
.state_cache
|
||||
.update_membership(
|
||||
room_id,
|
||||
user_id,
|
||||
default_member_content,
|
||||
user_id,
|
||||
last_state,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
// case 3: we can leave by sending a PDU.
|
||||
let state_lock = services.rooms.state.mutex.lock(room_id).await;
|
||||
|
||||
let user_member_event_content = services
|
||||
let Ok(event) = services
|
||||
.rooms
|
||||
.state_accessor
|
||||
.room_state_get_content::<RoomMemberEventContent>(
|
||||
@@ -138,61 +173,44 @@ pub async fn leave_room(
|
||||
&StateEventType::RoomMember,
|
||||
user_id.as_str(),
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
else {
|
||||
debug_warn!(
|
||||
"Trying to leave a room you are not a member of, marking room as left locally."
|
||||
);
|
||||
|
||||
match user_member_event_content {
|
||||
| Ok(content) => {
|
||||
services
|
||||
.rooms
|
||||
.timeline
|
||||
.build_and_append_pdu(
|
||||
PduBuilder::state(user_id.to_string(), &RoomMemberEventContent {
|
||||
membership: MembershipState::Leave,
|
||||
reason,
|
||||
join_authorized_via_users_server: None,
|
||||
is_direct: None,
|
||||
..content
|
||||
}),
|
||||
user_id,
|
||||
Some(room_id),
|
||||
&state_lock,
|
||||
)
|
||||
.await?;
|
||||
return services
|
||||
.rooms
|
||||
.state_cache
|
||||
.update_membership(
|
||||
room_id,
|
||||
user_id,
|
||||
default_member_content,
|
||||
user_id,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
};
|
||||
|
||||
// `build_and_append_pdu` calls `mark_as_left` internally, so we return early.
|
||||
return Ok(());
|
||||
},
|
||||
| Err(_) => {
|
||||
// an exception to case 3 is if the user isn't even in the room they're trying
|
||||
// to leave. this can happen if the client's caching is wrong.
|
||||
debug_warn!(
|
||||
"Trying to leave a room you are not a member of, marking room as left \
|
||||
locally."
|
||||
);
|
||||
|
||||
// return the existing leave state, if one exists. `mark_as_left` will then
|
||||
// update the `roomuserid_leftcount` table, making the leave come down sync
|
||||
// again.
|
||||
services
|
||||
.rooms
|
||||
.state_cache
|
||||
.left_state(user_id, room_id)
|
||||
.await?
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
services
|
||||
.rooms
|
||||
.state_cache
|
||||
.mark_as_left(user_id, room_id, leave_pdu)
|
||||
.await;
|
||||
|
||||
services
|
||||
.rooms
|
||||
.state_cache
|
||||
.update_joined_count(room_id)
|
||||
.await;
|
||||
services
|
||||
.rooms
|
||||
.timeline
|
||||
.build_and_append_pdu(
|
||||
PduBuilder::state(user_id.to_string(), &RoomMemberEventContent {
|
||||
membership: MembershipState::Leave,
|
||||
reason,
|
||||
join_authorized_via_users_server: None,
|
||||
is_direct: None,
|
||||
..event
|
||||
}),
|
||||
user_id,
|
||||
Some(room_id),
|
||||
&state_lock,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -202,7 +220,7 @@ pub async fn remote_leave_room(
|
||||
user_id: &UserId,
|
||||
room_id: &RoomId,
|
||||
reason: Option<String>,
|
||||
) -> Result<Pdu> {
|
||||
) -> Result<()> {
|
||||
let mut make_leave_response_and_server =
|
||||
Err!(BadServerResponse("No remote server available to assist in leaving {room_id}."));
|
||||
|
||||
@@ -355,7 +373,7 @@ pub async fn remote_leave_room(
|
||||
&remote_server,
|
||||
federation::membership::create_leave_event::v2::Request {
|
||||
room_id: room_id.to_owned(),
|
||||
event_id: event_id.clone(),
|
||||
event_id,
|
||||
pdu: services
|
||||
.sending
|
||||
.convert_to_outgoing_federation_event(leave_event.clone())
|
||||
@@ -364,14 +382,5 @@ pub async fn remote_leave_room(
|
||||
)
|
||||
.await?;
|
||||
|
||||
services
|
||||
.rooms
|
||||
.outlier
|
||||
.add_pdu_outlier(&event_id, &leave_event);
|
||||
|
||||
let leave_pdu = Pdu::from_id_val(&event_id, leave_event).map_err(|e| {
|
||||
err!(BadServerResponse("Invalid leave PDU received during federated leave: {e:?}"))
|
||||
})?;
|
||||
|
||||
Ok(leave_pdu)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
Services,
|
||||
rooms::{
|
||||
lazy_loading,
|
||||
lazy_loading::{MemberSet, Options},
|
||||
lazy_loading::{Options, Witness},
|
||||
timeline::PdusIterItem,
|
||||
},
|
||||
};
|
||||
@@ -162,7 +162,7 @@ pub(crate) async fn get_message_events_route(
|
||||
|
||||
let state = witness
|
||||
.map(Option::into_iter)
|
||||
.map(|option| option.flat_map(MemberSet::into_iter))
|
||||
.map(|option| option.flat_map(Witness::into_iter))
|
||||
.map(IterStream::stream)
|
||||
.into_stream()
|
||||
.flatten()
|
||||
@@ -192,7 +192,7 @@ pub(crate) async fn lazy_loading_witness<'a, I>(
|
||||
services: &Services,
|
||||
lazy_loading_context: &lazy_loading::Context<'_>,
|
||||
events: I,
|
||||
) -> MemberSet
|
||||
) -> Witness
|
||||
where
|
||||
I: Iterator<Item = &'a PdusIterItem> + Clone + Send,
|
||||
{
|
||||
@@ -213,10 +213,10 @@ pub(crate) async fn lazy_loading_witness<'a, I>(
|
||||
let receipts = services
|
||||
.rooms
|
||||
.read_receipt
|
||||
.readreceipts_since(lazy_loading_context.room_id, Some(oldest.into_unsigned()));
|
||||
.readreceipts_since(lazy_loading_context.room_id, oldest.into_unsigned());
|
||||
|
||||
pin_mut!(receipts);
|
||||
let witness: MemberSet = events
|
||||
let witness: Witness = events
|
||||
.stream()
|
||||
.map(ref_at!(1))
|
||||
.map(Event::sender)
|
||||
@@ -224,7 +224,7 @@ pub(crate) async fn lazy_loading_witness<'a, I>(
|
||||
.chain(
|
||||
receipts
|
||||
.ready_take_while(|(_, c, _)| *c <= newest.into_unsigned())
|
||||
.map(|(user_id, ..)| user_id),
|
||||
.map(|(user_id, ..)| user_id.to_owned()),
|
||||
)
|
||||
.collect()
|
||||
.await;
|
||||
@@ -232,7 +232,7 @@ pub(crate) async fn lazy_loading_witness<'a, I>(
|
||||
services
|
||||
.rooms
|
||||
.lazy_loading
|
||||
.retain_lazy_members(witness, lazy_loading_context)
|
||||
.witness_retain(witness, lazy_loading_context)
|
||||
.await
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
pub(super) mod media_legacy;
|
||||
pub(super) mod membership;
|
||||
pub(super) mod message;
|
||||
pub(super) mod oidc;
|
||||
pub(super) mod openid;
|
||||
pub(super) mod presence;
|
||||
pub(super) mod profile;
|
||||
@@ -58,6 +59,7 @@
|
||||
pub(super) use membership::*;
|
||||
pub use membership::{join_room_by_id_helper, leave_all_rooms, leave_room, remote_leave_room};
|
||||
pub(super) use message::*;
|
||||
pub(super) use oidc::*;
|
||||
pub(super) use openid::*;
|
||||
pub(super) use presence::*;
|
||||
pub(super) use profile::*;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
use axum::extract::{Query, State};
|
||||
use conduwuit::{Result, err, utils::ReadyExt};
|
||||
use conduwuit_web::oidc::{
|
||||
AuthorizationQuery, OidcRequest, OidcResponse, oidc_consent_form, oidc_login_form,
|
||||
};
|
||||
use oxide_auth::{
|
||||
endpoint::{OwnerConsent, Solicitation},
|
||||
frontends::simple::endpoint::FnSolicitor,
|
||||
};
|
||||
use percent_encoding::percent_decode_str;
|
||||
use ruma::UserId;
|
||||
use service::oidc::{SCOPE_PREFIX_API, SCOPE_PREFIX_DEVICE};
|
||||
|
||||
/// # `GET /_matrix/client/unstable/org.matrix.msc2964/authorize`
|
||||
///
|
||||
/// Authenticate a user and device, and solicit the user's consent.
|
||||
///
|
||||
/// Redirects to the login page if no token or token not belonging to any user.
|
||||
/// [super::login::oidc_login] takes it up at the same point, so it's either
|
||||
/// the client has a token, or the user does user password. Then the user gets
|
||||
/// access to stage two, [authorize_consent].
|
||||
pub(crate) async fn authorize(
|
||||
State(services): State<crate::State>,
|
||||
Query(query): Query<AuthorizationQuery>,
|
||||
oauth: OidcRequest,
|
||||
) -> Result<OidcResponse> {
|
||||
tracing::trace!("processing OAuth request: {query:#?}");
|
||||
// Enforce MSC2964's restrictions on OAuth2 flow.
|
||||
let Ok(scope) = percent_decode_str(&query.scope).decode_utf8() else {
|
||||
return Err(err!(Request(Unknown("the scope could not be percent-decoded"))));
|
||||
};
|
||||
if !scope.contains(&format!("{SCOPE_PREFIX_API}*")) {
|
||||
return Err(err!(Request(Unknown("the scope does not include the client API"))));
|
||||
}
|
||||
if !scope.contains(SCOPE_PREFIX_DEVICE) {
|
||||
return Err(err!(Request(Unknown("the scope does not include a device ID"))));
|
||||
}
|
||||
if query.code_challenge_method != "S256" {
|
||||
return Err(err!(Request(Unknown("unsupported code challenge method"))));
|
||||
}
|
||||
|
||||
// Redirect to the login page if no token or token not known.
|
||||
let hostname = services.config.server_name.host();
|
||||
let Some(token) = oauth.authorization_header() else {
|
||||
return Ok(oidc_login_form(hostname, &query));
|
||||
};
|
||||
|
||||
tracing::debug!("submitting OIDC authorisation for token : {token:#?}");
|
||||
// Get the user id from the token and add it to the query.
|
||||
let (owner_id, _) = services.oidc.user_and_device_from_token(token).await?;
|
||||
let mut query_with_user_id = query.clone();
|
||||
query_with_user_id.username = Some(owner_id.localpart().to_owned());
|
||||
|
||||
services
|
||||
.oidc
|
||||
.endpoint()
|
||||
.with_solicitor(oidc_consent_form(hostname, &query_with_user_id))
|
||||
.authorization_flow()
|
||||
.execute(oauth)
|
||||
.map_err(|err| err!("authorization failed: {err:?}"))
|
||||
}
|
||||
|
||||
/// Whether a user allows their device to access this homeserver's resources.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub(crate) struct Allowance {
|
||||
allow: Option<String>,
|
||||
}
|
||||
|
||||
/// # `POST /_matrix/client/unstable/org.matrix.msc2964/authorize?allow=[Option<String>]`
|
||||
///
|
||||
/// Authorize the device based on the owner's consent. If the owner allows
|
||||
/// it to access their data, the client may request a token at the
|
||||
/// [super::token::token] endpoint.
|
||||
///
|
||||
/// On the owner's consent, if their specific device is unregistered it will be
|
||||
/// registered in their device list (not to be confused with the OIDC client
|
||||
/// registration).
|
||||
pub(crate) async fn authorize_consent(
|
||||
Query(Allowance { allow }): Query<Allowance>,
|
||||
State(services): State<crate::State>,
|
||||
Query(query): Query<AuthorizationQuery>,
|
||||
oauth: OidcRequest,
|
||||
) -> Result<OidcResponse> {
|
||||
tracing::debug!("processing owner's consent: {:?}", allow);
|
||||
tracing::trace!("owner's consent request: {:#?}", query);
|
||||
let Some(owner_id) = allow.clone() else {
|
||||
return Err(err!(Request(Unknown("the owner did not consent to the client's access"))));
|
||||
};
|
||||
let server_name = services.globals.server_name();
|
||||
let owner_id = UserId::parse_with_server_name(owner_id.clone(), server_name)
|
||||
.map_err(|err| err!(Request(InvalidUsername("invalid username {owner_id:?}: {err}"))))?;
|
||||
let Some(matrix_client) = services
|
||||
.oidc
|
||||
.client_from_client_id(&query.client_id)
|
||||
.await?
|
||||
else {
|
||||
return Err(err!(Request(Unknown(
|
||||
"no client has registered client_id {:?}",
|
||||
query.client_id
|
||||
))));
|
||||
};
|
||||
let scope = query.scope.parse().map_err(|err| {
|
||||
err!(Request(Unknown("could not parse scope {:?}: {}", query.scope, err)))
|
||||
})?;
|
||||
let device_id = services.oidc.device_id_from_scope(&scope)?;
|
||||
// Check that the device is registered in the owner devices list.
|
||||
// Note that this is _not_ the OIDC client registration.
|
||||
let device_is_registered_with_owner = services
|
||||
.users
|
||||
.all_device_ids(&owner_id)
|
||||
.ready_any(|v| v == device_id)
|
||||
.await;
|
||||
if !device_is_registered_with_owner {
|
||||
// TODO get the client's IP from the request.
|
||||
let client_ip = None;
|
||||
services
|
||||
.oidc
|
||||
.register_device(
|
||||
&query.client_id,
|
||||
(&owner_id, &device_id),
|
||||
matrix_client.name.as_deref(),
|
||||
client_ip,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
services
|
||||
.oidc
|
||||
.endpoint()
|
||||
.with_solicitor(FnSolicitor(move |_: &mut _, _: Solicitation<'_>| match allow.clone() {
|
||||
| None => OwnerConsent::Denied,
|
||||
| Some(user_id) => OwnerConsent::Authorized(user_id),
|
||||
}))
|
||||
.authorization_flow()
|
||||
.execute(oauth)
|
||||
.map_err(|err| err!(Request(Unknown("consent request failed: {err:?}"))))
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/// Manual implementation of [MSC2965]'s OIDC server discovery.
|
||||
///
|
||||
/// [MSC2965]: https://github.com/matrix-org/matrix-spec-proposals/pull/2965
|
||||
use axum::extract::State;
|
||||
use conduwuit::Result;
|
||||
use ruma::{
|
||||
api::client::{
|
||||
discovery::get_authorization_server_metadata::msc2965::{
|
||||
self, AccountManagementAction, AuthorizationServerMetadata, CodeChallengeMethod,
|
||||
GrantType, Prompt, ResponseMode, ResponseType,
|
||||
},
|
||||
error::{
|
||||
Error as ClientError, ErrorBody as ClientErrorBody, ErrorKind as ClientErrorKind,
|
||||
},
|
||||
},
|
||||
serde::Raw,
|
||||
};
|
||||
|
||||
use crate::{Ruma, RumaResponse, conduwuit::Error};
|
||||
|
||||
/// # `GET /_matrix/client/unstable/org.matrix.msc2965/auth_metadata`
|
||||
///
|
||||
/// If `globals.auth.enable_oidc_login` is set, advertise this homeserver's
|
||||
/// OAuth2 endpoints. Otherwise, MSC2965 requires that the homeserver responds
|
||||
/// with 404/M_UNRECOGNIZED.
|
||||
pub(crate) async fn get_auth_metadata(
|
||||
State(services): State<crate::State>,
|
||||
_body: Ruma<msc2965::Request>,
|
||||
) -> Result<RumaResponse<msc2965::Response>> {
|
||||
let unrecognized_error = Err(Error::Ruma(ClientError::new(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
ClientErrorBody::Standard {
|
||||
kind: ClientErrorKind::Unrecognized,
|
||||
message: "This homeserver has disabled OIDC authentication.".to_owned(),
|
||||
},
|
||||
)));
|
||||
let Some(ref auth) = services.server.config.auth else {
|
||||
return unrecognized_error;
|
||||
};
|
||||
if !auth.enable_oidc_login {
|
||||
return unrecognized_error;
|
||||
}
|
||||
// Advertise this homeserver's access URL as the issuer URL.
|
||||
// Unwrap all Url::parse() calls because the issuer URL is validated at startup.
|
||||
let issuer = services.server.config.well_known.client.as_ref().unwrap();
|
||||
let account_management_uri = auth.enable_oidc_account_management.then_some(
|
||||
issuer
|
||||
.join("/_matrix/client/unstable/org.matrix.msc2964/account")
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
// Build up metadata with primitives from ruma::api::client::msc2965.
|
||||
let metadata = AuthorizationServerMetadata {
|
||||
issuer: issuer.clone(),
|
||||
authorization_endpoint: issuer
|
||||
.join("/_matrix/client/unstable/org.matrix.msc2964/authorize")
|
||||
.unwrap(),
|
||||
device_authorization_endpoint: Some(
|
||||
issuer
|
||||
.join("/_matrix/client/unstable/org.matrix.msc2964/device")
|
||||
.unwrap(),
|
||||
),
|
||||
token_endpoint: issuer
|
||||
.join("/_matrix/client/unstable/org.matrix.msc2964/token")
|
||||
.unwrap(),
|
||||
registration_endpoint: Some(
|
||||
issuer
|
||||
.join("/_matrix/client/unstable/org.matrix.msc2964/device/register")
|
||||
.unwrap(),
|
||||
),
|
||||
revocation_endpoint: issuer
|
||||
.join("/_matrix/client/unstable/org.matrix.msc2964/revoke")
|
||||
.unwrap(),
|
||||
response_types_supported: [ResponseType::Code].into(),
|
||||
grant_types_supported: [GrantType::AuthorizationCode, GrantType::RefreshToken].into(),
|
||||
response_modes_supported: [ResponseMode::Fragment, ResponseMode::Query].into(),
|
||||
code_challenge_methods_supported: [CodeChallengeMethod::S256].into(),
|
||||
account_management_uri,
|
||||
account_management_actions_supported: [
|
||||
AccountManagementAction::Profile,
|
||||
AccountManagementAction::SessionView,
|
||||
AccountManagementAction::SessionEnd,
|
||||
]
|
||||
.into(),
|
||||
prompt_values_supported: match services.server.config.allow_registration {
|
||||
| true => vec![Prompt::Create],
|
||||
| false => vec![],
|
||||
},
|
||||
};
|
||||
let metadata = Raw::new(&metadata).expect("authorization server metadata should serialize");
|
||||
|
||||
Ok(RumaResponse(msc2965::Response::new(metadata)))
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use axum::extract::State;
|
||||
use conduwuit::{Result, err, utils::hash::verify_password};
|
||||
use conduwuit_web::oidc::{LoginError, LoginQuery, OidcRequest, OidcResponse, oidc_consent_form};
|
||||
use ruma::user_id::UserId;
|
||||
|
||||
//#[axum::debug_handler]
|
||||
/// # `POST /_matrix/client/unstable/org.matrix.msc2964/login`
|
||||
///
|
||||
/// Display a login UI to the user and return an authorization code on success.
|
||||
/// We presume that the OAuth2 query parameters are provided in the form.
|
||||
/// With the code, the client may then access stage two,
|
||||
/// [super::authorize::authorize_consent].
|
||||
pub(crate) async fn oidc_login(
|
||||
State(services): State<crate::State>,
|
||||
request: OidcRequest,
|
||||
) -> Result<OidcResponse> {
|
||||
let query: LoginQuery = request.clone().try_into().map_err(|LoginError(err)| {
|
||||
err!(Request(InvalidParam("Cannot process login form. {err}")))
|
||||
})?;
|
||||
tracing::trace!("processing login query {:#?}", query.clone());
|
||||
// Only accept local usernames. Mostly to simplify things at first.
|
||||
let user_id =
|
||||
UserId::parse_with_server_name(query.username.clone(), &services.config.server_name)
|
||||
.map_err(|e| err!(Request(InvalidUsername("Username is invalid: {e}"))))?;
|
||||
|
||||
if !services.users.exists(&user_id).await {
|
||||
return Err(err!(Request(Unknown("unknown username"))));
|
||||
}
|
||||
let valid_hash = services.users.password_hash(&user_id).await?;
|
||||
|
||||
if valid_hash.is_empty() {
|
||||
return Err(err!(Request(UserDeactivated("the user's hash was not found"))));
|
||||
}
|
||||
if verify_password(&query.password, &valid_hash).is_err() {
|
||||
return Err(err!(Request(InvalidParam("password does not match"))));
|
||||
}
|
||||
// TODO check if user disabled, etc. See /src/api/client/session.rs
|
||||
let hostname = services.config.server_name.host();
|
||||
tracing::info!("logging in {user_id:?}");
|
||||
|
||||
services
|
||||
.oidc
|
||||
.endpoint()
|
||||
.with_solicitor(oidc_consent_form(hostname, &query.into()))
|
||||
.authorization_flow()
|
||||
.execute(request)
|
||||
.map_err(|err| err!(Request(Unknown("authorisation failed: {err:?}"))))
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! OIDC
|
||||
//!
|
||||
//! Stands for OpenID Connect, and is an authentication scheme relying on
|
||||
//! OAuth2. The [MSC2964] Matrix Spec Proposal describes an authentication
|
||||
//! process based on the OIDC flow, with restrictions. See the [sample flow] for
|
||||
//! details on what's expected.
|
||||
//!
|
||||
//! This module implements the needed endpoints. It relies on the [oxide-auth]
|
||||
//! crate, and the [`service::oidc`] and [`web::oidc`] modules.
|
||||
//!
|
||||
//! [MSC2964]: https://github.com/matrix-org/matrix-spec-proposals/pull/2964
|
||||
//! [oxide-auth]: https://docs.rs/oxide-auth
|
||||
//! [sample flow]: https://github.com/sandhose/matrix-spec-proposals/blob/msc/sandhose/oauth2-profile/proposals/2964-oauth2-profile.md#sample-flow
|
||||
|
||||
mod authorize;
|
||||
mod discovery;
|
||||
mod login;
|
||||
mod register;
|
||||
mod token;
|
||||
|
||||
pub(crate) use self::{
|
||||
authorize::{authorize, authorize_consent},
|
||||
discovery::get_auth_metadata,
|
||||
login::oidc_login,
|
||||
register::register_client,
|
||||
token::token,
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
use axum::{Json, extract::State};
|
||||
use conduwuit::{Result, err};
|
||||
use conduwuit_service::oidc::normalize_redirect;
|
||||
use oxide_auth::primitives::prelude::Client;
|
||||
use reqwest::Url;
|
||||
use ruma::{ClientSecret, DeviceId, identifiers_validation};
|
||||
|
||||
/// The required parameters to register a new client for OAuth2 application.
|
||||
/// See the required metadata in OAuth2 authorization grant flow in [MSC2966].
|
||||
///
|
||||
/// [MSC2966]: https://github.com/matrix-org/matrix-spec-proposals/pull/2966
|
||||
#[derive(serde::Deserialize, Clone, Debug)]
|
||||
pub(crate) struct ClientQuery {
|
||||
/// Human-readable name.
|
||||
client_name: String,
|
||||
/// A public page that tells more about the client. All other links must be
|
||||
/// within.
|
||||
client_uri: Url,
|
||||
/// Redirect URIs declared by the client. At least one.
|
||||
redirect_uris: Vec<Url>,
|
||||
/// Must include the literal "code".
|
||||
response_types: Vec<String>,
|
||||
/// Must include the literals "authorization_code" and "refresh_token".
|
||||
grant_types: Vec<String>,
|
||||
/// How the client intends to authenticate its requests. Can be "none",
|
||||
/// meaning that the client will negotiate its token with the
|
||||
/// "authorization code" flow.
|
||||
token_endpoint_auth_method: String,
|
||||
/// Link to the logo.
|
||||
logo_uri: Option<Url>,
|
||||
/// Link to the client's policy.
|
||||
policy_uri: Option<Url>,
|
||||
/// Link to the terms of service.
|
||||
tos_uri: Option<Url>,
|
||||
/// Can be "native", implying localhost or reserved redirect pages.
|
||||
/// Defaults to "web" if not present.
|
||||
application_type: Option<String>,
|
||||
}
|
||||
|
||||
/// A successful response that the client was registered.
|
||||
#[derive(serde::Serialize, Debug)]
|
||||
pub(crate) struct ClientResponse {
|
||||
client_id: String,
|
||||
/// If the client is private, the secret it authenticates itself with.
|
||||
client_secret: Option<String>,
|
||||
/// If there's a `client_secret`, its expiration date in seconds since
|
||||
/// 1970-01-01T00:00. Some(0) means no expiration date.
|
||||
client_secret_expires_at: Option<u32>,
|
||||
client_name: String,
|
||||
/// Points to the "about" page of the client.
|
||||
client_uri: Url,
|
||||
logo_uri: Option<Url>,
|
||||
tos_uri: Option<Url>,
|
||||
policy_uri: Option<Url>,
|
||||
/// Registered redirect uris, which will be matched against when
|
||||
/// authenticating. If a localhost address, must contain instances of
|
||||
/// oxide-auth's `RegisteredUrl::IgnorePortOnLocalhost` to let
|
||||
/// authorization flow through any port over localhost.
|
||||
redirect_uris: Vec<Url>,
|
||||
token_endpoint_auth_method: String,
|
||||
response_types: Vec<String>,
|
||||
grant_types: Vec<String>,
|
||||
application_type: Option<String>,
|
||||
}
|
||||
|
||||
/// # `GET /_matrix/client/unstable/org.matrix.msc2964/device/register`
|
||||
///
|
||||
/// Register a client, as specified in [MSC2966]. This client, "device" in OIDC
|
||||
/// parlance, will have the right to submit [super::authorize::authorize]
|
||||
/// requests.
|
||||
///
|
||||
/// [MSC2966]: https://github.com/matrix-org/matrix-spec-proposals/pull/2966
|
||||
pub(crate) async fn register_client(
|
||||
State(services): State<crate::State>,
|
||||
Json(client): Json<ClientQuery>,
|
||||
) -> Result<Json<ClientResponse>> {
|
||||
tracing::trace!("processing OIDC device register request for client: {client:#?}");
|
||||
if client.redirect_uris.is_empty() {
|
||||
return Err(err!(Request(Unknown(
|
||||
"the client's registration request should contain at least a redirect_uri"
|
||||
))));
|
||||
}
|
||||
let mut redirect_uris = client.redirect_uris.clone();
|
||||
let redirect_uri = redirect_uris.pop().expect("at least one redirect_uri");
|
||||
let redirect_uri = normalize_redirect(redirect_uri);
|
||||
let remaining_uris = redirect_uris.into_iter().map(normalize_redirect).collect();
|
||||
let device_id = DeviceId::new();
|
||||
// Only provide a default scope, we'll test the client's proposed scope for
|
||||
// consent anyway.
|
||||
let scope = "default".parse().unwrap();
|
||||
// TODO check if the users service needs an update.
|
||||
//services.users.update_device_metadata();
|
||||
|
||||
// If the client cannot authenticate itself at the token endpoint, then
|
||||
// it's a public client. This is usually the case in Matrix.
|
||||
let is_private = client.token_endpoint_auth_method != "none";
|
||||
let client_secret = match is_private {
|
||||
| true => {
|
||||
let secret = ClientSecret::new();
|
||||
identifiers_validation::client_secret::validate(secret.as_str())?;
|
||||
Some(secret.to_string())
|
||||
},
|
||||
| false => None,
|
||||
};
|
||||
let registration = match is_private {
|
||||
| true => &Client::confidential(
|
||||
device_id.as_ref(),
|
||||
redirect_uri,
|
||||
scope,
|
||||
client_secret.as_ref().unwrap().as_bytes(),
|
||||
)
|
||||
.with_additional_redirect_uris(remaining_uris),
|
||||
| _ => &Client::public(device_id.as_ref(), redirect_uri, scope)
|
||||
.with_additional_redirect_uris(remaining_uris),
|
||||
};
|
||||
tracing::trace!("registering OIDC device : {registration:#?}");
|
||||
services
|
||||
.oidc
|
||||
.register_client(Some(client.client_name.clone()), registration);
|
||||
|
||||
let client_response = ClientResponse {
|
||||
client_id: device_id.to_string(),
|
||||
client_secret,
|
||||
client_secret_expires_at: if is_private { Some(0) } else { None },
|
||||
client_name: client.client_name.clone(),
|
||||
client_uri: client.client_uri.clone(),
|
||||
redirect_uris: client.redirect_uris.clone(),
|
||||
logo_uri: client.logo_uri.clone(),
|
||||
policy_uri: client.policy_uri.clone(),
|
||||
tos_uri: client.tos_uri.clone(),
|
||||
token_endpoint_auth_method: client.token_endpoint_auth_method.clone(),
|
||||
response_types: client.response_types.clone(),
|
||||
grant_types: client.grant_types.clone(),
|
||||
application_type: client.application_type,
|
||||
};
|
||||
tracing::debug!("OIDC device registered : {client_response:#?}");
|
||||
|
||||
Ok(Json(client_response))
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use axum::extract::State;
|
||||
use conduwuit::{Result, err};
|
||||
use conduwuit_web::oidc::{OidcRequest, OidcResponse};
|
||||
use oxide_auth::endpoint::QueryParameter;
|
||||
|
||||
/// # `POST /_matrix/client/unstable/org.matrix.msc2964/token`
|
||||
///
|
||||
/// Depending on `grant_type`, either deliver a new token to a device, and store
|
||||
/// it in the server's ring, or refresh the token.
|
||||
pub(crate) async fn token(
|
||||
State(services): State<crate::State>,
|
||||
oauth: OidcRequest,
|
||||
) -> Result<OidcResponse> {
|
||||
tracing::trace!("processing OpenID token request {:#?}", oauth);
|
||||
let Some(body) = oauth.body() else {
|
||||
return Err(err!(Request(Unknown("OAuth request had an empty body"))));
|
||||
};
|
||||
let grant_type = body
|
||||
.unique_value("grant_type")
|
||||
.map(|value| value.to_string());
|
||||
let endpoint = services.oidc.endpoint();
|
||||
tracing::debug!("submitting OpenID token request for grant type {grant_type:?}");
|
||||
|
||||
match grant_type.as_deref() {
|
||||
| Some("authorization_code") => endpoint
|
||||
.access_token_flow()
|
||||
.execute(oauth)
|
||||
.map_err(|err| err!(Request(Unknown("token grant failed: {err:?}")))),
|
||||
| Some("refresh_token") => endpoint
|
||||
.refresh_flow()
|
||||
.execute(oauth)
|
||||
.map_err(|err| err!(Request(Unknown("token refresh failed: {err:?}")))),
|
||||
| other => Err(err!(Request(Unknown("unsupported grant type: {other:?}")))),
|
||||
}
|
||||
}
|
||||
+33
-91
@@ -1,123 +1,65 @@
|
||||
mod v3;
|
||||
mod v4;
|
||||
mod v5;
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use conduwuit::{
|
||||
Event, PduCount, Result, err,
|
||||
Error, PduCount, Result,
|
||||
matrix::pdu::PduEvent,
|
||||
ref_at, trace,
|
||||
utils::stream::{BroadbandExt, ReadyExt, TryIgnore},
|
||||
};
|
||||
use conduwuit_service::Services;
|
||||
use futures::StreamExt;
|
||||
use futures::{StreamExt, pin_mut};
|
||||
use ruma::{
|
||||
OwnedUserId, RoomId, UserId,
|
||||
RoomId, UserId,
|
||||
events::TimelineEventType::{
|
||||
self, Beacon, CallInvite, PollStart, RoomEncrypted, RoomMessage, Sticker,
|
||||
},
|
||||
};
|
||||
|
||||
pub(crate) use self::{v3::sync_events_route, v5::sync_events_v5_route};
|
||||
pub(crate) use self::{
|
||||
v3::sync_events_route, v4::sync_events_v4_route, v5::sync_events_v5_route,
|
||||
};
|
||||
|
||||
pub(crate) const DEFAULT_BUMP_TYPES: &[TimelineEventType; 6] =
|
||||
&[CallInvite, PollStart, Beacon, RoomEncrypted, RoomMessage, Sticker];
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TimelinePdus {
|
||||
pub pdus: VecDeque<(PduCount, PduEvent)>,
|
||||
pub limited: bool,
|
||||
}
|
||||
|
||||
impl TimelinePdus {
|
||||
fn senders(&self) -> impl Iterator<Item = OwnedUserId> {
|
||||
self.pdus
|
||||
.iter()
|
||||
.map(ref_at!(1))
|
||||
.map(Event::sender)
|
||||
.map(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load up to `limit` PDUs in the range (starting_count, ending_count].
|
||||
async fn load_timeline(
|
||||
services: &Services,
|
||||
sender_user: &UserId,
|
||||
room_id: &RoomId,
|
||||
starting_count: Option<PduCount>,
|
||||
ending_count: Option<PduCount>,
|
||||
roomsincecount: PduCount,
|
||||
next_batch: Option<PduCount>,
|
||||
limit: usize,
|
||||
) -> Result<TimelinePdus> {
|
||||
let mut pdu_stream = match starting_count {
|
||||
| Some(starting_count) => {
|
||||
let last_timeline_count = services
|
||||
.rooms
|
||||
.timeline
|
||||
.last_timeline_count(Some(sender_user), room_id)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
err!(Database(warn!("Failed to fetch end of room timeline: {}", err)))
|
||||
})?;
|
||||
) -> Result<(Vec<(PduCount, PduEvent)>, bool), Error> {
|
||||
let last_timeline_count = services
|
||||
.rooms
|
||||
.timeline
|
||||
.last_timeline_count(Some(sender_user), room_id)
|
||||
.await?;
|
||||
|
||||
if last_timeline_count <= starting_count {
|
||||
// no messages have been sent in this room since `starting_count`
|
||||
return Ok(TimelinePdus::default());
|
||||
}
|
||||
if last_timeline_count <= roomsincecount {
|
||||
return Ok((Vec::new(), false));
|
||||
}
|
||||
|
||||
// for incremental sync, stream from the DB all PDUs which were sent after
|
||||
// `starting_count` but before `ending_count`, including `ending_count` but
|
||||
// not `starting_count`. this code is pretty similar to the initial sync
|
||||
// branch, they're separate to allow for future optimization
|
||||
services
|
||||
.rooms
|
||||
.timeline
|
||||
.pdus_rev(
|
||||
Some(sender_user),
|
||||
room_id,
|
||||
ending_count.map(|count| count.saturating_add(1)),
|
||||
)
|
||||
.ignore_err()
|
||||
.ready_take_while(move |&(pducount, _)| pducount > starting_count)
|
||||
.boxed()
|
||||
},
|
||||
| None => {
|
||||
// For initial sync, stream from the DB all PDUs before and including
|
||||
// `ending_count` in reverse order
|
||||
services
|
||||
.rooms
|
||||
.timeline
|
||||
.pdus_rev(
|
||||
Some(sender_user),
|
||||
room_id,
|
||||
ending_count.map(|count| count.saturating_add(1)),
|
||||
)
|
||||
.ignore_err()
|
||||
.boxed()
|
||||
},
|
||||
};
|
||||
let non_timeline_pdus = services
|
||||
.rooms
|
||||
.timeline
|
||||
.pdus_rev(Some(sender_user), room_id, None)
|
||||
.ignore_err()
|
||||
.ready_skip_while(|&(pducount, _)| pducount > next_batch.unwrap_or_else(PduCount::max))
|
||||
.ready_take_while(|&(pducount, _)| pducount > roomsincecount);
|
||||
|
||||
// Return at most `limit` PDUs from the stream
|
||||
let pdus = pdu_stream
|
||||
.by_ref()
|
||||
.take(limit)
|
||||
.ready_fold(VecDeque::with_capacity(limit), |mut pdus, item| {
|
||||
pdus.push_front(item);
|
||||
pdus
|
||||
})
|
||||
.await;
|
||||
// Take the last events for the timeline
|
||||
pin_mut!(non_timeline_pdus);
|
||||
let timeline_pdus: Vec<_> = non_timeline_pdus.by_ref().take(limit).collect().await;
|
||||
|
||||
// The timeline is limited if there are still more PDUs in the stream
|
||||
let limited = pdu_stream.next().await.is_some();
|
||||
let timeline_pdus: Vec<_> = timeline_pdus.into_iter().rev().collect();
|
||||
|
||||
trace!(
|
||||
"syncing {:?} timeline pdus from {:?} to {:?} (limited = {:?})",
|
||||
pdus.len(),
|
||||
starting_count,
|
||||
ending_count,
|
||||
limited,
|
||||
);
|
||||
// They /sync response doesn't always return all messages, so we say the output
|
||||
// is limited unless there are events in non_timeline_pdus
|
||||
let limited = non_timeline_pdus.next().await.is_some();
|
||||
|
||||
Ok(TimelinePdus { pdus, limited })
|
||||
Ok((timeline_pdus, limited))
|
||||
}
|
||||
|
||||
async fn share_encrypted_room(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,852 +0,0 @@
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use conduwuit::{
|
||||
Result, at, debug_warn, err, extract_variant,
|
||||
matrix::{
|
||||
Event,
|
||||
pdu::{PduCount, PduEvent},
|
||||
},
|
||||
trace,
|
||||
utils::{
|
||||
BoolExt, IterStream, ReadyExt, TryFutureExtExt,
|
||||
math::ruma_from_u64,
|
||||
stream::{TryIgnore, WidebandExt},
|
||||
},
|
||||
warn,
|
||||
};
|
||||
use conduwuit_service::Services;
|
||||
use futures::{
|
||||
FutureExt, StreamExt, TryFutureExt,
|
||||
future::{OptionFuture, join, join3, join4, try_join, try_join3},
|
||||
};
|
||||
use ruma::{
|
||||
OwnedRoomId, OwnedUserId, RoomId, UserId,
|
||||
api::client::sync::sync_events::{
|
||||
UnreadNotificationsCount,
|
||||
v3::{Ephemeral, JoinedRoom, RoomAccountData, RoomSummary, State as RoomState, Timeline},
|
||||
},
|
||||
events::{
|
||||
AnyRawAccountDataEvent, StateEventType,
|
||||
TimelineEventType::*,
|
||||
room::member::{MembershipState, RoomMemberEventContent},
|
||||
},
|
||||
serde::Raw,
|
||||
uint,
|
||||
};
|
||||
use service::rooms::short::ShortStateHash;
|
||||
|
||||
use super::{load_timeline, share_encrypted_room};
|
||||
use crate::client::{
|
||||
TimelinePdus, ignored_filter,
|
||||
sync::v3::{
|
||||
DEFAULT_TIMELINE_LIMIT, DeviceListUpdates, SyncContext, prepare_lazily_loaded_members,
|
||||
state::{build_state_incremental, build_state_initial},
|
||||
},
|
||||
};
|
||||
|
||||
/// Generate the sync response for a room the user is joined to.
|
||||
#[tracing::instrument(
|
||||
name = "joined",
|
||||
level = "debug",
|
||||
skip_all,
|
||||
fields(
|
||||
room_id = ?room_id,
|
||||
syncing_user = ?sync_context.syncing_user,
|
||||
),
|
||||
)]
|
||||
pub(super) async fn load_joined_room(
|
||||
services: &Services,
|
||||
sync_context: SyncContext<'_>,
|
||||
ref room_id: OwnedRoomId,
|
||||
) -> Result<(JoinedRoom, DeviceListUpdates)> {
|
||||
/*
|
||||
Building a sync response involves many steps which all depend on each other.
|
||||
To parallelize the process as much as possible, each step is divided into its own function,
|
||||
and `join*` functions are used to perform steps in parallel which do not depend on each other.
|
||||
*/
|
||||
|
||||
let (
|
||||
account_data,
|
||||
ephemeral,
|
||||
StateAndTimeline {
|
||||
state_events,
|
||||
timeline,
|
||||
summary,
|
||||
notification_counts,
|
||||
device_list_updates,
|
||||
},
|
||||
) = try_join3(
|
||||
build_account_data(services, sync_context, room_id),
|
||||
build_ephemeral(services, sync_context, room_id),
|
||||
build_state_and_timeline(services, sync_context, room_id),
|
||||
)
|
||||
.boxed()
|
||||
.await?;
|
||||
|
||||
if !timeline.is_empty() || !state_events.is_empty() {
|
||||
trace!(
|
||||
"syncing {} timeline events (limited = {}) and {} state events",
|
||||
timeline.events.len(),
|
||||
timeline.limited,
|
||||
state_events.len()
|
||||
);
|
||||
}
|
||||
|
||||
let joined_room = JoinedRoom {
|
||||
account_data,
|
||||
summary: summary.unwrap_or_default(),
|
||||
unread_notifications: notification_counts.unwrap_or_default(),
|
||||
timeline,
|
||||
state: RoomState {
|
||||
events: state_events.into_iter().map(Event::into_format).collect(),
|
||||
},
|
||||
ephemeral,
|
||||
unread_thread_notifications: BTreeMap::new(),
|
||||
};
|
||||
|
||||
Ok((joined_room, device_list_updates))
|
||||
}
|
||||
|
||||
/// Collect changes to the syncing user's account data events.
|
||||
#[tracing::instrument(level = "debug", skip_all)]
|
||||
async fn build_account_data(
|
||||
services: &Services,
|
||||
SyncContext {
|
||||
syncing_user,
|
||||
last_sync_end_count,
|
||||
current_count,
|
||||
..
|
||||
}: SyncContext<'_>,
|
||||
room_id: &RoomId,
|
||||
) -> Result<RoomAccountData> {
|
||||
let account_data_changes = services
|
||||
.account_data
|
||||
.changes_since(Some(room_id), syncing_user, last_sync_end_count, Some(current_count))
|
||||
.ready_filter_map(|e| extract_variant!(e, AnyRawAccountDataEvent::Room))
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
Ok(RoomAccountData { events: account_data_changes })
|
||||
}
|
||||
|
||||
/// Collect new ephemeral events.
|
||||
#[tracing::instrument(level = "debug", skip_all)]
|
||||
async fn build_ephemeral(
|
||||
services: &Services,
|
||||
SyncContext { syncing_user, last_sync_end_count, .. }: SyncContext<'_>,
|
||||
room_id: &RoomId,
|
||||
) -> Result<Ephemeral> {
|
||||
// note: some of the futures below are boxed. this is because, without the box,
|
||||
// rustc produces over thirty inscrutable errors in `mod.rs` at the call-site
|
||||
// of `load_joined_room`. I don't know why boxing them fixes this -- it seems
|
||||
// to be related to the async closures and borrowing from the sync context.
|
||||
|
||||
// collect updates to read receipts
|
||||
let receipt_events = services
|
||||
.rooms
|
||||
.read_receipt
|
||||
.readreceipts_since(room_id, last_sync_end_count)
|
||||
.filter_map(async |(read_user, _, edu)| {
|
||||
let is_ignored = services
|
||||
.users
|
||||
.user_is_ignored(&read_user, syncing_user)
|
||||
.await;
|
||||
|
||||
// filter out read receipts for ignored users
|
||||
is_ignored.or_some(edu)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.boxed();
|
||||
|
||||
// collect the updated list of typing users, if it's changed
|
||||
let typing_event = async {
|
||||
let should_send_typing_event = match last_sync_end_count {
|
||||
| Some(last_sync_end_count) => {
|
||||
match services.rooms.typing.last_typing_update(room_id).await {
|
||||
| Ok(last_typing_update) => {
|
||||
// update the typing list if the users typing have changed since the last
|
||||
// sync
|
||||
last_typing_update > last_sync_end_count
|
||||
},
|
||||
| Err(err) => {
|
||||
warn!("Error checking last typing update: {}", err);
|
||||
return None;
|
||||
},
|
||||
}
|
||||
},
|
||||
// always update the typing list on an initial sync
|
||||
| None => true,
|
||||
};
|
||||
|
||||
if should_send_typing_event {
|
||||
let event = services
|
||||
.rooms
|
||||
.typing
|
||||
.typings_event_for_user(room_id, syncing_user)
|
||||
.await;
|
||||
|
||||
if let Ok(event) = event {
|
||||
return Some(
|
||||
Raw::new(&event)
|
||||
.expect("typing event should be valid")
|
||||
.cast(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
};
|
||||
|
||||
// collect the syncing user's private-read marker, if it's changed
|
||||
let private_read_event = async {
|
||||
let should_send_private_read = match last_sync_end_count {
|
||||
| Some(last_sync_end_count) => {
|
||||
let last_privateread_update = services
|
||||
.rooms
|
||||
.read_receipt
|
||||
.last_privateread_update(syncing_user, room_id)
|
||||
.await;
|
||||
|
||||
// update the marker if it's changed since the last sync
|
||||
last_privateread_update > last_sync_end_count
|
||||
},
|
||||
// always update the marker on an initial sync
|
||||
| None => true,
|
||||
};
|
||||
|
||||
if should_send_private_read {
|
||||
services
|
||||
.rooms
|
||||
.read_receipt
|
||||
.private_read_get(room_id, syncing_user)
|
||||
.await
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let (receipt_events, typing_event, private_read_event) =
|
||||
join3(receipt_events, typing_event, private_read_event).await;
|
||||
|
||||
let mut edus = receipt_events;
|
||||
edus.extend(typing_event);
|
||||
edus.extend(private_read_event);
|
||||
|
||||
Ok(Ephemeral { events: edus })
|
||||
}
|
||||
|
||||
/// A struct to hold the state events, timeline, and other data which is
|
||||
/// computed from them.
|
||||
struct StateAndTimeline {
|
||||
state_events: Vec<PduEvent>,
|
||||
timeline: Timeline,
|
||||
summary: Option<RoomSummary>,
|
||||
notification_counts: Option<UnreadNotificationsCount>,
|
||||
device_list_updates: DeviceListUpdates,
|
||||
}
|
||||
|
||||
/// Compute changes to the room's state and timeline.
|
||||
#[tracing::instrument(level = "debug", skip_all)]
|
||||
async fn build_state_and_timeline(
|
||||
services: &Services,
|
||||
sync_context: SyncContext<'_>,
|
||||
room_id: &RoomId,
|
||||
) -> Result<StateAndTimeline> {
|
||||
let (shortstatehashes, timeline) = try_join(
|
||||
fetch_shortstatehashes(services, sync_context, room_id),
|
||||
build_timeline(services, sync_context, room_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (state_events, notification_counts, joined_since_last_sync) = try_join3(
|
||||
build_state_events(services, sync_context, room_id, shortstatehashes, &timeline),
|
||||
build_notification_counts(services, sync_context, room_id, &timeline),
|
||||
check_joined_since_last_sync(services, shortstatehashes, sync_context),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// the timeline should always include at least one PDU if the syncing user
|
||||
// joined since the last sync, that being the syncing user's join event. if
|
||||
// it's empty something is wrong.
|
||||
if joined_since_last_sync && timeline.pdus.is_empty() {
|
||||
warn!("timeline for newly joined room is empty");
|
||||
}
|
||||
|
||||
let (summary, device_list_updates) = try_join(
|
||||
build_room_summary(
|
||||
services,
|
||||
sync_context,
|
||||
room_id,
|
||||
shortstatehashes,
|
||||
&timeline,
|
||||
&state_events,
|
||||
joined_since_last_sync,
|
||||
),
|
||||
build_device_list_updates(
|
||||
services,
|
||||
sync_context,
|
||||
room_id,
|
||||
shortstatehashes,
|
||||
&state_events,
|
||||
joined_since_last_sync,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// the token which may be passed to the messages endpoint to backfill room
|
||||
// history
|
||||
let prev_batch = timeline.pdus.front().map(at!(0));
|
||||
|
||||
// note: we always indicate a limited timeline if the syncing user just joined
|
||||
// the room, to indicate to the client that it should request backfill (and to
|
||||
// copy Synapse's behavior). for federated room joins, the `timeline` will
|
||||
// usually only include the syncing user's join event.
|
||||
let limited = timeline.limited || joined_since_last_sync;
|
||||
|
||||
// filter out ignored events from the timeline and convert the PDUs into Ruma's
|
||||
// AnySyncTimelineEvent type
|
||||
let filtered_timeline = timeline
|
||||
.pdus
|
||||
.into_iter()
|
||||
.stream()
|
||||
.wide_filter_map(|item| ignored_filter(services, item, sync_context.syncing_user))
|
||||
.map(at!(1))
|
||||
.map(Event::into_format)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
Ok(StateAndTimeline {
|
||||
state_events,
|
||||
timeline: Timeline {
|
||||
limited,
|
||||
prev_batch: prev_batch.as_ref().map(ToString::to_string),
|
||||
events: filtered_timeline,
|
||||
},
|
||||
summary,
|
||||
notification_counts,
|
||||
device_list_updates,
|
||||
})
|
||||
}
|
||||
|
||||
/// Shortstatehashes necessary to compute what state events to sync.
|
||||
#[derive(Clone, Copy)]
|
||||
struct ShortStateHashes {
|
||||
/// The current state of the syncing room.
|
||||
current_shortstatehash: ShortStateHash,
|
||||
/// The state of the syncing room at the end of the last sync.
|
||||
last_sync_end_shortstatehash: Option<ShortStateHash>,
|
||||
}
|
||||
|
||||
/// Fetch the current_shortstatehash and last_sync_end_shortstatehash.
|
||||
#[tracing::instrument(level = "debug", skip_all)]
|
||||
async fn fetch_shortstatehashes(
|
||||
services: &Services,
|
||||
SyncContext { last_sync_end_count, current_count, .. }: SyncContext<'_>,
|
||||
room_id: &RoomId,
|
||||
) -> Result<ShortStateHashes> {
|
||||
// the room state currently.
|
||||
// TODO: this should be the room state as of `current_count`, but there's no way
|
||||
// to get that right now.
|
||||
let current_shortstatehash = services
|
||||
.rooms
|
||||
.state
|
||||
.get_room_shortstatehash(room_id)
|
||||
.map_err(|_| err!(Database(error!("Room {room_id} has no state"))));
|
||||
|
||||
// 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.
|
||||
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()
|
||||
}))
|
||||
.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;
|
||||
|
||||
Ok(ShortStateHashes {
|
||||
current_shortstatehash,
|
||||
last_sync_end_shortstatehash,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch recent timeline events.
|
||||
#[tracing::instrument(level = "debug", skip_all)]
|
||||
async fn build_timeline(
|
||||
services: &Services,
|
||||
sync_context: SyncContext<'_>,
|
||||
room_id: &RoomId,
|
||||
) -> Result<TimelinePdus> {
|
||||
let SyncContext {
|
||||
syncing_user,
|
||||
last_sync_end_count,
|
||||
current_count,
|
||||
filter,
|
||||
..
|
||||
} = sync_context;
|
||||
|
||||
/*
|
||||
determine the maximum number of events to return in this sync.
|
||||
if the sync filter specifies a limit, that will be used, otherwise
|
||||
`DEFAULT_TIMELINE_LIMIT` will be used. `DEFAULT_TIMELINE_LIMIT` will also be
|
||||
used if the limit is somehow greater than usize::MAX.
|
||||
*/
|
||||
let timeline_limit = filter
|
||||
.room
|
||||
.timeline
|
||||
.limit
|
||||
.and_then(|limit| limit.try_into().ok())
|
||||
.unwrap_or(DEFAULT_TIMELINE_LIMIT);
|
||||
|
||||
load_timeline(
|
||||
services,
|
||||
syncing_user,
|
||||
room_id,
|
||||
last_sync_end_count.map(PduCount::Normal),
|
||||
Some(PduCount::Normal(current_count)),
|
||||
timeline_limit,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Calculate the state events to sync.
|
||||
async fn build_state_events(
|
||||
services: &Services,
|
||||
sync_context: SyncContext<'_>,
|
||||
room_id: &RoomId,
|
||||
shortstatehashes: ShortStateHashes,
|
||||
timeline: &TimelinePdus,
|
||||
) -> Result<Vec<PduEvent>> {
|
||||
let SyncContext {
|
||||
syncing_user,
|
||||
last_sync_end_count,
|
||||
full_state,
|
||||
..
|
||||
} = sync_context;
|
||||
|
||||
let ShortStateHashes {
|
||||
current_shortstatehash,
|
||||
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
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
// compute the state delta between the previous sync and this sync.
|
||||
match (last_sync_end_count, last_sync_end_shortstatehash) {
|
||||
/*
|
||||
if `last_sync_end_count` is Some (meaning this is an incremental sync), and `last_sync_end_shortstatehash`
|
||||
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 =>
|
||||
build_state_incremental(
|
||||
services,
|
||||
syncing_user,
|
||||
room_id,
|
||||
PduCount::Normal(last_sync_end_count),
|
||||
last_sync_end_shortstatehash,
|
||||
timeline_start_shortstatehash,
|
||||
current_shortstatehash,
|
||||
timeline,
|
||||
lazily_loaded_members.as_ref(),
|
||||
)
|
||||
.boxed()
|
||||
.await,
|
||||
/*
|
||||
otherwise use `build_state_initial`. note that this branch will be taken if the user joined this room since the last sync
|
||||
for the first time ever, because in that case we have no `last_sync_end_shortstatehash` and can't correctly calculate
|
||||
the state using the incremental sync algorithm.
|
||||
*/
|
||||
| _ =>
|
||||
build_state_initial(
|
||||
services,
|
||||
syncing_user,
|
||||
timeline_start_shortstatehash,
|
||||
lazily_loaded_members.as_ref(),
|
||||
)
|
||||
.boxed()
|
||||
.await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the number of unread notifications in this room.
|
||||
#[tracing::instrument(level = "debug", skip_all)]
|
||||
async fn build_notification_counts(
|
||||
services: &Services,
|
||||
SyncContext { syncing_user, last_sync_end_count, .. }: SyncContext<'_>,
|
||||
room_id: &RoomId,
|
||||
timeline: &TimelinePdus,
|
||||
) -> Result<Option<UnreadNotificationsCount>> {
|
||||
// determine whether to actually update the notification counts
|
||||
let should_send_notification_counts = async {
|
||||
// if we're going to sync some timeline events, the notification count has
|
||||
// definitely changed to include them
|
||||
if !timeline.pdus.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// if this is an initial sync, we need to send notification counts because the
|
||||
// client doesn't know what they are yet
|
||||
let Some(last_sync_end_count) = last_sync_end_count else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let last_notification_read = services
|
||||
.rooms
|
||||
.user
|
||||
.last_notification_read(syncing_user, room_id)
|
||||
.await;
|
||||
|
||||
// if the syncing user has read the events we sent during the last sync, we need
|
||||
// to send a new notification count on this sync.
|
||||
if last_notification_read > last_sync_end_count {
|
||||
return true;
|
||||
}
|
||||
|
||||
// otherwise, nothing's changed.
|
||||
false
|
||||
};
|
||||
|
||||
if should_send_notification_counts.await {
|
||||
let (notification_count, highlight_count) = join(
|
||||
services
|
||||
.rooms
|
||||
.user
|
||||
.notification_count(syncing_user, room_id)
|
||||
.map(TryInto::try_into)
|
||||
.unwrap_or(uint!(0)),
|
||||
services
|
||||
.rooms
|
||||
.user
|
||||
.highlight_count(syncing_user, room_id)
|
||||
.map(TryInto::try_into)
|
||||
.unwrap_or(uint!(0)),
|
||||
)
|
||||
.await;
|
||||
|
||||
trace!(?notification_count, ?highlight_count, "syncing new notification counts");
|
||||
|
||||
Ok(Some(UnreadNotificationsCount {
|
||||
notification_count: Some(notification_count),
|
||||
highlight_count: Some(highlight_count),
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the syncing user joined the room since their last incremental sync.
|
||||
#[tracing::instrument(level = "debug", skip_all)]
|
||||
async fn check_joined_since_last_sync(
|
||||
services: &Services,
|
||||
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,
|
||||
};
|
||||
|
||||
// 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.
|
||||
// the root cause of this needs to be addressed
|
||||
let joined_since_last_sync =
|
||||
membership_during_previous_sync.is_none_or(|content: RoomMemberEventContent| {
|
||||
content.membership != MembershipState::Join
|
||||
});
|
||||
|
||||
if joined_since_last_sync {
|
||||
trace!("user joined since last sync");
|
||||
}
|
||||
|
||||
Ok(joined_since_last_sync)
|
||||
}
|
||||
|
||||
/// Build the `summary` field of the room object, which includes
|
||||
/// the number of joined and invited users and the room's heroes.
|
||||
#[tracing::instrument(level = "debug", skip_all)]
|
||||
async fn build_room_summary(
|
||||
services: &Services,
|
||||
SyncContext { syncing_user, .. }: SyncContext<'_>,
|
||||
room_id: &RoomId,
|
||||
ShortStateHashes { current_shortstatehash, .. }: ShortStateHashes,
|
||||
timeline: &TimelinePdus,
|
||||
state_events: &[PduEvent],
|
||||
joined_since_last_sync: bool,
|
||||
) -> Result<Option<RoomSummary>> {
|
||||
// determine whether any events in the state or timeline are membership events.
|
||||
let are_syncing_membership_events = timeline
|
||||
.pdus
|
||||
.iter()
|
||||
.map(|(_, pdu)| pdu)
|
||||
.chain(state_events.iter())
|
||||
.any(|event| event.kind == RoomMember);
|
||||
|
||||
/*
|
||||
we only need to send an updated room summary if:
|
||||
1. there are membership events in the state or timeline, because they might have changed the
|
||||
membership counts or heroes, or
|
||||
2. the syncing user just joined this room, which usually implies #1 because their join event should be in the timeline.
|
||||
*/
|
||||
if !(are_syncing_membership_events || joined_since_last_sync) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let joined_member_count = services
|
||||
.rooms
|
||||
.state_cache
|
||||
.room_joined_count(room_id)
|
||||
.unwrap_or(0);
|
||||
|
||||
let invited_member_count = services
|
||||
.rooms
|
||||
.state_cache
|
||||
.room_invited_count(room_id)
|
||||
.unwrap_or(0);
|
||||
|
||||
let has_name = services
|
||||
.rooms
|
||||
.state_accessor
|
||||
.state_contains_type(current_shortstatehash, &StateEventType::RoomName);
|
||||
|
||||
let has_canonical_alias = services
|
||||
.rooms
|
||||
.state_accessor
|
||||
.state_contains_type(current_shortstatehash, &StateEventType::RoomCanonicalAlias);
|
||||
|
||||
let (joined_member_count, invited_member_count, has_name, has_canonical_alias) =
|
||||
join4(joined_member_count, invited_member_count, has_name, has_canonical_alias).await;
|
||||
|
||||
// only send heroes if the room has neither a name nor a canonical alias
|
||||
let heroes = if !(has_name || has_canonical_alias) {
|
||||
Some(build_heroes(services, room_id, syncing_user, current_shortstatehash).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
trace!(
|
||||
?joined_member_count,
|
||||
?invited_member_count,
|
||||
heroes_length = heroes.as_ref().map(HashSet::len),
|
||||
"syncing updated summary"
|
||||
);
|
||||
|
||||
Ok(Some(RoomSummary {
|
||||
heroes: heroes
|
||||
.map(|heroes| heroes.into_iter().collect())
|
||||
.unwrap_or_default(),
|
||||
joined_member_count: Some(ruma_from_u64(joined_member_count)),
|
||||
invited_member_count: Some(ruma_from_u64(invited_member_count)),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Fetch the user IDs to include in the `m.heroes` property of the room
|
||||
/// summary.
|
||||
async fn build_heroes(
|
||||
services: &Services,
|
||||
room_id: &RoomId,
|
||||
syncing_user: &UserId,
|
||||
current_shortstatehash: ShortStateHash,
|
||||
) -> HashSet<OwnedUserId> {
|
||||
const MAX_HERO_COUNT: usize = 5;
|
||||
|
||||
// fetch joined members from the state cache first
|
||||
let joined_members_stream = services
|
||||
.rooms
|
||||
.state_cache
|
||||
.room_members(room_id)
|
||||
.map(ToOwned::to_owned);
|
||||
|
||||
// then fetch invited members
|
||||
let invited_members_stream = services
|
||||
.rooms
|
||||
.state_cache
|
||||
.room_members_invited(room_id)
|
||||
.map(ToOwned::to_owned);
|
||||
|
||||
// then as a last resort fetch every membership event
|
||||
let all_members_stream = services
|
||||
.rooms
|
||||
.short
|
||||
.multi_get_statekey_from_short(
|
||||
services
|
||||
.rooms
|
||||
.state_accessor
|
||||
.state_full_shortids(current_shortstatehash)
|
||||
.ignore_err()
|
||||
.ready_filter_map(|(key, _)| Some(key)),
|
||||
)
|
||||
.ignore_err()
|
||||
.ready_filter_map(|(event_type, state_key)| {
|
||||
if event_type == StateEventType::RoomMember {
|
||||
state_key.to_string().try_into().ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
joined_members_stream
|
||||
.chain(invited_members_stream)
|
||||
.chain(all_members_stream)
|
||||
// the hero list should never include the syncing user
|
||||
.ready_filter(|user_id| user_id != syncing_user)
|
||||
.take(MAX_HERO_COUNT)
|
||||
.collect()
|
||||
.await
|
||||
}
|
||||
|
||||
/// Collect updates to users' device lists for E2EE.
|
||||
#[tracing::instrument(level = "debug", skip_all)]
|
||||
async fn build_device_list_updates(
|
||||
services: &Services,
|
||||
SyncContext {
|
||||
syncing_user,
|
||||
last_sync_end_count,
|
||||
current_count,
|
||||
..
|
||||
}: SyncContext<'_>,
|
||||
room_id: &RoomId,
|
||||
ShortStateHashes { current_shortstatehash, .. }: ShortStateHashes,
|
||||
state_events: &Vec<PduEvent>,
|
||||
joined_since_last_sync: bool,
|
||||
) -> Result<DeviceListUpdates> {
|
||||
let is_encrypted_room = services
|
||||
.rooms
|
||||
.state_accessor
|
||||
.state_get(current_shortstatehash, &StateEventType::RoomEncryption, "")
|
||||
.is_ok();
|
||||
|
||||
// initial syncs don't include device updates, and rooms which aren't encrypted
|
||||
// don't affect them, so return early in either of those cases
|
||||
if last_sync_end_count.is_none() || !(is_encrypted_room.await) {
|
||||
return Ok(DeviceListUpdates::new());
|
||||
}
|
||||
|
||||
let mut device_list_updates = DeviceListUpdates::new();
|
||||
|
||||
// add users with changed keys to the `changed` list
|
||||
services
|
||||
.users
|
||||
.room_keys_changed(room_id, last_sync_end_count, Some(current_count))
|
||||
.map(at!(0))
|
||||
.map(ToOwned::to_owned)
|
||||
.ready_for_each(|user_id| {
|
||||
device_list_updates.changed.insert(user_id);
|
||||
})
|
||||
.await;
|
||||
|
||||
// add users who now share encrypted rooms to `changed` and
|
||||
// users who no longer share encrypted rooms to `left`
|
||||
for state_event in state_events {
|
||||
if state_event.kind == RoomMember {
|
||||
let Some(content): Option<RoomMemberEventContent> = state_event.get_content().ok()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(user_id): Option<OwnedUserId> = state_event
|
||||
.state_key
|
||||
.as_ref()
|
||||
.and_then(|key| key.parse().ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
{
|
||||
use MembershipState::*;
|
||||
|
||||
if matches!(content.membership, Leave | Join) {
|
||||
let shares_encrypted_room =
|
||||
share_encrypted_room(services, syncing_user, &user_id, Some(room_id))
|
||||
.await;
|
||||
match content.membership {
|
||||
| Leave if !shares_encrypted_room => {
|
||||
device_list_updates.left.insert(user_id);
|
||||
},
|
||||
| Join if joined_since_last_sync || shares_encrypted_room => {
|
||||
device_list_updates.changed.insert(user_id);
|
||||
},
|
||||
| _ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !device_list_updates.is_empty() {
|
||||
trace!(
|
||||
changed = device_list_updates.changed.len(),
|
||||
left = device_list_updates.left.len(),
|
||||
"syncing device list updates"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(device_list_updates)
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
use conduwuit::{
|
||||
Event, PduCount, PduEvent, Result, at, debug_warn,
|
||||
pdu::EventHash,
|
||||
trace,
|
||||
utils::{self, IterStream, future::ReadyEqExt, stream::WidebandExt as _},
|
||||
};
|
||||
use futures::{StreamExt, future::join};
|
||||
use ruma::{
|
||||
EventId, OwnedRoomId, RoomId,
|
||||
api::client::sync::sync_events::v3::{LeftRoom, RoomAccountData, State, Timeline},
|
||||
events::{StateEventType, TimelineEventType},
|
||||
uint,
|
||||
};
|
||||
use serde_json::value::RawValue;
|
||||
use service::{Services, rooms::short::ShortStateHash};
|
||||
|
||||
use crate::client::{
|
||||
TimelinePdus, ignored_filter,
|
||||
sync::{
|
||||
load_timeline,
|
||||
v3::{
|
||||
DEFAULT_TIMELINE_LIMIT, SyncContext, prepare_lazily_loaded_members,
|
||||
state::build_state_initial,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "left",
|
||||
level = "debug",
|
||||
skip_all,
|
||||
fields(
|
||||
room_id = %room_id,
|
||||
),
|
||||
)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn load_left_room(
|
||||
services: &Services,
|
||||
sync_context: SyncContext<'_>,
|
||||
ref room_id: OwnedRoomId,
|
||||
leave_membership_event: Option<PduEvent>,
|
||||
) -> Result<Option<LeftRoom>> {
|
||||
let SyncContext {
|
||||
syncing_user,
|
||||
last_sync_end_count,
|
||||
current_count,
|
||||
filter,
|
||||
..
|
||||
} = sync_context;
|
||||
|
||||
// the global count as of the moment the user left the room
|
||||
let Some(left_count) = services
|
||||
.rooms
|
||||
.state_cache
|
||||
.get_left_count(room_id, syncing_user)
|
||||
.await
|
||||
.ok()
|
||||
else {
|
||||
// if we get here, the membership cache is incorrect, likely due to a state
|
||||
// reset
|
||||
debug_warn!("attempting to sync left room but no left count exists");
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// return early if we haven't gotten to this leave yet.
|
||||
// this can happen if the user leaves while a sync response is being generated
|
||||
if current_count < left_count {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// return early if this is an incremental sync, and we've already synced this
|
||||
// leave to the user, and `include_leave` isn't set on the filter.
|
||||
if !filter.room.include_leave && last_sync_end_count >= Some(left_count) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Some(ref leave_membership_event) = leave_membership_event {
|
||||
debug_assert_eq!(
|
||||
leave_membership_event.kind,
|
||||
TimelineEventType::RoomMember,
|
||||
"leave PDU should be m.room.member"
|
||||
);
|
||||
}
|
||||
|
||||
let does_not_exist = services.rooms.metadata.exists(room_id).eq(&false).await;
|
||||
|
||||
let (timeline, state_events) = match leave_membership_event {
|
||||
| Some(leave_membership_event) if does_not_exist => {
|
||||
/*
|
||||
we have none PDUs with left beef for this room, likely because it was a rejected invite to a room
|
||||
which nobody on this homeserver is in. `leave_pdu` is the remote-assisted outlier leave event for the room,
|
||||
which is all we can send to the client.
|
||||
|
||||
if this is an initial sync, don't include this room at all to keep the client from asking for
|
||||
state that we don't have.
|
||||
*/
|
||||
|
||||
if last_sync_end_count.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
trace!("syncing remote-assisted leave PDU");
|
||||
(TimelinePdus::default(), vec![leave_membership_event])
|
||||
},
|
||||
| Some(leave_membership_event) => {
|
||||
// we have this room in our DB, and can fetch the state and timeline from when
|
||||
// the user left.
|
||||
|
||||
let leave_state_key = syncing_user;
|
||||
debug_assert_eq!(
|
||||
Some(leave_state_key.as_str()),
|
||||
leave_membership_event.state_key(),
|
||||
"leave PDU should be for the user requesting the sync"
|
||||
);
|
||||
|
||||
// the shortstatehash of the state _immediately before_ the syncing user left
|
||||
// this room. the state represented here _does not_ include
|
||||
// `leave_membership_event`.
|
||||
let leave_shortstatehash = services
|
||||
.rooms
|
||||
.state_accessor
|
||||
.pdu_shortstatehash(&leave_membership_event.event_id)
|
||||
.await?;
|
||||
|
||||
let prev_membership_event = services
|
||||
.rooms
|
||||
.state_accessor
|
||||
.state_get(
|
||||
leave_shortstatehash,
|
||||
&StateEventType::RoomMember,
|
||||
leave_state_key.as_str(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
build_left_state_and_timeline(
|
||||
services,
|
||||
sync_context,
|
||||
room_id,
|
||||
leave_membership_event,
|
||||
leave_shortstatehash,
|
||||
prev_membership_event,
|
||||
)
|
||||
.await?
|
||||
},
|
||||
| None => {
|
||||
/*
|
||||
no leave event was actually sent in this room, but we still need to pretend
|
||||
like the user left it. this is usually because the room was banned by a server admin.
|
||||
|
||||
if this is an incremental sync, generate a fake leave event to make the room vanish from clients.
|
||||
otherwise we don't tell the client about this room at all.
|
||||
*/
|
||||
if last_sync_end_count.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
trace!("syncing dummy leave event");
|
||||
(TimelinePdus::default(), vec![create_dummy_leave_event(
|
||||
services,
|
||||
sync_context,
|
||||
room_id,
|
||||
)])
|
||||
},
|
||||
};
|
||||
|
||||
let raw_timeline_pdus = timeline
|
||||
.pdus
|
||||
.into_iter()
|
||||
.stream()
|
||||
// filter out ignored events from the timeline
|
||||
.wide_filter_map(|item| ignored_filter(services, item, syncing_user))
|
||||
.map(at!(1))
|
||||
.map(Event::into_format)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
Ok(Some(LeftRoom {
|
||||
account_data: RoomAccountData { events: Vec::new() },
|
||||
timeline: Timeline {
|
||||
limited: timeline.limited,
|
||||
prev_batch: Some(current_count.to_string()),
|
||||
events: raw_timeline_pdus,
|
||||
},
|
||||
state: State {
|
||||
events: state_events.into_iter().map(Event::into_format).collect(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async fn build_left_state_and_timeline(
|
||||
services: &Services,
|
||||
sync_context: SyncContext<'_>,
|
||||
room_id: &RoomId,
|
||||
leave_membership_event: PduEvent,
|
||||
leave_shortstatehash: ShortStateHash,
|
||||
prev_membership_event: PduEvent,
|
||||
) -> Result<(TimelinePdus, Vec<PduEvent>)> {
|
||||
let SyncContext {
|
||||
syncing_user,
|
||||
last_sync_end_count,
|
||||
filter,
|
||||
..
|
||||
} = sync_context;
|
||||
|
||||
let timeline_start_count = if let Some(last_sync_end_count) = last_sync_end_count {
|
||||
// for incremental syncs, start the timeline after `since`
|
||||
PduCount::Normal(last_sync_end_count)
|
||||
} else {
|
||||
// for initial syncs, start the timeline after the previous membership
|
||||
// event. we don't want to include the membership event itself
|
||||
// because clients get confused when they see a `join`
|
||||
// membership event in a `leave` room.
|
||||
services
|
||||
.rooms
|
||||
.timeline
|
||||
.get_pdu_count(&prev_membership_event.event_id)
|
||||
.await?
|
||||
};
|
||||
|
||||
// end the timeline at the user's leave event
|
||||
let timeline_end_count = services
|
||||
.rooms
|
||||
.timeline
|
||||
.get_pdu_count(leave_membership_event.event_id())
|
||||
.await?;
|
||||
|
||||
// limit the timeline using the same logic as for joined rooms
|
||||
let timeline_limit = filter
|
||||
.room
|
||||
.timeline
|
||||
.limit
|
||||
.and_then(|limit| limit.try_into().ok())
|
||||
.unwrap_or(DEFAULT_TIMELINE_LIMIT);
|
||||
|
||||
let timeline = load_timeline(
|
||||
services,
|
||||
syncing_user,
|
||||
room_id,
|
||||
Some(timeline_start_count),
|
||||
Some(timeline_end_count),
|
||||
timeline_limit,
|
||||
)
|
||||
.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;
|
||||
|
||||
// TODO: calculate incremental state for incremental syncs.
|
||||
// always calculating initial state _works_ but returns more data and does
|
||||
// more processing than strictly necessary.
|
||||
let mut state = build_state_initial(
|
||||
services,
|
||||
syncing_user,
|
||||
timeline_start_shortstatehash,
|
||||
lazily_loaded_members.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
/*
|
||||
remove membership events for the syncing user from state.
|
||||
usually, `state` should include a `join` membership event and `timeline` should include a `leave` one.
|
||||
however, the matrix-js-sdk gets confused when this happens (see [1]) and doesn't process the room leave,
|
||||
so we have to filter out the membership from `state`.
|
||||
|
||||
NOTE: we are sending more information than synapse does in this scenario, because we always
|
||||
calculate `state` for initial syncs, even when the sync being performed is incremental.
|
||||
however, the specification does not forbid sending extraneous events in `state`.
|
||||
|
||||
TODO: there is an additional bug at play here. sometimes `load_joined_room` syncs the `leave` event
|
||||
before `load_left_room` does, which means the `timeline` we sync immediately after a leave is empty.
|
||||
this shouldn't happen -- `timeline` should always include the `leave` event. this is probably
|
||||
a race condition with the membership state cache.
|
||||
|
||||
[1]: https://github.com/matrix-org/matrix-js-sdk/issues/5071
|
||||
*/
|
||||
|
||||
// `state` should only ever include one membership event for the syncing user
|
||||
let membership_event_index = state.iter().position(|pdu| {
|
||||
*pdu.event_type() == TimelineEventType::RoomMember
|
||||
&& pdu.state_key() == Some(syncing_user.as_str())
|
||||
});
|
||||
|
||||
if let Some(index) = membership_event_index {
|
||||
// the ordering of events in `state` does not matter
|
||||
state.swap_remove(index);
|
||||
}
|
||||
|
||||
trace!(
|
||||
?timeline_start_count,
|
||||
?timeline_end_count,
|
||||
"syncing {} timeline events (limited = {}) and {} state events",
|
||||
timeline.pdus.len(),
|
||||
timeline.limited,
|
||||
state.len()
|
||||
);
|
||||
|
||||
Ok((timeline, state))
|
||||
}
|
||||
|
||||
fn create_dummy_leave_event(
|
||||
services: &Services,
|
||||
SyncContext { syncing_user, .. }: SyncContext<'_>,
|
||||
room_id: &RoomId,
|
||||
) -> PduEvent {
|
||||
// TODO: because this event ID is random, it could cause caching issues with
|
||||
// clients. perhaps a database table could be created to hold these dummy
|
||||
// events, or they could be stored as outliers?
|
||||
PduEvent {
|
||||
event_id: EventId::new(services.globals.server_name()),
|
||||
sender: syncing_user.to_owned(),
|
||||
origin: None,
|
||||
origin_server_ts: utils::millis_since_unix_epoch()
|
||||
.try_into()
|
||||
.expect("Timestamp is valid js_int value"),
|
||||
kind: TimelineEventType::RoomMember,
|
||||
content: RawValue::from_string(r#"{"membership": "leave"}"#.to_owned()).unwrap(),
|
||||
state_key: Some(syncing_user.as_str().into()),
|
||||
unsigned: None,
|
||||
// The following keys are dropped on conversion
|
||||
room_id: Some(room_id.to_owned()),
|
||||
prev_events: vec![],
|
||||
depth: uint!(1),
|
||||
auth_events: vec![],
|
||||
redacts: None,
|
||||
hashes: EventHash { sha256: String::new() },
|
||||
signatures: None,
|
||||
}
|
||||
}
|
||||
@@ -1,494 +0,0 @@
|
||||
mod joined;
|
||||
mod left;
|
||||
mod state;
|
||||
|
||||
use std::{
|
||||
cmp::{self},
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::extract::State;
|
||||
use conduwuit::{
|
||||
Result, extract_variant,
|
||||
utils::{
|
||||
ReadyExt, TryFutureExtExt,
|
||||
stream::{BroadbandExt, Tools, WidebandExt},
|
||||
},
|
||||
warn,
|
||||
};
|
||||
use conduwuit_service::Services;
|
||||
use futures::{
|
||||
FutureExt, StreamExt, TryFutureExt,
|
||||
future::{OptionFuture, join3, join4, join5},
|
||||
};
|
||||
use ruma::{
|
||||
DeviceId, OwnedUserId, RoomId, UserId,
|
||||
api::client::{
|
||||
filter::FilterDefinition,
|
||||
sync::sync_events::{
|
||||
self, DeviceLists,
|
||||
v3::{
|
||||
Filter, GlobalAccountData, InviteState, InvitedRoom, KnockState, KnockedRoom,
|
||||
Presence, Rooms, ToDevice,
|
||||
},
|
||||
},
|
||||
uiaa::UiaaResponse,
|
||||
},
|
||||
events::{
|
||||
AnyRawAccountDataEvent,
|
||||
presence::{PresenceEvent, PresenceEventContent},
|
||||
},
|
||||
serde::Raw,
|
||||
};
|
||||
use service::rooms::lazy_loading::{self, MemberSet, Options as _};
|
||||
|
||||
use super::{load_timeline, share_encrypted_room};
|
||||
use crate::{
|
||||
Ruma, RumaResponse,
|
||||
client::{
|
||||
is_ignored_invite,
|
||||
sync::v3::{joined::load_joined_room, left::load_left_room},
|
||||
},
|
||||
};
|
||||
|
||||
/// The default maximum number of events to return in the `timeline` key of
|
||||
/// joined and left rooms. If the number of events sent since the last sync
|
||||
/// exceeds this number, the `timeline` will be `limited`.
|
||||
const DEFAULT_TIMELINE_LIMIT: usize = 30;
|
||||
|
||||
/// A collection of updates to users' device lists, used for E2EE.
|
||||
struct DeviceListUpdates {
|
||||
changed: HashSet<OwnedUserId>,
|
||||
left: HashSet<OwnedUserId>,
|
||||
}
|
||||
|
||||
impl DeviceListUpdates {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
changed: HashSet::new(),
|
||||
left: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn merge(&mut self, other: Self) {
|
||||
self.changed.extend(other.changed);
|
||||
self.left.extend(other.left);
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool { self.changed.is_empty() && self.left.is_empty() }
|
||||
}
|
||||
|
||||
impl From<DeviceListUpdates> for DeviceLists {
|
||||
fn from(val: DeviceListUpdates) -> Self {
|
||||
Self {
|
||||
changed: val.changed.into_iter().collect(),
|
||||
left: val.left.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// References to common data needed to calculate the sync response.
|
||||
#[derive(Clone, Copy)]
|
||||
struct SyncContext<'a> {
|
||||
/// The ID of the user requesting this sync.
|
||||
syncing_user: &'a UserId,
|
||||
/// The ID of the device requesting this sync, which will belong to
|
||||
/// `syncing_user`.
|
||||
syncing_device: &'a DeviceId,
|
||||
/// The global count at the end of the previous sync response.
|
||||
/// The previous sync's `current_count` will become the next sync's
|
||||
/// `last_sync_end_count`. This will be None if no `since` query parameter
|
||||
/// was specified, indicating an initial sync.
|
||||
last_sync_end_count: Option<u64>,
|
||||
/// The global count as of when we started building the sync response.
|
||||
/// This is used as an upper bound when querying the database to ensure the
|
||||
/// response represents a snapshot in time and doesn't include data which
|
||||
/// appeared while the response was being built.
|
||||
current_count: u64,
|
||||
/// The `full_state` query parameter, used when syncing state for joined and
|
||||
/// left rooms.
|
||||
full_state: bool,
|
||||
/// The sync filter, which the client uses to specify what data should be
|
||||
/// included in the sync response.
|
||||
filter: &'a FilterDefinition,
|
||||
}
|
||||
|
||||
impl<'a> SyncContext<'a> {
|
||||
fn lazy_loading_context(&self, room_id: &'a RoomId) -> lazy_loading::Context<'a> {
|
||||
lazy_loading::Context {
|
||||
user_id: self.syncing_user,
|
||||
device_id: Some(self.syncing_device),
|
||||
room_id,
|
||||
token: self.last_sync_end_count,
|
||||
options: Some(&self.filter.room.state.lazy_load_options),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn lazy_loading_enabled(&self) -> bool {
|
||||
(self.filter.room.state.lazy_load_options.is_enabled()
|
||||
|| self.filter.room.timeline.lazy_load_options.is_enabled())
|
||||
&& !self.full_state
|
||||
}
|
||||
}
|
||||
|
||||
type PresenceUpdates = HashMap<OwnedUserId, PresenceEventContent>;
|
||||
|
||||
/// # `GET /_matrix/client/r0/sync`
|
||||
///
|
||||
/// Synchronize the client's state with the latest state on the server.
|
||||
///
|
||||
/// - This endpoint takes a `since` parameter which should be the `next_batch`
|
||||
/// value from a previous request for incremental syncs.
|
||||
///
|
||||
/// Calling this endpoint without a `since` parameter returns:
|
||||
/// - Some of the most recent events of each timeline
|
||||
/// - Notification counts for each room
|
||||
/// - Joined and invited member counts, heroes
|
||||
/// - All state events
|
||||
///
|
||||
/// Calling this endpoint with a `since` parameter from a previous `next_batch`
|
||||
/// returns: For joined rooms:
|
||||
/// - Some of the most recent events of each timeline that happened after since
|
||||
/// - If user joined the room after since: All state events (unless lazy loading
|
||||
/// is activated) and all device list updates in that room
|
||||
/// - If the user was already in the room: A list of all events that are in the
|
||||
/// state now, but were not in the state at `since`
|
||||
/// - If the state we send contains a member event: Joined and invited member
|
||||
/// counts, heroes
|
||||
/// - Device list updates that happened after `since`
|
||||
/// - If there are events in the timeline we send or the user send updated his
|
||||
/// read mark: Notification counts
|
||||
/// - EDUs that are active now (read receipts, typing updates, presence)
|
||||
/// - TODO: Allow multiple sync streams to support Pantalaimon
|
||||
///
|
||||
/// For invited rooms:
|
||||
/// - If the user was invited after `since`: A subset of the state of the room
|
||||
/// at the point of the invite
|
||||
///
|
||||
/// For left rooms:
|
||||
/// - If the user left after `since`: `prev_batch` token, empty state (TODO:
|
||||
/// subset of the state at the point of the leave)
|
||||
#[tracing::instrument(
|
||||
name = "sync",
|
||||
level = "debug",
|
||||
skip_all,
|
||||
fields(
|
||||
since = %body.body.since.as_deref().unwrap_or_default(),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn sync_events_route(
|
||||
State(services): State<crate::State>,
|
||||
body: Ruma<sync_events::v3::Request>,
|
||||
) -> Result<sync_events::v3::Response, RumaResponse<UiaaResponse>> {
|
||||
let (sender_user, sender_device) = body.sender();
|
||||
|
||||
// Presence update
|
||||
if services.config.allow_local_presence {
|
||||
services
|
||||
.presence
|
||||
.ping_presence(sender_user, &body.body.set_presence)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Setup watchers, so if there's no response, we can wait for them
|
||||
let watcher = services.sync.watch(sender_user, sender_device);
|
||||
|
||||
let response = build_sync_events(&services, &body).await?;
|
||||
if body.body.full_state
|
||||
|| !(response.rooms.is_empty()
|
||||
&& response.presence.is_empty()
|
||||
&& response.account_data.is_empty()
|
||||
&& response.device_lists.is_empty()
|
||||
&& response.to_device.is_empty())
|
||||
{
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
// Hang a few seconds so requests are not spammed
|
||||
// Stop hanging if new info arrives
|
||||
let default = Duration::from_secs(30);
|
||||
let duration = cmp::min(body.body.timeout.unwrap_or(default), default);
|
||||
_ = tokio::time::timeout(duration, watcher).await;
|
||||
|
||||
// Retry returning data
|
||||
build_sync_events(&services, &body).await
|
||||
}
|
||||
|
||||
pub(crate) async fn build_sync_events(
|
||||
services: &Services,
|
||||
body: &Ruma<sync_events::v3::Request>,
|
||||
) -> Result<sync_events::v3::Response, RumaResponse<UiaaResponse>> {
|
||||
let (syncing_user, syncing_device) = body.sender();
|
||||
|
||||
let current_count = services.globals.current_count()?;
|
||||
|
||||
// the `since` token is the last sync end count stringified
|
||||
let last_sync_end_count = body
|
||||
.body
|
||||
.since
|
||||
.as_ref()
|
||||
.and_then(|string| string.parse().ok());
|
||||
|
||||
let full_state = body.body.full_state;
|
||||
|
||||
// FilterDefinition is very large (0x1000 bytes), let's put it on the heap
|
||||
let filter = Box::new(match body.body.filter.as_ref() {
|
||||
// use the default filter if none was specified
|
||||
| None => FilterDefinition::default(),
|
||||
// use inline filters directly
|
||||
| Some(Filter::FilterDefinition(filter)) => filter.clone(),
|
||||
// look up filter IDs from the database
|
||||
| Some(Filter::FilterId(filter_id)) => services
|
||||
.users
|
||||
.get_filter(syncing_user, filter_id)
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
|
||||
let context = SyncContext {
|
||||
syncing_user,
|
||||
syncing_device,
|
||||
last_sync_end_count,
|
||||
current_count,
|
||||
full_state,
|
||||
filter: &filter,
|
||||
};
|
||||
|
||||
let joined_rooms = services
|
||||
.rooms
|
||||
.state_cache
|
||||
.rooms_joined(syncing_user)
|
||||
.map(ToOwned::to_owned)
|
||||
.broad_filter_map(|room_id| async {
|
||||
let joined_room = load_joined_room(services, context, room_id.clone()).await;
|
||||
|
||||
match joined_room {
|
||||
| Ok((room, updates)) => Some((room_id, room, updates)),
|
||||
| Err(err) => {
|
||||
warn!(?err, ?room_id, "error loading joined room {}", room_id);
|
||||
None
|
||||
},
|
||||
}
|
||||
})
|
||||
.ready_fold(
|
||||
(BTreeMap::new(), DeviceListUpdates::new()),
|
||||
|(mut joined_rooms, mut all_updates), (room_id, joined_room, updates)| {
|
||||
all_updates.merge(updates);
|
||||
|
||||
if !joined_room.is_empty() {
|
||||
joined_rooms.insert(room_id, joined_room);
|
||||
}
|
||||
|
||||
(joined_rooms, all_updates)
|
||||
},
|
||||
);
|
||||
|
||||
let left_rooms = services
|
||||
.rooms
|
||||
.state_cache
|
||||
.rooms_left(syncing_user)
|
||||
.broad_filter_map(|(room_id, leave_pdu)| {
|
||||
load_left_room(services, context, room_id.clone(), leave_pdu)
|
||||
.map_ok(move |left_room| (room_id, left_room))
|
||||
.ok()
|
||||
})
|
||||
.ready_filter_map(|(room_id, left_room)| left_room.map(|left_room| (room_id, left_room)))
|
||||
.collect();
|
||||
|
||||
let invited_rooms = services
|
||||
.rooms
|
||||
.state_cache
|
||||
.rooms_invited(syncing_user)
|
||||
.wide_filter_map(async |(room_id, invite_state)| {
|
||||
if is_ignored_invite(services, syncing_user, &room_id).await {
|
||||
None
|
||||
} else {
|
||||
Some((room_id, invite_state))
|
||||
}
|
||||
})
|
||||
.fold_default(|mut invited_rooms: BTreeMap<_, _>, (room_id, invite_state)| async move {
|
||||
let invite_count = services
|
||||
.rooms
|
||||
.state_cache
|
||||
.get_invite_count(&room_id, syncing_user)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// only sync this invite if it was sent after the last /sync call
|
||||
if last_sync_end_count < invite_count {
|
||||
let invited_room = InvitedRoom {
|
||||
invite_state: InviteState { events: invite_state },
|
||||
};
|
||||
|
||||
invited_rooms.insert(room_id, invited_room);
|
||||
}
|
||||
invited_rooms
|
||||
});
|
||||
|
||||
let knocked_rooms = services
|
||||
.rooms
|
||||
.state_cache
|
||||
.rooms_knocked(syncing_user)
|
||||
.fold_default(|mut knocked_rooms: BTreeMap<_, _>, (room_id, knock_state)| async move {
|
||||
let knock_count = services
|
||||
.rooms
|
||||
.state_cache
|
||||
.get_knock_count(&room_id, syncing_user)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// only sync this knock if it was sent after the last /sync call
|
||||
if last_sync_end_count < knock_count {
|
||||
let knocked_room = KnockedRoom {
|
||||
knock_state: KnockState { events: knock_state },
|
||||
};
|
||||
|
||||
knocked_rooms.insert(room_id, knocked_room);
|
||||
}
|
||||
knocked_rooms
|
||||
});
|
||||
|
||||
let presence_updates: OptionFuture<_> = services
|
||||
.config
|
||||
.allow_local_presence
|
||||
.then(|| process_presence_updates(services, last_sync_end_count, syncing_user))
|
||||
.into();
|
||||
|
||||
let account_data = services
|
||||
.account_data
|
||||
.changes_since(None, syncing_user, last_sync_end_count, Some(current_count))
|
||||
.ready_filter_map(|e| extract_variant!(e, AnyRawAccountDataEvent::Global))
|
||||
.collect();
|
||||
|
||||
// Look for device list updates of this account
|
||||
let keys_changed = services
|
||||
.users
|
||||
.keys_changed(syncing_user, last_sync_end_count, Some(current_count))
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
let to_device_events = services
|
||||
.users
|
||||
.get_to_device_events(
|
||||
syncing_user,
|
||||
syncing_device,
|
||||
last_sync_end_count,
|
||||
Some(current_count),
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let device_one_time_keys_count = services
|
||||
.users
|
||||
.count_one_time_keys(syncing_user, syncing_device);
|
||||
|
||||
// Remove all to-device events the device received *last time*
|
||||
let remove_to_device_events =
|
||||
services
|
||||
.users
|
||||
.remove_to_device_events(syncing_user, syncing_device, last_sync_end_count);
|
||||
|
||||
let rooms = join4(joined_rooms, left_rooms, invited_rooms, knocked_rooms);
|
||||
let ephemeral = join3(remove_to_device_events, to_device_events, presence_updates);
|
||||
let top = join5(account_data, ephemeral, device_one_time_keys_count, keys_changed, rooms)
|
||||
.boxed()
|
||||
.await;
|
||||
|
||||
let (account_data, ephemeral, device_one_time_keys_count, keys_changed, rooms) = top;
|
||||
let ((), to_device_events, presence_updates) = ephemeral;
|
||||
let (joined_rooms, left_rooms, invited_rooms, knocked_rooms) = rooms;
|
||||
let (joined_rooms, mut device_list_updates) = joined_rooms;
|
||||
device_list_updates.changed.extend(keys_changed);
|
||||
|
||||
let response = sync_events::v3::Response {
|
||||
account_data: GlobalAccountData { events: account_data },
|
||||
device_lists: device_list_updates.into(),
|
||||
device_one_time_keys_count,
|
||||
// Fallback keys are not yet supported
|
||||
device_unused_fallback_key_types: None,
|
||||
next_batch: current_count.to_string(),
|
||||
presence: Presence {
|
||||
events: presence_updates
|
||||
.into_iter()
|
||||
.flat_map(IntoIterator::into_iter)
|
||||
.map(|(sender, content)| PresenceEvent { content, sender })
|
||||
.map(|ref event| Raw::new(event))
|
||||
.filter_map(Result::ok)
|
||||
.collect(),
|
||||
},
|
||||
rooms: Rooms {
|
||||
leave: left_rooms,
|
||||
join: joined_rooms,
|
||||
invite: invited_rooms,
|
||||
knock: knocked_rooms,
|
||||
},
|
||||
to_device: ToDevice { events: to_device_events },
|
||||
};
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "presence", level = "debug", skip_all)]
|
||||
async fn process_presence_updates(
|
||||
services: &Services,
|
||||
last_sync_end_count: Option<u64>,
|
||||
syncing_user: &UserId,
|
||||
) -> PresenceUpdates {
|
||||
services
|
||||
.presence
|
||||
.presence_since(last_sync_end_count.unwrap_or(0)) // send all presences on initial sync
|
||||
.filter(|(user_id, ..)| {
|
||||
services
|
||||
.rooms
|
||||
.state_cache
|
||||
.user_sees_user(syncing_user, user_id)
|
||||
})
|
||||
.filter_map(|(user_id, _, presence_bytes)| {
|
||||
services
|
||||
.presence
|
||||
.from_json_bytes_to_event(presence_bytes, user_id)
|
||||
.map_ok(move |event| (user_id, event))
|
||||
.ok()
|
||||
})
|
||||
.map(|(user_id, event)| (user_id.to_owned(), event.content))
|
||||
.collect()
|
||||
.await
|
||||
}
|
||||
|
||||
/// Using the provided sync context and an iterator of user IDs in the
|
||||
/// `timeline`, return a HashSet of user IDs whose membership events should be
|
||||
/// sent to the client if lazy-loading is enabled.
|
||||
#[allow(clippy::let_and_return)]
|
||||
async fn prepare_lazily_loaded_members(
|
||||
services: &Services,
|
||||
sync_context: SyncContext<'_>,
|
||||
room_id: &RoomId,
|
||||
timeline_members: impl Iterator<Item = OwnedUserId>,
|
||||
) -> Option<MemberSet> {
|
||||
let lazy_loading_context = &sync_context.lazy_loading_context(room_id);
|
||||
|
||||
// reset lazy loading state on initial sync.
|
||||
// do this even if lazy loading is disabled so future lazy loads
|
||||
// will have the correct members.
|
||||
if sync_context.last_sync_end_count.is_none() {
|
||||
services
|
||||
.rooms
|
||||
.lazy_loading
|
||||
.reset(lazy_loading_context)
|
||||
.await;
|
||||
}
|
||||
|
||||
// filter the input members through `retain_lazy_members`, which
|
||||
// contains the actual lazy loading logic.
|
||||
let lazily_loaded_members =
|
||||
OptionFuture::from(sync_context.lazy_loading_enabled().then(|| {
|
||||
services
|
||||
.rooms
|
||||
.lazy_loading
|
||||
.retain_lazy_members(timeline_members.collect(), lazy_loading_context)
|
||||
}))
|
||||
.await;
|
||||
|
||||
lazily_loaded_members
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
use std::{collections::BTreeSet, ops::ControlFlow};
|
||||
|
||||
use conduwuit::{
|
||||
Result, at, is_equal_to,
|
||||
matrix::{
|
||||
Event,
|
||||
pdu::{PduCount, PduEvent},
|
||||
},
|
||||
utils::{
|
||||
BoolExt, IterStream, ReadyExt, TryFutureExtExt,
|
||||
stream::{BroadbandExt, TryIgnore},
|
||||
},
|
||||
};
|
||||
use conduwuit_service::{
|
||||
Services,
|
||||
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 tracing::trace;
|
||||
|
||||
use crate::client::TimelinePdus;
|
||||
|
||||
/// Calculate the state events to include in an initial sync response.
|
||||
///
|
||||
/// If lazy-loading is enabled (`lazily_loaded_members` is Some), the returned
|
||||
/// Vec will include the membership events of exclusively the members in
|
||||
/// `lazily_loaded_members`.
|
||||
#[tracing::instrument(
|
||||
name = "initial",
|
||||
level = "trace",
|
||||
skip_all,
|
||||
fields(current_shortstatehash)
|
||||
)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn build_state_initial(
|
||||
services: &Services,
|
||||
sender_user: &UserId,
|
||||
timeline_start_shortstatehash: ShortStateHash,
|
||||
lazily_loaded_members: Option<&MemberSet>,
|
||||
) -> Result<Vec<PduEvent>> {
|
||||
// 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)
|
||||
.unzip()
|
||||
.await;
|
||||
|
||||
trace!("performing initial sync of {} state events", event_ids.len());
|
||||
|
||||
services
|
||||
.rooms
|
||||
.short
|
||||
// look up the full state keys
|
||||
.multi_get_statekey_from_short(shortstatekeys.into_iter().stream())
|
||||
.zip(event_ids.into_iter().stream())
|
||||
.ready_filter_map(|item| Some((item.0.ok()?, item.1)))
|
||||
.ready_filter_map(|((event_type, state_key), event_id)| {
|
||||
if let Some(lazily_loaded_members) = lazily_loaded_members {
|
||||
/*
|
||||
if lazy loading is enabled, filter out membership events which aren't for a user
|
||||
included in `lazily_loaded_members` or for the user requesting the sync.
|
||||
*/
|
||||
let event_is_redundant = event_type == StateEventType::RoomMember
|
||||
&& state_key.as_str().try_into().is_ok_and(|user_id: &UserId| {
|
||||
sender_user != user_id && !lazily_loaded_members.contains(user_id)
|
||||
});
|
||||
|
||||
event_is_redundant.or_some(event_id)
|
||||
} else {
|
||||
Some(event_id)
|
||||
}
|
||||
})
|
||||
.broad_filter_map(|event_id: OwnedEventId| async move {
|
||||
services.rooms.timeline.get_pdu(&event_id).await.ok()
|
||||
})
|
||||
.collect()
|
||||
.map(Ok)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Calculate the state events to include in an incremental sync response.
|
||||
///
|
||||
/// If lazy-loading is enabled (`lazily_loaded_members` is Some), the returned
|
||||
/// Vec will include the membership events of all the members in
|
||||
/// `lazily_loaded_members`.
|
||||
#[tracing::instrument(name = "incremental", level = "trace", skip_all)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
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,
|
||||
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.
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
/*
|
||||
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.
|
||||
|
||||
see: https://github.com/element-hq/synapse/issues/16941
|
||||
*/
|
||||
|
||||
let timeline_is_linear = timeline.pdus.is_empty() || {
|
||||
let last_pdu_of_last_sync = services
|
||||
.rooms
|
||||
.timeline
|
||||
.pdus_rev(Some(sender_user), 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
|
||||
.iter()
|
||||
.stream()
|
||||
.broad_filter_map(|user_id| async move {
|
||||
if user_id == sender_user {
|
||||
return None;
|
||||
}
|
||||
|
||||
services
|
||||
.rooms
|
||||
.state_accessor
|
||||
.state_get(
|
||||
timeline_start_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![]);
|
||||
}
|
||||
|
||||
/*
|
||||
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
|
||||
.rooms
|
||||
.short
|
||||
.multi_get_eventid_from_short::<'_, OwnedEventId, _>(
|
||||
services
|
||||
.rooms
|
||||
.state_accessor
|
||||
.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)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.ignore_err();
|
||||
|
||||
// finally, fetch the PDU contents and collect them into a vec
|
||||
let state_diff_pdus = state_diff
|
||||
.broad_filter_map(|event_id| async move {
|
||||
services
|
||||
.rooms
|
||||
.timeline
|
||||
.get_non_outlier_pdu(&event_id)
|
||||
.await
|
||||
.ok()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
trace!(?state_diff_pdus, "collected state PDUs for incremental sync");
|
||||
Ok(state_diff_pdus)
|
||||
}
|
||||
@@ -0,0 +1,848 @@
|
||||
use std::{
|
||||
cmp::{self, Ordering},
|
||||
collections::{BTreeMap, BTreeSet, HashMap, HashSet},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::extract::State;
|
||||
use conduwuit::{
|
||||
Err, Error, Event, PduCount, Result, at, debug, error, extract_variant,
|
||||
matrix::TypeStateKey,
|
||||
utils::{
|
||||
BoolExt, IterStream, ReadyExt, TryFutureExtExt,
|
||||
math::{ruma_from_usize, usize_from_ruma, usize_from_u64_truncated},
|
||||
stream::WidebandExt,
|
||||
},
|
||||
warn,
|
||||
};
|
||||
use conduwuit_service::{
|
||||
Services,
|
||||
rooms::read_receipt::pack_receipts,
|
||||
sync::{into_db_key, into_snake_key},
|
||||
};
|
||||
use futures::{FutureExt, StreamExt, TryFutureExt};
|
||||
use ruma::{
|
||||
MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedRoomId, RoomId, UInt, UserId,
|
||||
api::client::sync::sync_events::{
|
||||
self, DeviceLists, UnreadNotificationsCount,
|
||||
v4::{SlidingOp, SlidingSyncRoomHero},
|
||||
},
|
||||
directory::RoomTypeFilter,
|
||||
events::{
|
||||
AnyRawAccountDataEvent, AnySyncEphemeralRoomEvent, StateEventType,
|
||||
TimelineEventType::*,
|
||||
room::member::{MembershipState, RoomMemberEventContent},
|
||||
},
|
||||
serde::Raw,
|
||||
uint,
|
||||
};
|
||||
|
||||
use super::{load_timeline, share_encrypted_room};
|
||||
use crate::{
|
||||
Ruma,
|
||||
client::{DEFAULT_BUMP_TYPES, ignored_filter, is_ignored_invite},
|
||||
};
|
||||
|
||||
type TodoRooms = BTreeMap<OwnedRoomId, (BTreeSet<TypeStateKey>, usize, u64)>;
|
||||
const SINGLE_CONNECTION_SYNC: &str = "single_connection_sync";
|
||||
|
||||
#[allow(clippy::cognitive_complexity)]
|
||||
/// POST `/_matrix/client/unstable/org.matrix.msc3575/sync`
|
||||
///
|
||||
/// Sliding Sync endpoint (future endpoint: `/_matrix/client/v4/sync`)
|
||||
pub(crate) async fn sync_events_v4_route(
|
||||
State(services): State<crate::State>,
|
||||
body: Ruma<sync_events::v4::Request>,
|
||||
) -> Result<sync_events::v4::Response> {
|
||||
debug_assert!(DEFAULT_BUMP_TYPES.is_sorted(), "DEFAULT_BUMP_TYPES is not sorted");
|
||||
let sender_user = body.sender_user.as_ref().expect("user is authenticated");
|
||||
let sender_device = body.sender_device.as_ref().expect("user is authenticated");
|
||||
let mut body = body.body;
|
||||
|
||||
// Setup watchers, so if there's no response, we can wait for them
|
||||
let watcher = services.sync.watch(sender_user, sender_device);
|
||||
|
||||
let next_batch = services.globals.next_count()?;
|
||||
|
||||
let conn_id = body
|
||||
.conn_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| SINGLE_CONNECTION_SYNC.to_owned());
|
||||
|
||||
let globalsince = body
|
||||
.pos
|
||||
.as_ref()
|
||||
.and_then(|string| string.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let db_key = into_db_key(sender_user, sender_device, conn_id.clone());
|
||||
if globalsince != 0 && !services.sync.remembered(&db_key) {
|
||||
debug!("Restarting sync stream because it was gone from the database");
|
||||
return Err!(Request(UnknownPos("Connection data lost since last time")));
|
||||
}
|
||||
|
||||
if globalsince == 0 {
|
||||
services.sync.forget_sync_request_connection(&db_key);
|
||||
}
|
||||
|
||||
// Get sticky parameters from cache
|
||||
let snake_key = into_snake_key(sender_user, sender_device, conn_id.clone());
|
||||
let known_rooms = services
|
||||
.sync
|
||||
.update_sync_request_with_cache(&snake_key, &mut body);
|
||||
|
||||
let all_joined_rooms: Vec<_> = services
|
||||
.rooms
|
||||
.state_cache
|
||||
.rooms_joined(sender_user)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
let all_invited_rooms: Vec<_> = services
|
||||
.rooms
|
||||
.state_cache
|
||||
.rooms_invited(sender_user)
|
||||
.wide_filter_map(async |(room_id, invite_state)| {
|
||||
if is_ignored_invite(&services, sender_user, &room_id).await {
|
||||
None
|
||||
} else {
|
||||
Some((room_id, invite_state))
|
||||
}
|
||||
})
|
||||
.map(|r| r.0)
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
let all_knocked_rooms: Vec<_> = services
|
||||
.rooms
|
||||
.state_cache
|
||||
.rooms_knocked(sender_user)
|
||||
.map(|r| r.0)
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
let all_invited_rooms: Vec<&RoomId> = all_invited_rooms.iter().map(AsRef::as_ref).collect();
|
||||
let all_knocked_rooms: Vec<&RoomId> = all_knocked_rooms.iter().map(AsRef::as_ref).collect();
|
||||
|
||||
let all_rooms: Vec<&RoomId> = all_joined_rooms
|
||||
.iter()
|
||||
.map(AsRef::as_ref)
|
||||
.chain(all_invited_rooms.iter().map(AsRef::as_ref))
|
||||
.chain(all_knocked_rooms.iter().map(AsRef::as_ref))
|
||||
.collect();
|
||||
|
||||
let all_joined_rooms = all_joined_rooms.iter().map(AsRef::as_ref).collect();
|
||||
let all_invited_rooms = all_invited_rooms.iter().map(AsRef::as_ref).collect();
|
||||
|
||||
if body.extensions.to_device.enabled.unwrap_or(false) {
|
||||
services
|
||||
.users
|
||||
.remove_to_device_events(sender_user, sender_device, globalsince)
|
||||
.await;
|
||||
}
|
||||
|
||||
let mut left_encrypted_users = HashSet::new(); // Users that have left any encrypted rooms the sender was in
|
||||
let mut device_list_changes = HashSet::new();
|
||||
let mut device_list_left = HashSet::new();
|
||||
|
||||
let mut receipts = sync_events::v4::Receipts { rooms: BTreeMap::new() };
|
||||
|
||||
let mut account_data = sync_events::v4::AccountData {
|
||||
global: Vec::new(),
|
||||
rooms: BTreeMap::new(),
|
||||
};
|
||||
if body.extensions.account_data.enabled.unwrap_or(false) {
|
||||
account_data.global = services
|
||||
.account_data
|
||||
.changes_since(None, sender_user, globalsince, Some(next_batch))
|
||||
.ready_filter_map(|e| extract_variant!(e, AnyRawAccountDataEvent::Global))
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
if let Some(rooms) = body.extensions.account_data.rooms {
|
||||
for room in rooms {
|
||||
account_data.rooms.insert(
|
||||
room.clone(),
|
||||
services
|
||||
.account_data
|
||||
.changes_since(Some(&room), sender_user, globalsince, Some(next_batch))
|
||||
.ready_filter_map(|e| extract_variant!(e, AnyRawAccountDataEvent::Room))
|
||||
.collect()
|
||||
.await,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if body.extensions.e2ee.enabled.unwrap_or(false) {
|
||||
// Look for device list updates of this account
|
||||
device_list_changes.extend(
|
||||
services
|
||||
.users
|
||||
.keys_changed(sender_user, globalsince, None)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
.await,
|
||||
);
|
||||
|
||||
for room_id in &all_joined_rooms {
|
||||
let room_id: &&RoomId = room_id;
|
||||
let Ok(current_shortstatehash) =
|
||||
services.rooms.state.get_room_shortstatehash(room_id).await
|
||||
else {
|
||||
error!("Room {room_id} has no state");
|
||||
continue;
|
||||
};
|
||||
|
||||
let since_shortstatehash = services
|
||||
.rooms
|
||||
.user
|
||||
.get_token_shortstatehash(room_id, globalsince)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
let encrypted_room = services
|
||||
.rooms
|
||||
.state_accessor
|
||||
.state_get(current_shortstatehash, &StateEventType::RoomEncryption, "")
|
||||
.await
|
||||
.is_ok();
|
||||
|
||||
if let Some(since_shortstatehash) = since_shortstatehash {
|
||||
// Skip if there are only timeline changes
|
||||
if since_shortstatehash == current_shortstatehash {
|
||||
continue;
|
||||
}
|
||||
|
||||
let since_encryption = services
|
||||
.rooms
|
||||
.state_accessor
|
||||
.state_get(since_shortstatehash, &StateEventType::RoomEncryption, "")
|
||||
.await;
|
||||
|
||||
let since_sender_member: Option<RoomMemberEventContent> = services
|
||||
.rooms
|
||||
.state_accessor
|
||||
.state_get_content(
|
||||
since_shortstatehash,
|
||||
&StateEventType::RoomMember,
|
||||
sender_user.as_str(),
|
||||
)
|
||||
.ok()
|
||||
.await;
|
||||
|
||||
let joined_since_last_sync = since_sender_member
|
||||
.as_ref()
|
||||
.is_none_or(|member| member.membership != MembershipState::Join);
|
||||
|
||||
let new_encrypted_room = encrypted_room && since_encryption.is_err();
|
||||
|
||||
if encrypted_room {
|
||||
let current_state_ids: HashMap<_, OwnedEventId> = services
|
||||
.rooms
|
||||
.state_accessor
|
||||
.state_full_ids(current_shortstatehash)
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
let since_state_ids: HashMap<_, _> = services
|
||||
.rooms
|
||||
.state_accessor
|
||||
.state_full_ids(since_shortstatehash)
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
for (key, id) in current_state_ids {
|
||||
if since_state_ids.get(&key) != Some(&id) {
|
||||
let Ok(pdu) = services.rooms.timeline.get_pdu(&id).await else {
|
||||
error!("Pdu in state not found: {id}");
|
||||
continue;
|
||||
};
|
||||
if pdu.kind == RoomMember {
|
||||
if let Some(Ok(user_id)) =
|
||||
pdu.state_key.as_deref().map(UserId::parse)
|
||||
{
|
||||
if user_id == sender_user {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content: RoomMemberEventContent = pdu.get_content()?;
|
||||
match content.membership {
|
||||
| MembershipState::Join => {
|
||||
// A new user joined an encrypted room
|
||||
if !share_encrypted_room(
|
||||
&services,
|
||||
sender_user,
|
||||
user_id,
|
||||
Some(room_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
device_list_changes.insert(user_id.to_owned());
|
||||
}
|
||||
},
|
||||
| MembershipState::Leave => {
|
||||
// Write down users that have left encrypted rooms we
|
||||
// are in
|
||||
left_encrypted_users.insert(user_id.to_owned());
|
||||
},
|
||||
| _ => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if joined_since_last_sync || new_encrypted_room {
|
||||
// If the user is in a new encrypted room, give them all joined users
|
||||
device_list_changes.extend(
|
||||
services
|
||||
.rooms
|
||||
.state_cache
|
||||
.room_members(room_id)
|
||||
// Don't send key updates from the sender to the sender
|
||||
.ready_filter(|&user_id| sender_user != user_id)
|
||||
// Only send keys if the sender doesn't share an encrypted room with the target
|
||||
// already
|
||||
.filter_map(|user_id| {
|
||||
share_encrypted_room(&services, sender_user, user_id, Some(room_id))
|
||||
.map(|res| res.or_some(user_id.to_owned()))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.await,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Look for device list updates in this room
|
||||
device_list_changes.extend(
|
||||
services
|
||||
.users
|
||||
.room_keys_changed(room_id, globalsince, None)
|
||||
.map(|(user_id, _)| user_id)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
.await,
|
||||
);
|
||||
}
|
||||
|
||||
for user_id in left_encrypted_users {
|
||||
let dont_share_encrypted_room =
|
||||
!share_encrypted_room(&services, sender_user, &user_id, None).await;
|
||||
|
||||
// If the user doesn't share an encrypted room with the target anymore, we need
|
||||
// to tell them
|
||||
if dont_share_encrypted_room {
|
||||
device_list_left.insert(user_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut lists = BTreeMap::new();
|
||||
let mut todo_rooms: TodoRooms = BTreeMap::new(); // and required state
|
||||
|
||||
for (list_id, list) in &body.lists {
|
||||
let active_rooms = match list.filters.clone().and_then(|f| f.is_invite) {
|
||||
| Some(true) => &all_invited_rooms,
|
||||
| Some(false) => &all_joined_rooms,
|
||||
| None => &all_rooms,
|
||||
};
|
||||
|
||||
let active_rooms = match list.filters.clone().map(|f| f.not_room_types) {
|
||||
| Some(filter) if filter.is_empty() => active_rooms.clone(),
|
||||
| Some(value) => filter_rooms(&services, active_rooms, &value, true).await,
|
||||
| None => active_rooms.clone(),
|
||||
};
|
||||
|
||||
let active_rooms = match list.filters.clone().map(|f| f.room_types) {
|
||||
| Some(filter) if filter.is_empty() => active_rooms.clone(),
|
||||
| Some(value) => filter_rooms(&services, &active_rooms, &value, false).await,
|
||||
| None => active_rooms,
|
||||
};
|
||||
|
||||
let mut new_known_rooms: BTreeSet<OwnedRoomId> = BTreeSet::new();
|
||||
|
||||
let ranges = list.ranges.clone();
|
||||
lists.insert(list_id.clone(), sync_events::v4::SyncList {
|
||||
ops: ranges
|
||||
.into_iter()
|
||||
.map(|mut r| {
|
||||
r.0 = r.0.clamp(
|
||||
uint!(0),
|
||||
UInt::try_from(active_rooms.len().saturating_sub(1)).unwrap_or(UInt::MAX),
|
||||
);
|
||||
r.1 = r.1.clamp(
|
||||
r.0,
|
||||
UInt::try_from(active_rooms.len().saturating_sub(1)).unwrap_or(UInt::MAX),
|
||||
);
|
||||
|
||||
let room_ids = if !active_rooms.is_empty() {
|
||||
active_rooms[usize_from_ruma(r.0)..=usize_from_ruma(r.1)].to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
new_known_rooms.extend(room_ids.clone().into_iter().map(ToOwned::to_owned));
|
||||
for room_id in &room_ids {
|
||||
let todo_room = todo_rooms.entry((*room_id).to_owned()).or_insert((
|
||||
BTreeSet::new(),
|
||||
0_usize,
|
||||
u64::MAX,
|
||||
));
|
||||
|
||||
let limit: usize = list
|
||||
.room_details
|
||||
.timeline_limit
|
||||
.map(u64::from)
|
||||
.map_or(10, usize_from_u64_truncated)
|
||||
.min(100);
|
||||
|
||||
todo_room.0.extend(
|
||||
list.room_details
|
||||
.required_state
|
||||
.iter()
|
||||
.map(|(ty, sk)| (ty.clone(), sk.as_str().into())),
|
||||
);
|
||||
|
||||
todo_room.1 = todo_room.1.max(limit);
|
||||
// 0 means unknown because it got out of date
|
||||
todo_room.2 = todo_room.2.min(
|
||||
known_rooms
|
||||
.get(list_id.as_str())
|
||||
.and_then(|k| k.get(*room_id))
|
||||
.copied()
|
||||
.unwrap_or(0),
|
||||
);
|
||||
}
|
||||
sync_events::v4::SyncOp {
|
||||
op: SlidingOp::Sync,
|
||||
range: Some(r),
|
||||
index: None,
|
||||
room_ids: room_ids.into_iter().map(ToOwned::to_owned).collect(),
|
||||
room_id: None,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
count: ruma_from_usize(active_rooms.len()),
|
||||
});
|
||||
|
||||
if let Some(conn_id) = &body.conn_id {
|
||||
let db_key = into_db_key(sender_user, sender_device, conn_id);
|
||||
services.sync.update_sync_known_rooms(
|
||||
&db_key,
|
||||
list_id.clone(),
|
||||
new_known_rooms,
|
||||
globalsince,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut known_subscription_rooms = BTreeSet::new();
|
||||
for (room_id, room) in &body.room_subscriptions {
|
||||
if !services.rooms.metadata.exists(room_id).await
|
||||
|| services.rooms.metadata.is_disabled(room_id).await
|
||||
|| services.rooms.metadata.is_banned(room_id).await
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let todo_room =
|
||||
todo_rooms
|
||||
.entry(room_id.clone())
|
||||
.or_insert((BTreeSet::new(), 0_usize, u64::MAX));
|
||||
|
||||
let limit: usize = room
|
||||
.timeline_limit
|
||||
.map(u64::from)
|
||||
.map_or(10, usize_from_u64_truncated)
|
||||
.min(100);
|
||||
|
||||
todo_room.0.extend(
|
||||
room.required_state
|
||||
.iter()
|
||||
.map(|(ty, sk)| (ty.clone(), sk.as_str().into())),
|
||||
);
|
||||
todo_room.1 = todo_room.1.max(limit);
|
||||
// 0 means unknown because it got out of date
|
||||
todo_room.2 = todo_room.2.min(
|
||||
known_rooms
|
||||
.get("subscriptions")
|
||||
.and_then(|k| k.get(room_id))
|
||||
.copied()
|
||||
.unwrap_or(0),
|
||||
);
|
||||
known_subscription_rooms.insert(room_id.clone());
|
||||
}
|
||||
|
||||
for r in body.unsubscribe_rooms {
|
||||
known_subscription_rooms.remove(&r);
|
||||
body.room_subscriptions.remove(&r);
|
||||
}
|
||||
|
||||
if let Some(conn_id) = &body.conn_id {
|
||||
let db_key = into_db_key(sender_user, sender_device, conn_id);
|
||||
services.sync.update_sync_known_rooms(
|
||||
&db_key,
|
||||
"subscriptions".to_owned(),
|
||||
known_subscription_rooms,
|
||||
globalsince,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(conn_id) = body.conn_id.clone() {
|
||||
let db_key = into_db_key(sender_user, sender_device, conn_id);
|
||||
services
|
||||
.sync
|
||||
.update_sync_subscriptions(&db_key, body.room_subscriptions);
|
||||
}
|
||||
|
||||
let mut rooms = BTreeMap::new();
|
||||
for (room_id, (required_state_request, timeline_limit, roomsince)) in &todo_rooms {
|
||||
let roomsincecount = PduCount::Normal(*roomsince);
|
||||
|
||||
let mut timestamp: Option<_> = None;
|
||||
let mut invite_state = None;
|
||||
let (timeline_pdus, limited);
|
||||
let new_room_id: &RoomId = (*room_id).as_ref();
|
||||
if all_invited_rooms.contains(&new_room_id) {
|
||||
// TODO: figure out a timestamp we can use for remote invites
|
||||
invite_state = services
|
||||
.rooms
|
||||
.state_cache
|
||||
.invite_state(sender_user, room_id)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
(timeline_pdus, limited) = (Vec::new(), true);
|
||||
} else {
|
||||
(timeline_pdus, limited) = match load_timeline(
|
||||
&services,
|
||||
sender_user,
|
||||
room_id,
|
||||
roomsincecount,
|
||||
None,
|
||||
*timeline_limit,
|
||||
)
|
||||
.await
|
||||
{
|
||||
| Ok(value) => value,
|
||||
| Err(err) => {
|
||||
warn!("Encountered missing timeline in {}, error {}", room_id, err);
|
||||
continue;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
account_data.rooms.insert(
|
||||
room_id.to_owned(),
|
||||
services
|
||||
.account_data
|
||||
.changes_since(Some(room_id), sender_user, *roomsince, Some(next_batch))
|
||||
.ready_filter_map(|e| extract_variant!(e, AnyRawAccountDataEvent::Room))
|
||||
.collect()
|
||||
.await,
|
||||
);
|
||||
|
||||
let last_privateread_update = services
|
||||
.rooms
|
||||
.read_receipt
|
||||
.last_privateread_update(sender_user, room_id)
|
||||
.await > *roomsince;
|
||||
|
||||
let private_read_event = if last_privateread_update {
|
||||
services
|
||||
.rooms
|
||||
.read_receipt
|
||||
.private_read_get(room_id, sender_user)
|
||||
.await
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut vector: Vec<Raw<AnySyncEphemeralRoomEvent>> = services
|
||||
.rooms
|
||||
.read_receipt
|
||||
.readreceipts_since(room_id, *roomsince)
|
||||
.filter_map(|(read_user, _ts, v)| async move {
|
||||
services
|
||||
.users
|
||||
.user_is_ignored(read_user, sender_user)
|
||||
.await
|
||||
.or_some(v)
|
||||
})
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
if let Some(private_read_event) = private_read_event {
|
||||
vector.push(private_read_event);
|
||||
}
|
||||
|
||||
let receipt_size = vector.len();
|
||||
receipts
|
||||
.rooms
|
||||
.insert(room_id.clone(), pack_receipts(Box::new(vector.into_iter())));
|
||||
|
||||
if roomsince != &0
|
||||
&& timeline_pdus.is_empty()
|
||||
&& account_data.rooms.get(room_id).is_some_and(Vec::is_empty)
|
||||
&& receipt_size == 0
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let prev_batch = timeline_pdus
|
||||
.first()
|
||||
.map_or(Ok::<_, Error>(None), |(pdu_count, _)| {
|
||||
Ok(Some(match pdu_count {
|
||||
| PduCount::Backfilled(_) => {
|
||||
error!("timeline in backfill state?!");
|
||||
"0".to_owned()
|
||||
},
|
||||
| PduCount::Normal(c) => c.to_string(),
|
||||
}))
|
||||
})?
|
||||
.or_else(|| {
|
||||
if roomsince != &0 {
|
||||
Some(roomsince.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let room_events: Vec<_> = timeline_pdus
|
||||
.iter()
|
||||
.stream()
|
||||
.filter_map(|item| ignored_filter(&services, item.clone(), sender_user))
|
||||
.map(at!(1))
|
||||
.map(Event::into_format)
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
for (_, pdu) in timeline_pdus {
|
||||
let ts = MilliSecondsSinceUnixEpoch(pdu.origin_server_ts);
|
||||
if DEFAULT_BUMP_TYPES.binary_search(&pdu.kind).is_ok()
|
||||
&& timestamp.is_none_or(|time| time <= ts)
|
||||
{
|
||||
timestamp = Some(ts);
|
||||
}
|
||||
}
|
||||
|
||||
let required_state = required_state_request
|
||||
.iter()
|
||||
.stream()
|
||||
.filter_map(|state| async move {
|
||||
services
|
||||
.rooms
|
||||
.state_accessor
|
||||
.room_state_get(room_id, &state.0, &state.1)
|
||||
.await
|
||||
.map(Event::into_format)
|
||||
.ok()
|
||||
})
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
// Heroes
|
||||
let heroes: Vec<_> = services
|
||||
.rooms
|
||||
.state_cache
|
||||
.room_members(room_id)
|
||||
.ready_filter(|&member| member != sender_user)
|
||||
.filter_map(|user_id| {
|
||||
services
|
||||
.rooms
|
||||
.state_accessor
|
||||
.get_member(room_id, user_id)
|
||||
.map_ok(|memberevent| SlidingSyncRoomHero {
|
||||
user_id: user_id.into(),
|
||||
name: memberevent.displayname,
|
||||
avatar: memberevent.avatar_url,
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.take(5)
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
let name = match heroes.len().cmp(&(1_usize)) {
|
||||
| Ordering::Greater => {
|
||||
let firsts = heroes[1..]
|
||||
.iter()
|
||||
.map(|h| h.name.clone().unwrap_or_else(|| h.user_id.to_string()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
let last = heroes[0]
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| heroes[0].user_id.to_string());
|
||||
|
||||
Some(format!("{firsts} and {last}"))
|
||||
},
|
||||
| Ordering::Equal => Some(
|
||||
heroes[0]
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| heroes[0].user_id.to_string()),
|
||||
),
|
||||
| Ordering::Less => None,
|
||||
};
|
||||
|
||||
let heroes_avatar = if heroes.len() == 1 {
|
||||
heroes[0].avatar.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
rooms.insert(room_id.clone(), sync_events::v4::SlidingSyncRoom {
|
||||
name: services
|
||||
.rooms
|
||||
.state_accessor
|
||||
.get_name(room_id)
|
||||
.await
|
||||
.ok()
|
||||
.or(name),
|
||||
avatar: match heroes_avatar {
|
||||
| Some(heroes_avatar) => ruma::JsOption::Some(heroes_avatar),
|
||||
| _ => match services.rooms.state_accessor.get_avatar(room_id).await {
|
||||
| ruma::JsOption::Some(avatar) => ruma::JsOption::from_option(avatar.url),
|
||||
| ruma::JsOption::Null => ruma::JsOption::Null,
|
||||
| ruma::JsOption::Undefined => ruma::JsOption::Undefined,
|
||||
},
|
||||
},
|
||||
initial: Some(roomsince == &0),
|
||||
is_dm: None,
|
||||
invite_state,
|
||||
unread_notifications: UnreadNotificationsCount {
|
||||
highlight_count: Some(
|
||||
services
|
||||
.rooms
|
||||
.user
|
||||
.highlight_count(sender_user, room_id)
|
||||
.await
|
||||
.try_into()
|
||||
.expect("notification count can't go that high"),
|
||||
),
|
||||
notification_count: Some(
|
||||
services
|
||||
.rooms
|
||||
.user
|
||||
.notification_count(sender_user, room_id)
|
||||
.await
|
||||
.try_into()
|
||||
.expect("notification count can't go that high"),
|
||||
),
|
||||
},
|
||||
timeline: room_events,
|
||||
required_state,
|
||||
prev_batch,
|
||||
limited,
|
||||
joined_count: Some(
|
||||
services
|
||||
.rooms
|
||||
.state_cache
|
||||
.room_joined_count(room_id)
|
||||
.await
|
||||
.unwrap_or(0)
|
||||
.try_into()
|
||||
.unwrap_or_else(|_| uint!(0)),
|
||||
),
|
||||
invited_count: Some(
|
||||
services
|
||||
.rooms
|
||||
.state_cache
|
||||
.room_invited_count(room_id)
|
||||
.await
|
||||
.unwrap_or(0)
|
||||
.try_into()
|
||||
.unwrap_or_else(|_| uint!(0)),
|
||||
),
|
||||
num_live: None, // Count events in timeline greater than global sync counter
|
||||
timestamp,
|
||||
heroes: Some(heroes),
|
||||
});
|
||||
}
|
||||
|
||||
if rooms.iter().all(|(id, r)| {
|
||||
r.timeline.is_empty() && r.required_state.is_empty() && !receipts.rooms.contains_key(id)
|
||||
}) {
|
||||
// Hang a few seconds so requests are not spammed
|
||||
// Stop hanging if new info arrives
|
||||
let default = Duration::from_secs(30);
|
||||
let duration = cmp::min(body.timeout.unwrap_or(default), default);
|
||||
_ = tokio::time::timeout(duration, watcher).await;
|
||||
}
|
||||
|
||||
Ok(sync_events::v4::Response {
|
||||
initial: globalsince == 0,
|
||||
txn_id: body.txn_id.clone(),
|
||||
pos: next_batch.to_string(),
|
||||
lists,
|
||||
rooms,
|
||||
extensions: sync_events::v4::Extensions {
|
||||
to_device: if body.extensions.to_device.enabled.unwrap_or(false) {
|
||||
Some(sync_events::v4::ToDevice {
|
||||
events: services
|
||||
.users
|
||||
.get_to_device_events(
|
||||
sender_user,
|
||||
sender_device,
|
||||
Some(globalsince),
|
||||
Some(next_batch),
|
||||
)
|
||||
.collect()
|
||||
.await,
|
||||
next_batch: next_batch.to_string(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
e2ee: sync_events::v4::E2EE {
|
||||
device_lists: DeviceLists {
|
||||
changed: device_list_changes.into_iter().collect(),
|
||||
left: device_list_left.into_iter().collect(),
|
||||
},
|
||||
device_one_time_keys_count: services
|
||||
.users
|
||||
.count_one_time_keys(sender_user, sender_device)
|
||||
.await,
|
||||
// Fallback keys are not yet supported
|
||||
device_unused_fallback_key_types: None,
|
||||
},
|
||||
account_data,
|
||||
receipts,
|
||||
typing: sync_events::v4::Typing { rooms: BTreeMap::new() },
|
||||
},
|
||||
delta_token: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn filter_rooms<'a>(
|
||||
services: &Services,
|
||||
rooms: &[&'a RoomId],
|
||||
filter: &[RoomTypeFilter],
|
||||
negate: bool,
|
||||
) -> Vec<&'a RoomId> {
|
||||
rooms
|
||||
.iter()
|
||||
.stream()
|
||||
.filter_map(|r| async move {
|
||||
let room_type = services.rooms.state_accessor.get_room_type(r).await;
|
||||
|
||||
if room_type.as_ref().is_err_and(|e| !e.is_not_found()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let room_type_filter = RoomTypeFilter::from(room_type.ok());
|
||||
|
||||
let include = if negate {
|
||||
!filter.contains(&room_type_filter)
|
||||
} else {
|
||||
filter.is_empty() || filter.contains(&room_type_filter)
|
||||
};
|
||||
|
||||
include.then_some(r)
|
||||
})
|
||||
.collect()
|
||||
.await
|
||||
}
|
||||
+13
-77
@@ -1,6 +1,6 @@
|
||||
use std::{
|
||||
cmp::{self, Ordering},
|
||||
collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
|
||||
collections::{BTreeMap, BTreeSet, HashMap, HashSet},
|
||||
ops::Deref,
|
||||
time::Duration,
|
||||
};
|
||||
@@ -31,7 +31,6 @@
|
||||
events::{
|
||||
AnyRawAccountDataEvent, AnySyncEphemeralRoomEvent, StateEventType, TimelineEventType,
|
||||
room::member::{MembershipState, RoomMemberEventContent},
|
||||
typing::TypingEventContent,
|
||||
},
|
||||
serde::Raw,
|
||||
uint,
|
||||
@@ -40,9 +39,7 @@
|
||||
use super::share_encrypted_room;
|
||||
use crate::{
|
||||
Ruma,
|
||||
client::{
|
||||
DEFAULT_BUMP_TYPES, TimelinePdus, ignored_filter, is_ignored_invite, sync::load_timeline,
|
||||
},
|
||||
client::{DEFAULT_BUMP_TYPES, ignored_filter, is_ignored_invite, sync::load_timeline},
|
||||
};
|
||||
|
||||
type SyncInfo<'a> = (&'a UserId, &'a DeviceId, u64, &'a sync_events::v5::Request);
|
||||
@@ -213,9 +210,6 @@ pub(crate) async fn sync_events_v5_route(
|
||||
_ = tokio::time::timeout(duration, watcher).await;
|
||||
}
|
||||
|
||||
let typing = collect_typing_events(services, sender_user, &body, &todo_rooms).await?;
|
||||
response.extensions.typing = typing;
|
||||
|
||||
trace!(
|
||||
rooms = ?response.rooms.len(),
|
||||
account_data = ?response.extensions.account_data.rooms.len(),
|
||||
@@ -299,8 +293,6 @@ async fn handle_lists<'a, Rooms, AllRooms>(
|
||||
Rooms: Iterator<Item = &'a RoomId> + Clone + Send + 'a,
|
||||
AllRooms: Iterator<Item = &'a RoomId> + Clone + Send + 'a,
|
||||
{
|
||||
// TODO MSC4186: Implement remaining list filters: is_dm, is_encrypted,
|
||||
// room_types.
|
||||
for (list_id, list) in &body.lists {
|
||||
let active_rooms: Vec<_> = match list.filters.as_ref().and_then(|f| f.is_invite) {
|
||||
| None => all_rooms.clone().collect(),
|
||||
@@ -417,13 +409,13 @@ async fn process_rooms<'a, Rooms>(
|
||||
.await
|
||||
.ok();
|
||||
|
||||
(timeline_pdus, limited) = (VecDeque::new(), true);
|
||||
(timeline_pdus, limited) = (Vec::new(), true);
|
||||
} else {
|
||||
TimelinePdus { pdus: timeline_pdus, limited } = match load_timeline(
|
||||
(timeline_pdus, limited) = match load_timeline(
|
||||
services,
|
||||
sender_user,
|
||||
room_id,
|
||||
Some(roomsincecount),
|
||||
roomsincecount,
|
||||
Some(PduCount::from(next_batch)),
|
||||
*timeline_limit,
|
||||
)
|
||||
@@ -442,7 +434,7 @@ async fn process_rooms<'a, Rooms>(
|
||||
room_id.to_owned(),
|
||||
services
|
||||
.account_data
|
||||
.changes_since(Some(room_id), sender_user, Some(*roomsince), Some(next_batch))
|
||||
.changes_since(Some(room_id), sender_user, *roomsince, Some(next_batch))
|
||||
.ready_filter_map(|e| extract_variant!(e, AnyRawAccountDataEvent::Room))
|
||||
.collect()
|
||||
.await,
|
||||
@@ -468,11 +460,11 @@ async fn process_rooms<'a, Rooms>(
|
||||
let mut receipts: Vec<Raw<AnySyncEphemeralRoomEvent>> = services
|
||||
.rooms
|
||||
.read_receipt
|
||||
.readreceipts_since(room_id, Some(*roomsince))
|
||||
.readreceipts_since(room_id, *roomsince)
|
||||
.filter_map(|(read_user, _ts, v)| async move {
|
||||
services
|
||||
.users
|
||||
.user_is_ignored(&read_user, sender_user)
|
||||
.user_is_ignored(read_user, sender_user)
|
||||
.await
|
||||
.or_some(v)
|
||||
})
|
||||
@@ -507,7 +499,7 @@ async fn process_rooms<'a, Rooms>(
|
||||
}
|
||||
|
||||
let prev_batch = timeline_pdus
|
||||
.front()
|
||||
.first()
|
||||
.map_or(Ok::<_, Error>(None), |(pdu_count, _)| {
|
||||
Ok(Some(match pdu_count {
|
||||
| PduCount::Backfilled(_) => {
|
||||
@@ -680,62 +672,6 @@ async fn process_rooms<'a, Rooms>(
|
||||
}
|
||||
Ok(rooms)
|
||||
}
|
||||
|
||||
async fn collect_typing_events(
|
||||
services: &Services,
|
||||
sender_user: &UserId,
|
||||
body: &sync_events::v5::Request,
|
||||
todo_rooms: &TodoRooms,
|
||||
) -> Result<sync_events::v5::response::Typing> {
|
||||
if !body.extensions.typing.enabled.unwrap_or(false) {
|
||||
return Ok(sync_events::v5::response::Typing::default());
|
||||
}
|
||||
let rooms: Vec<_> = body.extensions.typing.rooms.clone().unwrap_or_else(|| {
|
||||
body.room_subscriptions
|
||||
.keys()
|
||||
.map(ToOwned::to_owned)
|
||||
.collect()
|
||||
});
|
||||
let lists: Vec<_> = body
|
||||
.extensions
|
||||
.typing
|
||||
.lists
|
||||
.clone()
|
||||
.unwrap_or_else(|| body.lists.keys().map(ToOwned::to_owned).collect::<Vec<_>>());
|
||||
|
||||
if rooms.is_empty() && lists.is_empty() {
|
||||
return Ok(sync_events::v5::response::Typing::default());
|
||||
}
|
||||
|
||||
let mut typing_response = sync_events::v5::response::Typing::default();
|
||||
for (room_id, (_, _, roomsince)) in todo_rooms {
|
||||
if services.rooms.typing.last_typing_update(room_id).await? <= *roomsince {
|
||||
continue;
|
||||
}
|
||||
|
||||
match services
|
||||
.rooms
|
||||
.typing
|
||||
.typing_users_for_user(room_id, sender_user)
|
||||
.await
|
||||
{
|
||||
| Ok(typing_users) => {
|
||||
typing_response.rooms.insert(
|
||||
room_id.to_owned(), // Already OwnedRoomId
|
||||
Raw::new(&sync_events::v5::response::SyncTypingEvent {
|
||||
content: TypingEventContent::new(typing_users),
|
||||
})?,
|
||||
);
|
||||
},
|
||||
| Err(e) => {
|
||||
warn!(%room_id, "Failed to get typing events for room: {}", e);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Ok(typing_response)
|
||||
}
|
||||
|
||||
async fn collect_account_data(
|
||||
services: &Services,
|
||||
(sender_user, _, globalsince, body): (&UserId, &DeviceId, u64, &sync_events::v5::Request),
|
||||
@@ -751,7 +687,7 @@ async fn collect_account_data(
|
||||
|
||||
account_data.global = services
|
||||
.account_data
|
||||
.changes_since(None, sender_user, Some(globalsince), None)
|
||||
.changes_since(None, sender_user, globalsince, None)
|
||||
.ready_filter_map(|e| extract_variant!(e, AnyRawAccountDataEvent::Global))
|
||||
.collect()
|
||||
.await;
|
||||
@@ -762,7 +698,7 @@ async fn collect_account_data(
|
||||
room.clone(),
|
||||
services
|
||||
.account_data
|
||||
.changes_since(Some(room), sender_user, Some(globalsince), None)
|
||||
.changes_since(Some(room), sender_user, globalsince, None)
|
||||
.ready_filter_map(|e| extract_variant!(e, AnyRawAccountDataEvent::Room))
|
||||
.collect()
|
||||
.await,
|
||||
@@ -796,7 +732,7 @@ async fn collect_e2ee<'a, Rooms>(
|
||||
device_list_changes.extend(
|
||||
services
|
||||
.users
|
||||
.keys_changed(sender_user, Some(globalsince), None)
|
||||
.keys_changed(sender_user, globalsince, None)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
.await,
|
||||
@@ -932,7 +868,7 @@ async fn collect_e2ee<'a, Rooms>(
|
||||
device_list_changes.extend(
|
||||
services
|
||||
.users
|
||||
.room_keys_changed(room_id, Some(globalsince), None)
|
||||
.room_keys_changed(room_id, globalsince, None)
|
||||
.map(|(user_id, _)| user_id)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
|
||||
@@ -52,6 +52,7 @@ pub(crate) async fn get_supported_versions_route(
|
||||
("org.matrix.msc3026.busy_presence".to_owned(), true), /* busy presence status (https://github.com/matrix-org/matrix-spec-proposals/pull/3026) */
|
||||
("org.matrix.msc3827".to_owned(), true), /* filtering of /publicRooms by room type (https://github.com/matrix-org/matrix-spec-proposals/pull/3827) */
|
||||
("org.matrix.msc3952_intentional_mentions".to_owned(), true), /* intentional mentions (https://github.com/matrix-org/matrix-spec-proposals/pull/3952) */
|
||||
("org.matrix.msc3575".to_owned(), true), /* sliding sync (https://github.com/matrix-org/matrix-spec-proposals/pull/3575/files#r1588877046) */
|
||||
("org.matrix.msc3916.stable".to_owned(), true), /* authenticated media (https://github.com/matrix-org/matrix-spec-proposals/pull/3916) */
|
||||
("org.matrix.msc4180".to_owned(), true), /* stable flag for 3916 (https://github.com/matrix-org/matrix-spec-proposals/pull/4180) */
|
||||
("uk.tcpip.msc4133".to_owned(), true), /* Extending User Profile API with Key:Value Pairs (https://github.com/matrix-org/matrix-spec-proposals/pull/4133) */
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
use futures::StreamExt;
|
||||
use ruma::api::client::{
|
||||
discovery::{
|
||||
discover_homeserver::{self, HomeserverInfo, SlidingSyncProxyInfo},
|
||||
discover_homeserver::{
|
||||
self, AuthenticationServerInfo, HomeserverInfo, SlidingSyncProxyInfo,
|
||||
},
|
||||
discover_support::{self, Contact},
|
||||
},
|
||||
error::ErrorKind,
|
||||
@@ -26,8 +28,16 @@ pub(crate) async fn well_known_client(
|
||||
Ok(discover_homeserver::Response {
|
||||
homeserver: HomeserverInfo { base_url: client_url.clone() },
|
||||
identity_server: None,
|
||||
sliding_sync_proxy: Some(SlidingSyncProxyInfo { url: client_url }),
|
||||
sliding_sync_proxy: Some(SlidingSyncProxyInfo { url: client_url.clone() }),
|
||||
tile_server: None,
|
||||
authentication: services.config.auth.as_ref().and_then(|auth| {
|
||||
auth.enable_oidc_login
|
||||
.then_some(AuthenticationServerInfo::new(
|
||||
client_url.clone(),
|
||||
auth.enable_oidc_account_management
|
||||
.then_some(format!("{client_url}/account")),
|
||||
))
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,21 @@ pub fn build(router: Router<State>, server: &Server) -> Router<State> {
|
||||
.ruma_route(&client::get_protocols_route)
|
||||
.route("/_matrix/client/unstable/thirdparty/protocols",
|
||||
get(client::get_protocols_route_unstable))
|
||||
// MSC2965: OAuth 2.0 Authorization Server Metadata discovery.
|
||||
.route("/_matrix/client/unstable/org.matrix.msc2965/auth_metadata",
|
||||
get(client::get_auth_metadata))
|
||||
// MSC2964: Usage of OAuth 2.0 authorization code grant and refresh token grant.
|
||||
.route("/_matrix/client/unstable/org.matrix.msc2964/authorize",
|
||||
get(client::authorize))
|
||||
.route("/_matrix/client/unstable/org.matrix.msc2964/authorize",
|
||||
post(client::authorize_consent))
|
||||
.route("/_matrix/client/unstable/org.matrix.msc2964/login",
|
||||
post(client::oidc_login))
|
||||
.route("/_matrix/client/unstable/org.matrix.msc2964/token",
|
||||
post(client::token))
|
||||
// MSC2966: Usage of OAuth 2.0 Dynamic Client Registration in Matrix.
|
||||
.route("/_matrix/client/unstable/org.matrix.msc2964/device/register",
|
||||
post(client::register_client))
|
||||
.ruma_route(&client::send_message_event_route)
|
||||
.ruma_route(&client::send_state_event_for_key_route)
|
||||
.ruma_route(&client::get_state_events_route)
|
||||
@@ -143,6 +158,7 @@ pub fn build(router: Router<State>, server: &Server) -> Router<State> {
|
||||
.put(client::send_state_event_for_empty_key_route),
|
||||
)
|
||||
.ruma_route(&client::sync_events_route)
|
||||
.ruma_route(&client::sync_events_v4_route)
|
||||
.ruma_route(&client::sync_events_v5_route)
|
||||
.ruma_route(&client::get_context_route)
|
||||
.ruma_route(&client::get_message_events_route)
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
use ruma::{
|
||||
CanonicalJsonValue, OwnedUserId, UserId,
|
||||
api::{client::error::ErrorKind, federation::membership::create_invite},
|
||||
events::room::member::{MembershipState, RoomMemberEventContent},
|
||||
serde::JsonObject,
|
||||
};
|
||||
|
||||
@@ -132,21 +133,17 @@ pub(crate) async fn create_invite_route(
|
||||
services
|
||||
.rooms
|
||||
.state_cache
|
||||
.mark_as_invited(
|
||||
&recipient_user,
|
||||
.update_membership(
|
||||
&body.room_id,
|
||||
&recipient_user,
|
||||
RoomMemberEventContent::new(MembershipState::Invite),
|
||||
sender_user,
|
||||
Some(invite_state),
|
||||
body.via.clone(),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
services
|
||||
.rooms
|
||||
.state_cache
|
||||
.update_joined_count(&body.room_id)
|
||||
.await;
|
||||
|
||||
for appservice in services.appservice.read().await.values() {
|
||||
if appservice.is_user_match(&recipient_user) {
|
||||
services
|
||||
|
||||
@@ -289,6 +289,15 @@ pub fn check(config: &Config) -> Result {
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(auth) = &config.auth {
|
||||
if auth.enable_oidc_login && config.well_known.client.is_none() {
|
||||
return Err!(Config(
|
||||
"auth.enable_oidc_login",
|
||||
"OIDC authentication is enabled but the well-known client is not set."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+25
-2
@@ -53,7 +53,7 @@
|
||||
### For more information, see:
|
||||
### https://continuwuity.org/configuration.html
|
||||
"#,
|
||||
ignore = "catchall well_known tls blurhashing allow_invalid_tls_certificates_yes_i_know_what_the_fuck_i_am_doing_with_this_and_i_know_this_is_insecure"
|
||||
ignore = "catchall auth well_known tls blurhashing allow_invalid_tls_certificates_yes_i_know_what_the_fuck_i_am_doing_with_this_and_i_know_this_is_insecure"
|
||||
)]
|
||||
pub struct Config {
|
||||
/// The server_name is the pretty name of this server. It is used as a
|
||||
@@ -62,7 +62,8 @@ pub struct Config {
|
||||
/// See the docs for reverse proxying and delegation:
|
||||
/// https://continuwuity.org/deploying/generic.html#setting-up-the-reverse-proxy
|
||||
///
|
||||
/// Also see the `[global.well_known]` config section at the very bottom.
|
||||
/// Also see the `[global.auth]` and `[global.well_known]` config sections
|
||||
/// at the very bottom.
|
||||
///
|
||||
/// Examples of delegation:
|
||||
/// - https://puppygock.gay/.well-known/matrix/server
|
||||
@@ -104,6 +105,9 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub tls: TlsConfig,
|
||||
|
||||
// external structure; separate section
|
||||
pub auth: Option<AuthConfig>,
|
||||
|
||||
/// The UNIX socket continuwuity will listen on.
|
||||
///
|
||||
/// continuwuity cannot listen on both an IP address and a UNIX socket. If
|
||||
@@ -2020,6 +2024,25 @@ pub struct TlsConfig {
|
||||
pub dual_protocol: bool,
|
||||
}
|
||||
|
||||
#[allow(rustdoc::broken_intra_doc_links, rustdoc::bare_urls)]
|
||||
#[derive(Clone, Debug, Deserialize, Default)]
|
||||
#[config_example_generator(filename = "conduwuit-example.toml", section = "global.auth")]
|
||||
pub struct AuthConfig {
|
||||
/// Use this homeserver as the OIDC authentication reference. It will
|
||||
/// advertise itself as the OIDC authentication issuer to new clients,
|
||||
/// and use the internal user database to answer on the advertised
|
||||
/// endpoints. Note that the legacy Matrix authentication still will be
|
||||
/// reachable.
|
||||
/// Unset by default.
|
||||
pub enable_oidc_login: bool,
|
||||
|
||||
/// Whether this homeserver should provide users with an account management
|
||||
/// interface. Only used if `enable_oidc_login` is set. Note that the
|
||||
/// endpoint is unimplemented at the moment.
|
||||
/// Unset by default.
|
||||
pub enable_oidc_account_management: bool,
|
||||
}
|
||||
|
||||
#[allow(rustdoc::broken_intra_doc_links, rustdoc::bare_urls)]
|
||||
#[derive(Clone, Debug, Deserialize, Default)]
|
||||
#[config_example_generator(filename = "conduwuit-example.toml", section = "global.well_known")]
|
||||
|
||||
@@ -908,7 +908,7 @@ struct GetThirdPartyInvite {
|
||||
false
|
||||
}
|
||||
},
|
||||
| JoinRule::Restricted(_) => {
|
||||
| JoinRule::Restricted(_) =>
|
||||
if membership_allows_join || user_for_join_auth_is_valid {
|
||||
trace!(
|
||||
%sender,
|
||||
@@ -928,8 +928,7 @@ struct GetThirdPartyInvite {
|
||||
valid authorising user given to permit the join"
|
||||
);
|
||||
false
|
||||
}
|
||||
},
|
||||
},
|
||||
| JoinRule::Public => {
|
||||
trace!(%sender, "join rule is public, allowing join");
|
||||
true
|
||||
|
||||
@@ -101,40 +101,40 @@ pub async fn resolve<'a, Pdu, Sets, SetIter, Hasher, Fetch, FetchFut, Exists, Ex
|
||||
debug!(version = ?stateres_version, "State resolution starting");
|
||||
|
||||
// Split non-conflicting and conflicting state
|
||||
let (unconflicted, conflicting) = separate(state_sets.into_iter());
|
||||
let (clean, conflicting) = separate(state_sets.into_iter());
|
||||
|
||||
debug!(count = unconflicted.len(), "non-conflicting events");
|
||||
trace!(map = ?unconflicted, "non-conflicting events");
|
||||
debug!(count = clean.len(), "non-conflicting events");
|
||||
trace!(map = ?clean, "non-conflicting events");
|
||||
|
||||
if conflicting.is_empty() {
|
||||
debug!("no conflicting state found");
|
||||
return Ok(unconflicted);
|
||||
return Ok(clean);
|
||||
}
|
||||
|
||||
debug!(count = conflicting.len(), "conflicting events");
|
||||
trace!(map = ?conflicting, "conflicting events");
|
||||
let (conflicted_state_subgraph, initial_state) =
|
||||
if stateres_version == StateResolutionVersion::V2_1 {
|
||||
let csg = calculate_conflicted_subgraph(&conflicting, event_fetch)
|
||||
let conflicted_state_subgraph: HashSet<_> = match stateres_version {
|
||||
| StateResolutionVersion::V2_1 =>
|
||||
calculate_conflicted_subgraph(&conflicting, event_fetch)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
Error::InvalidPdu("Failed to calculate conflicted subgraph".to_owned())
|
||||
})?;
|
||||
debug!(count = csg.len(), "conflicted subgraph");
|
||||
trace!(set = ?csg, "conflicted subgraph");
|
||||
(csg, HashMap::new())
|
||||
} else {
|
||||
(HashSet::new(), unconflicted.clone())
|
||||
};
|
||||
})?,
|
||||
| _ => HashSet::new(),
|
||||
};
|
||||
debug!(count = conflicted_state_subgraph.len(), "conflicted subgraph");
|
||||
trace!(set = ?conflicted_state_subgraph, "conflicted subgraph");
|
||||
|
||||
let conflicting_values = conflicting.into_values().flatten().stream();
|
||||
|
||||
// `all_conflicted` contains unique items
|
||||
// synapse says `full_set = {eid for eid in full_conflicted_set if eid in
|
||||
// event_map}`
|
||||
// Hydra: Also consider the conflicted state subgraph
|
||||
let all_conflicted: HashSet<_> = get_auth_chain_diff(auth_chain_sets)
|
||||
.chain(conflicting.into_values().flatten().stream())
|
||||
.broad_filter_map(async |id| event_exists(id.clone()).await.then_some(id))
|
||||
.chain(conflicting_values)
|
||||
.chain(conflicted_state_subgraph.into_iter().stream())
|
||||
.broad_filter_map(async |id| event_exists(id.clone()).await.then_some(id))
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
@@ -171,7 +171,7 @@ pub async fn resolve<'a, Pdu, Sets, SetIter, Hasher, Fetch, FetchFut, Exists, Ex
|
||||
&room_version,
|
||||
&stateres_version,
|
||||
sorted_control_levels.iter().stream().map(AsRef::as_ref),
|
||||
initial_state,
|
||||
clean.clone(),
|
||||
&event_fetch,
|
||||
)
|
||||
.await?;
|
||||
@@ -201,7 +201,7 @@ pub async fn resolve<'a, Pdu, Sets, SetIter, Hasher, Fetch, FetchFut, Exists, Ex
|
||||
let power_levels_ty_sk = (StateEventType::RoomPowerLevels, StateKey::new());
|
||||
let power_event = resolved_control.get(&power_levels_ty_sk);
|
||||
|
||||
trace!(event_id = ?power_event, "power event");
|
||||
debug!(event_id = ?power_event, "power event");
|
||||
|
||||
let sorted_left_events =
|
||||
mainline_sort(&events_to_resolve, power_event.cloned(), &event_fetch).await?;
|
||||
@@ -212,13 +212,13 @@ pub async fn resolve<'a, Pdu, Sets, SetIter, Hasher, Fetch, FetchFut, Exists, Ex
|
||||
&room_version,
|
||||
&stateres_version,
|
||||
sorted_left_events.iter().stream().map(AsRef::as_ref),
|
||||
resolved_control, // The control events are added to the final resolved state
|
||||
resolved_control.clone(), // The control events are added to the final resolved state
|
||||
&event_fetch,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Ensure unconflicting state is in the final state
|
||||
resolved_state.extend(unconflicted);
|
||||
resolved_state.extend(clean);
|
||||
|
||||
debug!("state resolution finished");
|
||||
trace!( map = ?resolved_state, "final resolved state" );
|
||||
|
||||
@@ -36,7 +36,7 @@ fn map_ok_or<U, F>(
|
||||
) -> MapOkOrElse<Self, impl FnOnce(Self::Ok) -> U, impl FnOnce(Self::Error) -> U>
|
||||
where
|
||||
F: FnOnce(Self::Ok) -> U,
|
||||
Self: Sized;
|
||||
Self: Send + Sized;
|
||||
|
||||
fn ok(
|
||||
self,
|
||||
@@ -100,7 +100,7 @@ fn map_ok_or<U, F>(
|
||||
) -> MapOkOrElse<Self, impl FnOnce(Self::Ok) -> U, impl FnOnce(Self::Error) -> U>
|
||||
where
|
||||
F: FnOnce(Self::Ok) -> U,
|
||||
Self: Sized,
|
||||
Self: Send + Sized,
|
||||
{
|
||||
self.map_ok_or_else(|_| default, f)
|
||||
}
|
||||
|
||||
@@ -434,6 +434,14 @@ pub(super) fn open_list(db: &Arc<Engine>, maps: &[Descriptor]) -> Result<Maps> {
|
||||
name: "userroomid_notificationcount",
|
||||
..descriptor::RANDOM
|
||||
},
|
||||
Descriptor {
|
||||
name: "client_registrar",
|
||||
..descriptor::RANDOM
|
||||
},
|
||||
Descriptor {
|
||||
name: "deviceid_clientidmap",
|
||||
..descriptor::RANDOM_SMALL
|
||||
},
|
||||
Descriptor {
|
||||
name: "userroomid_invitesender",
|
||||
..descriptor::RANDOM_SMALL
|
||||
|
||||
@@ -125,6 +125,8 @@ ctor.workspace = true
|
||||
[target.'cfg(all(unix, target_os = "linux"))'.dependencies]
|
||||
sd-notify.workspace = true
|
||||
sd-notify.optional = true
|
||||
oxide-auth.workspace = true
|
||||
once_cell.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -129,14 +129,13 @@ pub fn changes_since<'a>(
|
||||
&'a self,
|
||||
room_id: Option<&'a RoomId>,
|
||||
user_id: &'a UserId,
|
||||
since: Option<u64>,
|
||||
since: u64,
|
||||
to: Option<u64>,
|
||||
) -> impl Stream<Item = AnyRawAccountDataEvent> + Send + 'a {
|
||||
type Key<'a> = (Option<&'a RoomId>, &'a UserId, u64, Ignore);
|
||||
|
||||
// Skip the data that's exactly at since, because we sent that last time
|
||||
// ...unless this is an initial sync, in which case send everything
|
||||
let first_possible = (room_id, user_id, since.map_or(0, |since| since.saturating_add(1)));
|
||||
let first_possible = (room_id, user_id, since.saturating_add(1));
|
||||
|
||||
self.db
|
||||
.roomuserdataid_accountdata
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::{cmp, collections::HashMap};
|
||||
use std::cmp;
|
||||
|
||||
use conduwuit::{
|
||||
Err, Pdu, Result, debug, debug_info, debug_warn, error, info,
|
||||
Err, Result, debug, debug_info, debug_warn, error, info,
|
||||
result::NotFound,
|
||||
utils::{
|
||||
IterStream, ReadyExt,
|
||||
@@ -13,16 +13,14 @@
|
||||
use futures::{FutureExt, StreamExt, TryStreamExt};
|
||||
use itertools::Itertools;
|
||||
use ruma::{
|
||||
OwnedRoomId, OwnedUserId, RoomId, UserId,
|
||||
OwnedUserId, RoomId, UserId,
|
||||
events::{
|
||||
GlobalAccountDataEventType, StateEventType, push_rules::PushRulesEvent,
|
||||
room::member::MembershipState,
|
||||
GlobalAccountDataEventType, push_rules::PushRulesEvent, room::member::MembershipState,
|
||||
},
|
||||
push::Ruleset,
|
||||
serde::Raw,
|
||||
};
|
||||
|
||||
use crate::{Services, media, rooms::short::ShortStateHash};
|
||||
use crate::{Services, media};
|
||||
|
||||
/// The current schema version.
|
||||
/// - If database is opened at greater version we reject with error. The
|
||||
@@ -154,14 +152,6 @@ async fn migrate(services: &Services) -> Result<()> {
|
||||
info!("Migration: Bumped database version to 18");
|
||||
}
|
||||
|
||||
if db["global"]
|
||||
.get(POPULATED_USERROOMID_LEFTSTATE_TABLE_MARKER)
|
||||
.await
|
||||
.is_not_found()
|
||||
{
|
||||
populate_userroomid_leftstate_table(services).await?;
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
services.globals.db.database_version().await,
|
||||
DATABASE_VERSION,
|
||||
@@ -466,11 +456,7 @@ async fn retroactively_fix_bad_data_from_roomuserid_joined(services: &Services)
|
||||
|
||||
for user_id in &non_joined_members {
|
||||
debug_info!("User is left or banned, marking as left");
|
||||
services
|
||||
.rooms
|
||||
.state_cache
|
||||
.mark_as_left(user_id, room_id, None)
|
||||
.await;
|
||||
services.rooms.state_cache.mark_as_left(user_id, room_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,78 +624,3 @@ async fn fix_corrupt_msc4133_fields(services: &Services) -> Result {
|
||||
db.db.sort()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const POPULATED_USERROOMID_LEFTSTATE_TABLE_MARKER: &str = "populate_userroomid_leftstate_table";
|
||||
async fn populate_userroomid_leftstate_table(services: &Services) -> Result {
|
||||
type KeyVal<'a> = (Key<'a>, Raw<Option<Pdu>>);
|
||||
type Key<'a> = (&'a UserId, &'a RoomId);
|
||||
|
||||
let db = &services.db;
|
||||
let cork = db.cork_and_sync();
|
||||
let userroomid_leftstate = db["userroomid_leftstate"].clone();
|
||||
|
||||
let (total, fixed, _) = userroomid_leftstate
|
||||
.stream()
|
||||
.try_fold(
|
||||
(0_usize, 0_usize, HashMap::<OwnedRoomId, ShortStateHash>::new()),
|
||||
async |(mut total, mut fixed, mut shortstatehash_cache): (
|
||||
usize,
|
||||
usize,
|
||||
HashMap<_, _>,
|
||||
),
|
||||
((user_id, room_id), state): KeyVal<'_>|
|
||||
-> Result<(usize, usize, HashMap<_, _>)> {
|
||||
if state.deserialize().is_err() {
|
||||
let latest_shortstatehash =
|
||||
if let Some(shortstatehash) = shortstatehash_cache.get(room_id) {
|
||||
*shortstatehash
|
||||
} else if let Ok(shortstatehash) =
|
||||
services.rooms.state.get_room_shortstatehash(room_id).await
|
||||
{
|
||||
shortstatehash_cache.insert(room_id.to_owned(), shortstatehash);
|
||||
shortstatehash
|
||||
} else {
|
||||
warn!(?room_id, ?user_id, "room has no shortstatehash");
|
||||
return Ok((total, fixed, shortstatehash_cache));
|
||||
};
|
||||
|
||||
let leave_state_event = services
|
||||
.rooms
|
||||
.state_accessor
|
||||
.state_get(
|
||||
latest_shortstatehash,
|
||||
&StateEventType::RoomMember,
|
||||
user_id.as_str(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match leave_state_event {
|
||||
| Ok(leave_state_event) => {
|
||||
userroomid_leftstate.put((user_id, room_id), Json(leave_state_event));
|
||||
fixed = fixed.saturating_add(1);
|
||||
},
|
||||
| Err(_) => {
|
||||
warn!(
|
||||
?room_id,
|
||||
?user_id,
|
||||
"room cached as left has no leave event for user, removing \
|
||||
cache entry"
|
||||
);
|
||||
userroomid_leftstate.del((user_id, room_id));
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
total = total.saturating_add(1);
|
||||
Ok((total, fixed, shortstatehash_cache))
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
drop(cork);
|
||||
info!(?total, ?fixed, "Fixed entries in `userroomid_leftstate`.");
|
||||
|
||||
db["global"].insert(POPULATED_USERROOMID_LEFTSTATE_TABLE_MARKER, []);
|
||||
db.db.sort()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
pub mod key_backups;
|
||||
pub mod media;
|
||||
pub mod moderation;
|
||||
pub mod oidc;
|
||||
pub mod presence;
|
||||
pub mod pusher;
|
||||
pub mod resolver;
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
//! OIDC service.
|
||||
//!
|
||||
//! Provides the registrar, authorizer and issuer needed by [api::client::oidc].
|
||||
//! The whole OIDC OAuth2 flow is taken care of by [oxide-auth].
|
||||
//!
|
||||
//! [oxide-auth]: https://docs.rs/oxide-auth
|
||||
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use conduwuit::{Result, err};
|
||||
use conduwuit_core::utils;
|
||||
use database::{Deserialized, Json, Map};
|
||||
use oxide_auth::{
|
||||
endpoint::{PreGrant, Scope},
|
||||
frontends::simple::endpoint::{Generic, Vacant},
|
||||
primitives::{
|
||||
grant::Grant,
|
||||
prelude::{
|
||||
AuthMap, Authorizer, Client, ClientUrl, Issuer, RandomGenerator, Registrar, TokenMap,
|
||||
},
|
||||
registrar::{
|
||||
Argon2, BoundClient, EncodedClient, RegisteredClient, RegisteredUrl, RegistrarError,
|
||||
},
|
||||
},
|
||||
};
|
||||
use ruma::{
|
||||
MilliSecondsSinceUnixEpoch, OwnedDeviceId, OwnedUserId, UserId, api::client::device::Device,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
use crate::{Dep, globals};
|
||||
|
||||
pub const SCOPE_PREFIX_DEVICE: &str = "urn:matrix:org.matrix.msc2967.client:device:";
|
||||
pub const SCOPE_PREFIX_API: &str = "urn:matrix:org.matrix.msc2967.client:api:";
|
||||
|
||||
static PASSWORD_POLICY: std::sync::LazyLock<Argon2> = std::sync::LazyLock::new(Argon2::default);
|
||||
|
||||
/// A client app that connects to continuwuity via OIDC, as recorded in the
|
||||
/// database.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct OidcClient {
|
||||
/// The name published by the app itself.
|
||||
pub name: Option<String>,
|
||||
/// A device id that we'll generate on OIDC registration.
|
||||
pub device_id: Option<String>,
|
||||
/// The device's coordinates recorded by oxide-auth.
|
||||
pub client: EncodedClient,
|
||||
}
|
||||
|
||||
struct Services {
|
||||
globals: Dep<globals::Service>,
|
||||
}
|
||||
|
||||
struct Data {
|
||||
client_registrar: Arc<Map>,
|
||||
deviceid_clientidmap: Arc<Map>,
|
||||
userid_devicelistversion: Arc<Map>,
|
||||
userdeviceid_metadata: Arc<Map>,
|
||||
}
|
||||
|
||||
pub struct Service {
|
||||
/// Authorization tokens are 16 byte random keys to a memory hash map.
|
||||
///
|
||||
/// Will be reinitialised on continuwuity's restart.
|
||||
authorizer: Mutex<AuthMap<RandomGenerator>>,
|
||||
/// Bearer tokens are also random generated but 256-bit tokens, since they
|
||||
/// live longer.
|
||||
///
|
||||
/// We could also use a `TokenSigner::ephemeral` here to create signed
|
||||
/// tokens which can be read and parsed by anyone, but not maliciously
|
||||
/// created. However, they can not be revoked and thus don't offer even
|
||||
/// longer lived refresh tokens.
|
||||
///
|
||||
/// Will be reinitialised on continuwuity's restart.
|
||||
issuer: Mutex<TokenMap<RandomGenerator>>,
|
||||
services: Services,
|
||||
db: Data,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl crate::Service for Service {
|
||||
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
|
||||
// TODO implement authorizer and issuer inside database so that token
|
||||
// requests survive server restarts.
|
||||
Ok(Arc::new(Self {
|
||||
authorizer: Mutex::new(AuthMap::new(RandomGenerator::new(16))),
|
||||
issuer: Mutex::new(TokenMap::new(RandomGenerator::new(16))),
|
||||
services: Services {
|
||||
globals: args.depend::<globals::Service>("globals"),
|
||||
},
|
||||
db: Data {
|
||||
client_registrar: args.db["client_registrar"].clone(),
|
||||
deviceid_clientidmap: args.db["deviceid_clientidmap"].clone(),
|
||||
userid_devicelistversion: args.db["userid_devicelistversion"].clone(),
|
||||
userdeviceid_metadata: args.db["userdeviceid_metadata"].clone(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
|
||||
}
|
||||
|
||||
impl Service {
|
||||
/// Register an OIDC client in the client_registrar for future
|
||||
/// authentication flows.
|
||||
pub fn register_client(&self, display_name: Option<String>, client: &Client) {
|
||||
let client = client.clone().encode(&*PASSWORD_POLICY);
|
||||
let client_id = client.client_id.clone();
|
||||
let oidc_client = OidcClient {
|
||||
name: display_name,
|
||||
// Matrix clients have no device_id at registration time.
|
||||
device_id: None,
|
||||
client,
|
||||
};
|
||||
self.db.client_registrar.put(client_id, Json(oidc_client));
|
||||
}
|
||||
|
||||
/// Register a device in the main continuwuity database. This should only
|
||||
/// happen on successful authentication and consent, and will register the
|
||||
/// client's device_id.
|
||||
pub async fn register_device(
|
||||
&self,
|
||||
client_id: &str,
|
||||
(user_id, device_id): (&OwnedUserId, &OwnedDeviceId),
|
||||
display_name: Option<&str>,
|
||||
client_ip: Option<String>,
|
||||
) -> Result<()> {
|
||||
let device_key = (user_id, device_id);
|
||||
let device = Device {
|
||||
device_id: device_id.into(),
|
||||
display_name: display_name.map(ToOwned::to_owned),
|
||||
last_seen_ip: client_ip,
|
||||
last_seen_ts: Some(MilliSecondsSinceUnixEpoch::now()),
|
||||
};
|
||||
increment(&self.db.userid_devicelistversion, user_id.as_bytes()).await;
|
||||
self.db.userdeviceid_metadata.put(device_key, Json(device));
|
||||
|
||||
let mut client: OidcClient = self
|
||||
.db
|
||||
.client_registrar
|
||||
.get(client_id)
|
||||
.await?
|
||||
.deserialized()?;
|
||||
client.device_id = Some(device_id.to_string());
|
||||
self.db
|
||||
.client_registrar
|
||||
.put(client_id.to_owned(), Json(client));
|
||||
|
||||
self.db
|
||||
.deviceid_clientidmap
|
||||
.put(device_id, client_id.to_owned());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn grant_from_token(&self, token: &str) -> Option<Grant> {
|
||||
let issuer = self.issuer.lock().expect("lockable issuer");
|
||||
|
||||
issuer
|
||||
.recover_token(token)
|
||||
.expect("infallible recover_token implementation")
|
||||
}
|
||||
|
||||
pub async fn client_from_client_id(&self, client_id: &str) -> Result<Option<OidcClient>> {
|
||||
self.db
|
||||
.client_registrar
|
||||
.get(client_id)
|
||||
.await?
|
||||
.deserialized()
|
||||
}
|
||||
|
||||
pub async fn client_from_device_id(
|
||||
&self,
|
||||
device_id: OwnedDeviceId,
|
||||
) -> Result<Option<OidcClient>> {
|
||||
let client_id: String = self
|
||||
.db
|
||||
.deviceid_clientidmap
|
||||
.get(&device_id)
|
||||
.await?
|
||||
.deserialized()?;
|
||||
|
||||
self.db
|
||||
.client_registrar
|
||||
.get(&client_id)
|
||||
.await?
|
||||
.deserialized()
|
||||
}
|
||||
|
||||
pub fn device_id_from_scope(&self, scope: &Scope) -> Result<OwnedDeviceId> {
|
||||
let Some(device_id) = scope.iter().find(|s| s.starts_with(SCOPE_PREFIX_DEVICE)) else {
|
||||
tracing::warn!("device_id not found in scope {scope:?}");
|
||||
return Err(err!(Request(InvalidParam("something went wrong with the scope"))));
|
||||
};
|
||||
let device_id = device_id.replace(SCOPE_PREFIX_DEVICE, "");
|
||||
|
||||
Ok(device_id.into())
|
||||
}
|
||||
|
||||
pub async fn user_and_device_from_token(
|
||||
&self,
|
||||
token: &str,
|
||||
) -> Result<(OwnedUserId, OwnedDeviceId)> {
|
||||
let Some(Grant { owner_id, client_id, .. }) = self.grant_from_token(token) else {
|
||||
return Err(err!(Request(MissingToken("unknown token: {token:?}"))));
|
||||
};
|
||||
let server_name = self.services.globals.server_name();
|
||||
let owner_id =
|
||||
UserId::parse_with_server_name(owner_id.clone(), server_name).map_err(|err| {
|
||||
err!(Request(InvalidUsername("invalid username {owner_id:?}: {err}")))
|
||||
})?;
|
||||
let client = self
|
||||
.client_from_client_id(&client_id)
|
||||
.await?
|
||||
.expect("validated client_id");
|
||||
let Some(device_id) = client.device_id else {
|
||||
return Err(err!(Request(Unknown("this client has no device_id yet"))));
|
||||
};
|
||||
let device_id = OwnedDeviceId::from(device_id);
|
||||
|
||||
Ok((owner_id, device_id))
|
||||
}
|
||||
|
||||
/// The oxide-auth carry-all endpoint.
|
||||
pub fn endpoint(
|
||||
&self,
|
||||
) -> Generic<impl Registrar + '_, impl Authorizer + '_, impl Issuer + '_> {
|
||||
Generic {
|
||||
registrar: self,
|
||||
authorizer: self.authorizer.lock().unwrap(),
|
||||
issuer: self.issuer.lock().unwrap(),
|
||||
// Solicitor configured later.
|
||||
solicitor: Vacant,
|
||||
// Scope configured later.
|
||||
scopes: Vacant,
|
||||
response: Vacant,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn increment(db: &Arc<Map>, key: &[u8]) {
|
||||
let old = db.get(key).await;
|
||||
let new = utils::increment(old.ok().as_deref());
|
||||
db.insert(key, new);
|
||||
}
|
||||
|
||||
/// Substitute "127.0.0.1" and "[::1]" for "localhost" to let oxide-auth compare
|
||||
/// them ignoring their port.
|
||||
pub fn normalize_redirect_hostname(url: &mut Url) {
|
||||
let new_host = url.host_str().map(|h| {
|
||||
h.replace("127.0.0.1", "localhost")
|
||||
.replace("[::1]", "localhost")
|
||||
});
|
||||
|
||||
url.set_host(new_host.as_deref())
|
||||
.expect("replaceable redirect hostname");
|
||||
}
|
||||
|
||||
/// If `url` is a localhost (either 'localhost', '127.0.0.1' or '[::1]'), wrap
|
||||
/// it in an `IgnorePortOnLocalhost`, so that oxide-auth ignores the port when
|
||||
/// comparing it with the registered ones.
|
||||
#[must_use]
|
||||
pub fn normalize_redirect(mut url: Url) -> RegisteredUrl {
|
||||
normalize_redirect_hostname(&mut url);
|
||||
|
||||
match url.host_str() {
|
||||
| Some("localhost") => RegisteredUrl::IgnorePortOnLocalhost(url.into()),
|
||||
| _ => RegisteredUrl::Semantic(url),
|
||||
}
|
||||
}
|
||||
|
||||
/// Let this service act as an oxide-auth `Registrar`.
|
||||
impl Registrar for Service {
|
||||
fn bound_redirect<'a>(
|
||||
&self,
|
||||
bound: ClientUrl<'a>,
|
||||
) -> Result<BoundClient<'a>, RegistrarError> {
|
||||
let client_handle = self
|
||||
.db
|
||||
.client_registrar
|
||||
.get_blocking(bound.client_id.as_ref())
|
||||
.map_err(|_| RegistrarError::Unspecified)?;
|
||||
let oidc_client: OidcClient = client_handle
|
||||
.deserialized()
|
||||
.map_err(|_| RegistrarError::Unspecified)?;
|
||||
let client = oidc_client.client;
|
||||
// Perform exact matching as motivated in the rfc, but substitute
|
||||
// "127.0.0.1" and "[::1]" for "localhost" to let oxide-auth ignore
|
||||
// their port.
|
||||
let redirect_uri = bound.redirect_uri;
|
||||
let normalized_uri = redirect_uri.clone().map(|u| normalize_redirect(u.to_url()));
|
||||
let redirect_uri = match normalized_uri {
|
||||
| None => client.redirect_uri,
|
||||
| Some(url) => {
|
||||
let original = std::iter::once(&client.redirect_uri);
|
||||
let alternatives = client.additional_redirect_uris.iter();
|
||||
if original
|
||||
.chain(alternatives)
|
||||
.any(|registered| *registered == url)
|
||||
{
|
||||
// If normalized_uri is Some(url), so is redirect_uri, so unwrap().
|
||||
redirect_uri.unwrap().into_owned().into()
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"the request's redirect url didn't match any registered. bound: {:?}, \
|
||||
in client {:#?}",
|
||||
url,
|
||||
client
|
||||
);
|
||||
return Err(RegistrarError::Unspecified);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Ok(BoundClient {
|
||||
client_id: bound.client_id,
|
||||
redirect_uri: Cow::Owned(redirect_uri),
|
||||
})
|
||||
}
|
||||
|
||||
fn negotiate(
|
||||
&self,
|
||||
bound: BoundClient<'_>,
|
||||
_scope: Option<Scope>,
|
||||
) -> Result<PreGrant, RegistrarError> {
|
||||
let client_handle = self
|
||||
.db
|
||||
.client_registrar
|
||||
.get_blocking(bound.client_id.as_ref())
|
||||
.map_err(|_| RegistrarError::Unspecified)?;
|
||||
let oidc_client: OidcClient = client_handle
|
||||
.deserialized()
|
||||
.map_err(|_| RegistrarError::Unspecified)?;
|
||||
|
||||
Ok(PreGrant {
|
||||
client_id: bound.client_id.into_owned(),
|
||||
redirect_uri: bound.redirect_uri.into_owned(),
|
||||
// Always use the client's scope.
|
||||
scope: oidc_client.client.default_scope,
|
||||
})
|
||||
}
|
||||
|
||||
fn check(&self, client_id: &str, passphrase: Option<&[u8]>) -> Result<(), RegistrarError> {
|
||||
let client_handle = self
|
||||
.db
|
||||
.client_registrar
|
||||
.get_blocking(client_id)
|
||||
.map_err(|_| RegistrarError::Unspecified)?;
|
||||
let oidc_client: OidcClient = client_handle
|
||||
.deserialized()
|
||||
.map_err(|_| RegistrarError::Unspecified)?;
|
||||
let client = oidc_client.client;
|
||||
|
||||
RegisteredClient::new(&client, &*PASSWORD_POLICY).check_authentication(passphrase)
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ pub enum Status {
|
||||
Seen(u64),
|
||||
}
|
||||
|
||||
pub type MemberSet = HashSet<OwnedUserId>;
|
||||
pub type Witness = HashSet<OwnedUserId>;
|
||||
type Key<'a> = (&'a UserId, Option<&'a DeviceId>, &'a RoomId, &'a UserId);
|
||||
|
||||
impl crate::Service for Service {
|
||||
@@ -67,11 +67,9 @@ pub async fn reset(&self, ctx: &Context<'_>) {
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Returns only the subset of `senders` which should be sent to the client
|
||||
/// according to the provided lazy loading context.
|
||||
#[implement(Service)]
|
||||
#[tracing::instrument(name = "retain", level = "debug", skip_all)]
|
||||
pub async fn retain_lazy_members(&self, senders: MemberSet, ctx: &Context<'_>) -> MemberSet {
|
||||
pub async fn witness_retain(&self, senders: Witness, ctx: &Context<'_>) -> Witness {
|
||||
debug_assert!(
|
||||
ctx.options.is_none_or(Options::is_enabled),
|
||||
"lazy loading should be enabled by your options"
|
||||
@@ -86,7 +84,7 @@ pub async fn retain_lazy_members(&self, senders: MemberSet, ctx: &Context<'_>) -
|
||||
|
||||
pin_mut!(witness);
|
||||
let _cork = self.db.db.cork();
|
||||
let mut senders = MemberSet::with_capacity(senders.len());
|
||||
let mut senders = Witness::with_capacity(senders.len());
|
||||
while let Some((status, sender)) = witness.next().await {
|
||||
if include_redundant || status == Status::Unseen {
|
||||
senders.insert(sender.into());
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
use database::{Deserialized, Json, Map};
|
||||
use futures::{Stream, StreamExt};
|
||||
use ruma::{
|
||||
CanonicalJsonObject, OwnedUserId, RoomId, UserId,
|
||||
CanonicalJsonObject, RoomId, UserId,
|
||||
events::{AnySyncEphemeralRoomEvent, receipt::ReceiptEvent},
|
||||
serde::Raw,
|
||||
};
|
||||
@@ -25,7 +25,7 @@ struct Services {
|
||||
globals: Dep<globals::Service>,
|
||||
}
|
||||
|
||||
pub(super) type ReceiptItem = (OwnedUserId, u64, Raw<AnySyncEphemeralRoomEvent>);
|
||||
pub(super) type ReceiptItem<'a> = (&'a UserId, u64, Raw<AnySyncEphemeralRoomEvent>);
|
||||
|
||||
impl Data {
|
||||
pub(super) fn new(args: &crate::Args<'_>) -> Self {
|
||||
@@ -65,7 +65,7 @@ pub(super) fn readreceipts_since<'a>(
|
||||
&'a self,
|
||||
room_id: &'a RoomId,
|
||||
since: u64,
|
||||
) -> impl Stream<Item = ReceiptItem> + Send + 'a {
|
||||
) -> impl Stream<Item = ReceiptItem<'a>> + Send + 'a {
|
||||
type Key<'a> = (&'a RoomId, u64, &'a UserId);
|
||||
type KeyVal<'a> = (Key<'a>, CanonicalJsonObject);
|
||||
|
||||
@@ -81,7 +81,7 @@ pub(super) fn readreceipts_since<'a>(
|
||||
|
||||
let event = serde_json::value::to_raw_value(&json)?;
|
||||
|
||||
Ok((user_id.to_owned(), count, Raw::from_json(event)))
|
||||
Ok((user_id, count, Raw::from_json(event)))
|
||||
})
|
||||
.ignore_err()
|
||||
}
|
||||
|
||||
@@ -104,16 +104,16 @@ pub async fn private_read_get(
|
||||
Ok(Raw::from_json(event))
|
||||
}
|
||||
|
||||
/// Returns an iterator over the most recent read_receipts in a room,
|
||||
/// optionally after the event with id `since`.
|
||||
/// Returns an iterator over the most recent read_receipts in a room that
|
||||
/// happened after the event with id `since`.
|
||||
#[inline]
|
||||
#[tracing::instrument(skip(self), level = "debug")]
|
||||
pub fn readreceipts_since<'a>(
|
||||
&'a self,
|
||||
room_id: &'a RoomId,
|
||||
since: Option<u64>,
|
||||
) -> impl Stream<Item = ReceiptItem> + Send + 'a {
|
||||
self.db.readreceipts_since(room_id, since.unwrap_or(0))
|
||||
since: u64,
|
||||
) -> impl Stream<Item = ReceiptItem<'a>> + Send + 'a {
|
||||
self.db.readreceipts_since(room_id, since)
|
||||
}
|
||||
|
||||
/// Sets a private read marker at PDU `count`.
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
use std::{borrow::Borrow, fmt::Debug, mem::size_of_val, sync::Arc};
|
||||
|
||||
pub use conduwuit::matrix::pdu::{ShortEventId, ShortId, ShortRoomId, ShortStateKey};
|
||||
use conduwuit::{
|
||||
Result, err, implement,
|
||||
matrix::StateKey,
|
||||
pair_of,
|
||||
utils::{self, IterStream, ReadyExt},
|
||||
};
|
||||
use conduwuit::{Result, err, implement, matrix::StateKey, utils, utils::IterStream};
|
||||
use database::{Deserialized, Get, Map, Qry};
|
||||
use futures::{
|
||||
Stream, StreamExt,
|
||||
stream::{self},
|
||||
};
|
||||
use ruma::{EventId, OwnedEventId, RoomId, events::StateEventType};
|
||||
use futures::{Stream, StreamExt};
|
||||
use ruma::{EventId, RoomId, events::StateEventType};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{Dep, globals};
|
||||
@@ -266,23 +258,3 @@ pub async fn get_or_create_shortroomid(&self, room_id: &RoomId) -> ShortRoomId {
|
||||
short
|
||||
})
|
||||
}
|
||||
|
||||
#[implement(Service)]
|
||||
pub async fn multi_get_state_from_short<'a, S>(
|
||||
&'a self,
|
||||
short_state: S,
|
||||
) -> impl Stream<Item = Result<((StateEventType, StateKey), OwnedEventId)>> + Send + 'a
|
||||
where
|
||||
S: Stream<Item = (ShortStateKey, ShortEventId)> + Send + 'a,
|
||||
{
|
||||
let (short_state_keys, short_event_ids): pair_of!(Vec<_>) = short_state.unzip().await;
|
||||
|
||||
StreamExt::zip(
|
||||
self.multi_get_statekey_from_short(stream::iter(short_state_keys.into_iter())),
|
||||
self.multi_get_eventid_from_short(stream::iter(short_event_ids.into_iter())),
|
||||
)
|
||||
.ready_filter_map(|state_event| match state_event {
|
||||
| (Ok(state_key), Ok(event_id)) => Some(Ok((state_key, event_id))),
|
||||
| (Err(e), _) | (_, Err(e)) => Some(Err(e)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
EventId, OwnedEventId, OwnedRoomId, RoomId, RoomVersionId, UserId,
|
||||
events::{
|
||||
AnyStrippedStateEvent, StateEventType, TimelineEventType,
|
||||
room::create::RoomCreateEventContent,
|
||||
room::{create::RoomCreateEventContent, member::RoomMemberEventContent},
|
||||
},
|
||||
serde::Raw,
|
||||
};
|
||||
@@ -126,9 +126,21 @@ pub async fn force_state(
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(membership_event) = pdu.get_content::<RoomMemberEventContent>() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
self.services
|
||||
.state_cache
|
||||
.update_membership(room_id, user_id, &pdu, false)
|
||||
.update_membership(
|
||||
room_id,
|
||||
user_id,
|
||||
membership_event,
|
||||
&pdu.sender,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
},
|
||||
| TimelineEventType::SpaceChild => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::{borrow::Borrow, ops::Deref, sync::Arc};
|
||||
|
||||
use conduwuit::{
|
||||
Pdu, Result, at, err, implement,
|
||||
Result, at, err, implement,
|
||||
matrix::{Event, StateKey},
|
||||
pair_of,
|
||||
utils::{
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
};
|
||||
use database::Deserialized;
|
||||
use futures::{FutureExt, Stream, StreamExt, TryFutureExt, pin_mut};
|
||||
use futures::{FutureExt, Stream, StreamExt, TryFutureExt, future::try_join, pin_mut};
|
||||
use ruma::{
|
||||
EventId, OwnedEventId, UserId,
|
||||
events::{
|
||||
@@ -125,7 +125,7 @@ pub async fn state_get(
|
||||
shortstatehash: ShortStateHash,
|
||||
event_type: &StateEventType,
|
||||
state_key: &str,
|
||||
) -> Result<Pdu> {
|
||||
) -> Result<impl Event> {
|
||||
self.state_get_id(shortstatehash, event_type, state_key)
|
||||
.and_then(async |event_id: OwnedEventId| self.services.timeline.get_pdu(&event_id).await)
|
||||
.await
|
||||
@@ -286,28 +286,28 @@ pub fn state_keys<'a>(
|
||||
/// not in .1)
|
||||
#[implement(super::Service)]
|
||||
#[inline]
|
||||
pub async fn state_removed(
|
||||
pub fn state_removed(
|
||||
&self,
|
||||
shortstatehash: pair_of!(ShortStateHash),
|
||||
) -> Result<Vec<(ShortStateKey, ShortEventId)>> {
|
||||
self.state_added((shortstatehash.1, shortstatehash.0)).await
|
||||
) -> impl Stream<Item = (ShortStateKey, ShortEventId)> + Send + '_ {
|
||||
self.state_added((shortstatehash.1, shortstatehash.0))
|
||||
}
|
||||
|
||||
/// Returns the state events added between the interval (present in .1 but
|
||||
/// not in .0)
|
||||
#[implement(super::Service)]
|
||||
pub async fn state_added(
|
||||
pub fn state_added(
|
||||
&self,
|
||||
shortstatehash: pair_of!(ShortStateHash),
|
||||
) -> Result<Vec<(ShortStateKey, ShortEventId)>> {
|
||||
let full_state_a = self.load_full_state(shortstatehash.0).await?;
|
||||
let full_state_b = self.load_full_state(shortstatehash.1).await?;
|
||||
|
||||
Ok(full_state_b
|
||||
.difference(&full_state_a)
|
||||
.copied()
|
||||
) -> impl Stream<Item = (ShortStateKey, ShortEventId)> + Send + '_ {
|
||||
let a = self.load_full_state(shortstatehash.0);
|
||||
let b = self.load_full_state(shortstatehash.1);
|
||||
try_join(a, b)
|
||||
.map_ok(|(a, b)| b.difference(&a).copied().collect::<Vec<_>>())
|
||||
.map_ok(IterStream::try_stream)
|
||||
.try_flatten_stream()
|
||||
.ignore_err()
|
||||
.map(parse_compressed_state_event)
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[implement(super::Service)]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use conduwuit::{
|
||||
Pdu, Result, SyncRwLock, implement,
|
||||
Result, SyncRwLock, implement,
|
||||
result::LogErr,
|
||||
utils::{ReadyExt, stream::TryIgnore},
|
||||
warn,
|
||||
@@ -13,7 +13,7 @@
|
||||
use futures::{Stream, StreamExt, future::join5, pin_mut};
|
||||
use ruma::{
|
||||
OwnedRoomId, OwnedUserId, RoomId, ServerName, UserId,
|
||||
events::{AnyStrippedStateEvent, room::member::MembershipState},
|
||||
events::{AnyStrippedStateEvent, AnySyncStateEvent, room::member::MembershipState},
|
||||
serde::Raw,
|
||||
};
|
||||
|
||||
@@ -54,6 +54,7 @@ struct Data {
|
||||
|
||||
type AppServiceInRoomCache = SyncRwLock<HashMap<OwnedRoomId, HashMap<String, bool>>>;
|
||||
type StrippedStateEventItem = (OwnedRoomId, Vec<Raw<AnyStrippedStateEvent>>);
|
||||
type SyncStateEventItem = (OwnedRoomId, Vec<Raw<AnySyncStateEvent>>);
|
||||
|
||||
impl crate::Service for Service {
|
||||
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
|
||||
@@ -430,9 +431,18 @@ pub async fn knock_state(
|
||||
|
||||
#[implement(Service)]
|
||||
#[tracing::instrument(skip(self), level = "trace")]
|
||||
pub async fn left_state(&self, user_id: &UserId, room_id: &RoomId) -> Result<Option<Pdu>> {
|
||||
pub async fn left_state(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
room_id: &RoomId,
|
||||
) -> Result<Vec<Raw<AnyStrippedStateEvent>>> {
|
||||
let key = (user_id, room_id);
|
||||
self.db.userroomid_leftstate.qry(&key).await.deserialized()
|
||||
self.db
|
||||
.userroomid_leftstate
|
||||
.qry(&key)
|
||||
.await
|
||||
.deserialized()
|
||||
.and_then(|val: Raw<Vec<AnyStrippedStateEvent>>| val.deserialize_as().map_err(Into::into))
|
||||
}
|
||||
|
||||
/// Returns an iterator over all rooms a user left.
|
||||
@@ -441,8 +451,8 @@ pub async fn left_state(&self, user_id: &UserId, room_id: &RoomId) -> Result<Opt
|
||||
pub fn rooms_left<'a>(
|
||||
&'a self,
|
||||
user_id: &'a UserId,
|
||||
) -> impl Stream<Item = (OwnedRoomId, Option<Pdu>)> + Send + 'a {
|
||||
type KeyVal<'a> = (Key<'a>, Raw<Option<Pdu>>);
|
||||
) -> impl Stream<Item = SyncStateEventItem> + Send + 'a {
|
||||
type KeyVal<'a> = (Key<'a>, Raw<Vec<Raw<AnySyncStateEvent>>>);
|
||||
type Key<'a> = (&'a UserId, &'a RoomId);
|
||||
|
||||
let prefix = (user_id, Interfix);
|
||||
@@ -451,7 +461,7 @@ pub fn rooms_left<'a>(
|
||||
.stream_prefix(&prefix)
|
||||
.ignore_err()
|
||||
.map(|((_, room_id), state): KeyVal<'_>| (room_id.to_owned(), state))
|
||||
.map(|(room_id, state)| Ok((room_id, state.deserialize()?)))
|
||||
.map(|(room_id, state)| Ok((room_id, state.deserialize_as()?)))
|
||||
.ignore_err()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use conduwuit::{Err, Event, Pdu, Result, implement, is_not_empty, utils::ReadyExt, warn};
|
||||
use conduwuit::{Err, Result, implement, is_not_empty, utils::ReadyExt, warn};
|
||||
use database::{Json, serialize_key};
|
||||
use futures::StreamExt;
|
||||
use ruma::{
|
||||
OwnedServerName, RoomId, UserId,
|
||||
events::{
|
||||
AnyStrippedStateEvent, GlobalAccountDataEventType, RoomAccountDataEventType,
|
||||
StateEventType,
|
||||
AnyStrippedStateEvent, AnySyncStateEvent, GlobalAccountDataEventType,
|
||||
RoomAccountDataEventType, StateEventType,
|
||||
direct::DirectEvent,
|
||||
invite_permission_config::FilterLevel,
|
||||
room::{
|
||||
@@ -26,7 +26,8 @@
|
||||
fields(
|
||||
%room_id,
|
||||
%user_id,
|
||||
?pdu,
|
||||
%sender,
|
||||
?membership_event,
|
||||
),
|
||||
)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -34,10 +35,13 @@ pub async fn update_membership(
|
||||
&self,
|
||||
room_id: &RoomId,
|
||||
user_id: &UserId,
|
||||
pdu: &Pdu,
|
||||
membership_event: RoomMemberEventContent,
|
||||
sender: &UserId,
|
||||
last_state: Option<Vec<Raw<AnyStrippedStateEvent>>>,
|
||||
invite_via: Option<Vec<OwnedServerName>>,
|
||||
update_joined_count: bool,
|
||||
) -> Result {
|
||||
let membership = pdu.get_content::<RoomMemberEventContent>()?;
|
||||
let membership = membership_event.membership;
|
||||
|
||||
// Keep track what remote users exist by adding them as "deactivated" users
|
||||
//
|
||||
@@ -50,7 +54,7 @@ pub async fn update_membership(
|
||||
}
|
||||
}
|
||||
|
||||
match &membership.membership {
|
||||
match &membership {
|
||||
| MembershipState::Join => {
|
||||
// Check if the user never joined this room
|
||||
if !self.once_joined(user_id, room_id).await {
|
||||
@@ -118,14 +122,33 @@ pub async fn update_membership(
|
||||
self.mark_as_joined(user_id, room_id);
|
||||
},
|
||||
| MembershipState::Invite => {
|
||||
// TODO: make sure that passing None for `last_state` is correct behavior.
|
||||
// the call from `append_pdu` used to use `services.state.summary_stripped`
|
||||
// to fill that parameter.
|
||||
self.mark_as_invited(user_id, room_id, pdu.sender(), None, None)
|
||||
.await?;
|
||||
// return an error for blocked invites. ignored invites aren't handled here
|
||||
// since the recipient's membership should still be changed to `invite`.
|
||||
// they're filtered out in the individual /sync handlers
|
||||
if matches!(
|
||||
self.services
|
||||
.users
|
||||
.invite_filter_level(sender, user_id)
|
||||
.await,
|
||||
FilterLevel::Block
|
||||
) {
|
||||
return Err!(Request(InviteBlocked(
|
||||
"{user_id} has blocked invites from {sender}."
|
||||
)));
|
||||
}
|
||||
self.mark_as_invited(user_id, room_id, sender, last_state, invite_via)
|
||||
.await;
|
||||
},
|
||||
| MembershipState::Leave | MembershipState::Ban => {
|
||||
self.mark_as_left(user_id, room_id, Some(pdu.clone())).await;
|
||||
self.mark_as_left(user_id, room_id);
|
||||
|
||||
if self.services.globals.user_is_local(user_id)
|
||||
&& (self.services.config.forget_forced_upon_leave
|
||||
|| self.services.metadata.is_banned(room_id).await
|
||||
|| self.services.metadata.is_disabled(room_id).await)
|
||||
{
|
||||
self.forget(room_id, user_id);
|
||||
}
|
||||
},
|
||||
| _ => {},
|
||||
}
|
||||
@@ -229,24 +252,24 @@ pub fn mark_as_joined(&self, user_id: &UserId, room_id: &RoomId) {
|
||||
self.db.roomid_inviteviaservers.remove(room_id);
|
||||
}
|
||||
|
||||
/// Mark a user as having left a room.
|
||||
///
|
||||
/// `leave_pdu` represents the m.room.member event which the user sent to leave
|
||||
/// the room. If this is None, no event was actually sent, but we must still
|
||||
/// behave as if the user is no longer in the room. This may occur, for example,
|
||||
/// if the room being left has been server-banned by an administrator.
|
||||
/// Direct DB function to directly mark a user as left. It is not
|
||||
/// recommended to use this directly. You most likely should use
|
||||
/// `update_membership` instead
|
||||
#[implement(super::Service)]
|
||||
#[tracing::instrument(skip(self), level = "debug")]
|
||||
pub async fn mark_as_left(&self, user_id: &UserId, room_id: &RoomId, leave_pdu: Option<Pdu>) {
|
||||
pub fn mark_as_left(&self, user_id: &UserId, room_id: &RoomId) {
|
||||
let userroom_id = (user_id, room_id);
|
||||
let userroom_id = serialize_key(userroom_id).expect("failed to serialize userroom_id");
|
||||
|
||||
let roomuser_id = (room_id, user_id);
|
||||
let roomuser_id = serialize_key(roomuser_id).expect("failed to serialize roomuser_id");
|
||||
|
||||
// (timo) TODO
|
||||
let leftstate = Vec::<Raw<AnySyncStateEvent>>::new();
|
||||
|
||||
self.db
|
||||
.userroomid_leftstate
|
||||
.raw_put(&userroom_id, Json(leave_pdu));
|
||||
.raw_put(&userroom_id, Json(leftstate));
|
||||
self.db
|
||||
.roomuserid_leftcount
|
||||
.raw_aput::<8, _, _>(&roomuser_id, self.services.globals.next_count().unwrap());
|
||||
@@ -262,14 +285,6 @@ pub async fn mark_as_left(&self, user_id: &UserId, room_id: &RoomId, leave_pdu:
|
||||
self.db.roomuserid_knockedcount.remove(&roomuser_id);
|
||||
|
||||
self.db.roomid_inviteviaservers.remove(room_id);
|
||||
|
||||
if self.services.globals.user_is_local(user_id)
|
||||
&& (self.services.config.forget_forced_upon_leave
|
||||
|| self.services.metadata.is_banned(room_id).await
|
||||
|| self.services.metadata.is_disabled(room_id).await)
|
||||
{
|
||||
self.forget(room_id, user_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct DB function to directly mark a user as knocked. It is not
|
||||
@@ -336,20 +351,7 @@ pub async fn mark_as_invited(
|
||||
sender_user: &UserId,
|
||||
last_state: Option<Vec<Raw<AnyStrippedStateEvent>>>,
|
||||
invite_via: Option<Vec<OwnedServerName>>,
|
||||
) -> Result<()> {
|
||||
// return an error for blocked invites. ignored invites aren't handled here
|
||||
// since the recipient's membership should still be changed to `invite`.
|
||||
// they're filtered out in the individual /sync handlers
|
||||
if matches!(
|
||||
self.services
|
||||
.users
|
||||
.invite_filter_level(sender_user, user_id)
|
||||
.await,
|
||||
FilterLevel::Block
|
||||
) {
|
||||
return Err!(Request(InviteBlocked("{user_id} has blocked invites from {sender_user}.")));
|
||||
}
|
||||
|
||||
) {
|
||||
let roomuser_id = (room_id, user_id);
|
||||
let roomuser_id = serialize_key(roomuser_id).expect("failed to serialize roomuser_id");
|
||||
|
||||
@@ -364,7 +366,7 @@ pub async fn mark_as_invited(
|
||||
.raw_aput::<8, _, _>(&roomuser_id, self.services.globals.next_count().unwrap());
|
||||
self.db
|
||||
.userroomid_invitesender
|
||||
.insert(&userroom_id, sender_user);
|
||||
.raw_put(&userroom_id, sender_user);
|
||||
|
||||
self.db.userroomid_joined.remove(&userroom_id);
|
||||
self.db.roomuserid_joined.remove(&roomuser_id);
|
||||
@@ -378,6 +380,4 @@ pub async fn mark_as_invited(
|
||||
if let Some(servers) = invite_via.filter(is_not_empty!()) {
|
||||
self.add_servers_invite_via(room_id, servers).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -526,7 +526,7 @@ pub(crate) fn compress_state_event(
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn parse_compressed_state_event(
|
||||
pub(crate) fn parse_compressed_state_event(
|
||||
compressed_event: CompressedStateEvent,
|
||||
) -> (ShortStateKey, ShortEventId) {
|
||||
use utils::u64_from_u8;
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
GlobalAccountDataEventType, StateEventType, TimelineEventType,
|
||||
push_rules::PushRulesEvent,
|
||||
room::{
|
||||
encrypted::Relation, power_levels::RoomPowerLevelsEventContent,
|
||||
encrypted::Relation,
|
||||
member::{MembershipState, RoomMemberEventContent},
|
||||
power_levels::RoomPowerLevelsEventContent,
|
||||
redaction::RoomRedactionEventContent,
|
||||
},
|
||||
},
|
||||
@@ -321,12 +323,31 @@ pub async fn append_pdu<'a, Leaves>(
|
||||
let target_user_id =
|
||||
UserId::parse(state_key).expect("This state_key was previously validated");
|
||||
|
||||
let content: RoomMemberEventContent = pdu.get_content()?;
|
||||
let stripped_state = match content.membership {
|
||||
| MembershipState::Invite | MembershipState::Knock => self
|
||||
.services
|
||||
.state
|
||||
.summary_stripped(pdu, room_id)
|
||||
.await
|
||||
.into(),
|
||||
| _ => None,
|
||||
};
|
||||
|
||||
// Update our membership info, we do this here incase a user is invited or
|
||||
// knocked and immediately leaves we need the DB to record the invite or
|
||||
// knock event for auth
|
||||
self.services
|
||||
.state_cache
|
||||
.update_membership(room_id, target_user_id, pdu, true)
|
||||
.update_membership(
|
||||
room_id,
|
||||
target_user_id,
|
||||
content,
|
||||
pdu.sender(),
|
||||
stripped_state,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -186,8 +186,10 @@ pub async fn get_pdu_id(&self, event_id: &EventId) -> Result<RawPduId> {
|
||||
}
|
||||
|
||||
/// Returns the pdu.
|
||||
///
|
||||
/// Checks the `eventid_outlierpdu` Tree if not found in the timeline.
|
||||
#[inline]
|
||||
pub async fn get_non_outlier_pdu(&self, event_id: &EventId) -> Result<PduEvent> {
|
||||
pub async fn get_non_outlier_pdu(&self, event_id: &EventId) -> Result<impl Event> {
|
||||
self.db.get_non_outlier_pdu(event_id).await
|
||||
}
|
||||
|
||||
@@ -241,7 +243,7 @@ pub fn all_pdus<'a>(
|
||||
self.pdus(Some(user_id), room_id, None).ignore_err()
|
||||
}
|
||||
|
||||
/// Reverse iteration starting after `until`.
|
||||
/// Reverse iteration starting at from.
|
||||
#[tracing::instrument(skip(self), level = "debug")]
|
||||
pub fn pdus_rev<'a>(
|
||||
&'a self,
|
||||
@@ -253,7 +255,7 @@ pub fn pdus_rev<'a>(
|
||||
.pdus_rev(user_id, room_id, until.unwrap_or_else(PduCount::max))
|
||||
}
|
||||
|
||||
/// Forward iteration starting after `from`.
|
||||
/// Forward iteration starting at from.
|
||||
#[tracing::instrument(skip(self), level = "debug")]
|
||||
pub fn pdus<'a>(
|
||||
&'a self,
|
||||
|
||||
@@ -179,15 +179,18 @@ pub async fn last_typing_update(&self, room_id: &RoomId) -> Result<u64> {
|
||||
.unwrap_or(0))
|
||||
}
|
||||
|
||||
pub async fn typing_users_for_user(
|
||||
/// Returns a new typing EDU.
|
||||
pub async fn typings_all(
|
||||
&self,
|
||||
room_id: &RoomId,
|
||||
sender_user: &UserId,
|
||||
) -> Result<Vec<OwnedUserId>> {
|
||||
) -> Result<SyncEphemeralRoomEvent<ruma::events::typing::TypingEventContent>> {
|
||||
let room_typing_indicators = self.typing.read().await.get(room_id).cloned();
|
||||
|
||||
let Some(typing_indicators) = room_typing_indicators else {
|
||||
return Ok(Vec::new());
|
||||
return Ok(SyncEphemeralRoomEvent {
|
||||
content: ruma::events::typing::TypingEventContent { user_ids: Vec::new() },
|
||||
});
|
||||
};
|
||||
|
||||
let user_ids: Vec<_> = typing_indicators
|
||||
@@ -204,19 +207,8 @@ pub async fn typing_users_for_user(
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
Ok(user_ids)
|
||||
}
|
||||
|
||||
/// Returns a new typing EDU.
|
||||
pub async fn typings_event_for_user(
|
||||
&self,
|
||||
room_id: &RoomId,
|
||||
sender_user: &UserId,
|
||||
) -> Result<SyncEphemeralRoomEvent<ruma::events::typing::TypingEventContent>> {
|
||||
Ok(SyncEphemeralRoomEvent {
|
||||
content: ruma::events::typing::TypingEventContent {
|
||||
user_ids: self.typing_users_for_user(room_id, sender_user).await?,
|
||||
},
|
||||
content: ruma::events::typing::TypingEventContent { user_ids },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -423,7 +423,7 @@ async fn select_edus_device_changes(
|
||||
let keys_changed = self
|
||||
.services
|
||||
.users
|
||||
.room_keys_changed(room_id, Some(since.0), None)
|
||||
.room_keys_changed(room_id, since.0, None)
|
||||
.ready_filter(|(user_id, _)| self.services.globals.user_is_local(user_id));
|
||||
|
||||
pin_mut!(keys_changed);
|
||||
@@ -520,7 +520,7 @@ async fn select_edus_receipts_room(
|
||||
let receipts = self
|
||||
.services
|
||||
.read_receipt
|
||||
.readreceipts_since(room_id, Some(since.0));
|
||||
.readreceipts_since(room_id, since.0);
|
||||
|
||||
pin_mut!(receipts);
|
||||
let mut read = BTreeMap::<OwnedUserId, ReceiptData>::new();
|
||||
@@ -530,7 +530,7 @@ async fn select_edus_receipts_room(
|
||||
}
|
||||
|
||||
max_edu_count.fetch_max(count, Ordering::Relaxed);
|
||||
if !self.services.globals.user_is_local(&user_id) {
|
||||
if !self.services.globals.user_is_local(user_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -554,7 +554,7 @@ async fn select_edus_receipts_room(
|
||||
let receipt = receipt
|
||||
.remove(&ReceiptType::Read)
|
||||
.expect("our read receipts always set this")
|
||||
.remove(&user_id)
|
||||
.remove(user_id)
|
||||
.expect("our read receipts always have the user here");
|
||||
|
||||
let receipt_data = ReceiptData {
|
||||
@@ -562,7 +562,7 @@ async fn select_edus_receipts_room(
|
||||
event_ids: vec![event_id.clone()],
|
||||
};
|
||||
|
||||
if read.insert(user_id, receipt_data).is_none() {
|
||||
if read.insert(user_id.to_owned(), receipt_data).is_none() {
|
||||
*num = num.saturating_add(1);
|
||||
if *num >= SELECT_RECEIPT_LIMIT {
|
||||
break;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
account_data, admin, announcements, appservice, client, config, emergency, federation,
|
||||
globals, key_backups,
|
||||
manager::Manager,
|
||||
media, moderation, presence, pusher, resolver, rooms, sending, server_keys, service,
|
||||
media, moderation, oidc, presence, pusher, resolver, rooms, sending, server_keys, service,
|
||||
service::{Args, Map, Service},
|
||||
sync, transaction_ids, uiaa, users,
|
||||
};
|
||||
@@ -39,6 +39,7 @@ pub struct Services {
|
||||
pub users: Arc<users::Service>,
|
||||
pub moderation: Arc<moderation::Service>,
|
||||
pub announcements: Arc<announcements::Service>,
|
||||
pub oidc: Arc<oidc::Service>,
|
||||
|
||||
manager: Mutex<Option<Arc<Manager>>>,
|
||||
pub(crate) service: Arc<Map>,
|
||||
@@ -107,6 +108,7 @@ macro_rules! build {
|
||||
users: build!(users::Service),
|
||||
moderation: build!(moderation::Service),
|
||||
announcements: build!(announcements::Service),
|
||||
oidc: build!(oidc::Service),
|
||||
|
||||
manager: Mutex::new(None),
|
||||
service,
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Dep, account_data, admin, appservice, globals, rooms};
|
||||
use crate::{Dep, account_data, admin, appservice, globals, oidc, rooms};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserSuspension {
|
||||
@@ -52,6 +52,7 @@ struct Services {
|
||||
admin: Dep<admin::Service>,
|
||||
appservice: Dep<appservice::Service>,
|
||||
globals: Dep<globals::Service>,
|
||||
oidc: Dep<oidc::Service>,
|
||||
state_accessor: Dep<rooms::state_accessor::Service>,
|
||||
state_cache: Dep<rooms::state_cache::Service>,
|
||||
}
|
||||
@@ -89,6 +90,7 @@ fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
|
||||
account_data: args.depend::<account_data::Service>("account_data"),
|
||||
admin: args.depend::<admin::Service>("admin"),
|
||||
appservice: args.depend::<appservice::Service>("appservice"),
|
||||
oidc: args.depend::<oidc::Service>("oidc"),
|
||||
globals: args.depend::<globals::Service>("globals"),
|
||||
state_accessor: args
|
||||
.depend::<rooms::state_accessor::Service>("rooms::state_accessor"),
|
||||
@@ -270,7 +272,18 @@ pub async fn count(&self) -> usize { self.db.userid_password.count().await }
|
||||
|
||||
/// Find out which user an access token belongs to.
|
||||
pub async fn find_from_token(&self, token: &str) -> Result<(OwnedUserId, OwnedDeviceId)> {
|
||||
self.db.token_userdeviceid.get(token).await.deserialized()
|
||||
if self
|
||||
.services
|
||||
.server
|
||||
.config
|
||||
.auth
|
||||
.as_ref()
|
||||
.is_some_and(|auth| auth.enable_oidc_login)
|
||||
{
|
||||
self.services.oidc.user_and_device_from_token(token).await
|
||||
} else {
|
||||
self.db.token_userdeviceid.get(token).await.deserialized()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an iterator over all users on this homeserver (offered for
|
||||
@@ -536,7 +549,24 @@ pub async fn add_one_time_key(
|
||||
// Only existing devices should be able to call this, but we shouldn't assert
|
||||
// either...
|
||||
let key = (user_id, device_id);
|
||||
if self.db.userdeviceid_metadata.qry(&key).await.is_err() {
|
||||
if self
|
||||
.services
|
||||
.server
|
||||
.config
|
||||
.auth
|
||||
.as_ref()
|
||||
.is_some_and(|auth| auth.enable_oidc_login)
|
||||
{
|
||||
if self
|
||||
.services
|
||||
.oidc
|
||||
.client_from_device_id(device_id.into())
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Err!(Database(error!(?user_id, ?device_id, "Device has no metadata.")));
|
||||
}
|
||||
} else if self.db.userdeviceid_metadata.qry(&key).await.is_err() {
|
||||
return Err!(Database(error!(
|
||||
?user_id,
|
||||
?device_id,
|
||||
@@ -790,7 +820,7 @@ pub async fn sign_key(
|
||||
pub fn keys_changed<'a>(
|
||||
&'a self,
|
||||
user_id: &'a UserId,
|
||||
from: Option<u64>,
|
||||
from: u64,
|
||||
to: Option<u64>,
|
||||
) -> impl Stream<Item = &'a UserId> + Send + 'a {
|
||||
self.keys_changed_user_or_room(user_id.as_str(), from, to)
|
||||
@@ -801,7 +831,7 @@ pub fn keys_changed<'a>(
|
||||
pub fn room_keys_changed<'a>(
|
||||
&'a self,
|
||||
room_id: &'a RoomId,
|
||||
from: Option<u64>,
|
||||
from: u64,
|
||||
to: Option<u64>,
|
||||
) -> impl Stream<Item = (&'a UserId, u64)> + Send + 'a {
|
||||
self.keys_changed_user_or_room(room_id.as_str(), from, to)
|
||||
@@ -810,12 +840,11 @@ pub fn room_keys_changed<'a>(
|
||||
fn keys_changed_user_or_room<'a>(
|
||||
&'a self,
|
||||
user_or_room_id: &'a str,
|
||||
from: Option<u64>,
|
||||
from: u64,
|
||||
to: Option<u64>,
|
||||
) -> impl Stream<Item = (&'a UserId, u64)> + Send + 'a {
|
||||
type KeyVal<'a> = ((&'a str, u64), &'a UserId);
|
||||
|
||||
let from = from.unwrap_or(0);
|
||||
let to = to.unwrap_or(u64::MAX);
|
||||
let start = (user_or_room_id, from.saturating_add(1));
|
||||
self.db
|
||||
|
||||
@@ -22,6 +22,8 @@ crate-type = [
|
||||
[dependencies]
|
||||
conduwuit-build-metadata.workspace = true
|
||||
conduwuit-service.workspace = true
|
||||
conduwuit-core.workspace = true
|
||||
async-trait.workspace = true
|
||||
|
||||
askama = "0.14.0"
|
||||
|
||||
@@ -30,6 +32,10 @@ futures.workspace = true
|
||||
tracing.workspace = true
|
||||
rand.workspace = true
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
url.workspace = true
|
||||
percent-encoding.workspace = true
|
||||
oxide-auth.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
};
|
||||
use conduwuit_build_metadata::{GIT_REMOTE_COMMIT_URL, GIT_REMOTE_WEB_URL, version_tag};
|
||||
use conduwuit_service::state;
|
||||
pub mod oidc;
|
||||
|
||||
pub fn build() -> Router<state::State> {
|
||||
let router = Router::<state::State>::new();
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
use askama::Template;
|
||||
use conduwuit_build_metadata::version_tag;
|
||||
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
||||
|
||||
// Imports needed by askama templates.
|
||||
use crate::{GIT_REMOTE_COMMIT_URL, GIT_REMOTE_WEB_URL};
|
||||
|
||||
mod authorize;
|
||||
mod consent;
|
||||
mod error;
|
||||
mod login;
|
||||
mod request;
|
||||
mod response;
|
||||
pub use authorize::AuthorizationQuery;
|
||||
pub use consent::oidc_consent_form;
|
||||
pub use error::OidcError;
|
||||
pub use login::{LoginError, LoginQuery, oidc_login_form};
|
||||
pub use request::OidcRequest;
|
||||
pub use response::OidcResponse;
|
||||
|
||||
/// The parameters for the OIDC login page template.
|
||||
#[derive(Template)]
|
||||
#[template(path = "login.html.j2")]
|
||||
pub(crate) struct LoginPageTemplate<'a> {
|
||||
nonce: &'a str,
|
||||
hostname: &'a str,
|
||||
route: &'a str,
|
||||
client_id: &'a str,
|
||||
client_secret: Option<&'a str>,
|
||||
redirect_uri: &'a str,
|
||||
scope: &'a str,
|
||||
state: &'a str,
|
||||
code_challenge: &'a str,
|
||||
code_challenge_method: &'a str,
|
||||
response_type: &'a str,
|
||||
response_mode: &'a str,
|
||||
}
|
||||
|
||||
/// The parameters for the OIDC consent page template.
|
||||
#[derive(Template)]
|
||||
#[template(path = "consent.html.j2")]
|
||||
pub(crate) struct ConsentPageTemplate<'a> {
|
||||
nonce: &'a str,
|
||||
hostname: &'a str,
|
||||
route: &'a str,
|
||||
user_id: &'a str,
|
||||
client_id: &'a str,
|
||||
client_secret: Option<&'a str>,
|
||||
redirect_uri: &'a str,
|
||||
scope: &'a str,
|
||||
state: &'a str,
|
||||
code_challenge: &'a str,
|
||||
code_challenge_method: &'a str,
|
||||
response_type: &'a str,
|
||||
response_mode: &'a str,
|
||||
}
|
||||
|
||||
pub(crate) fn encode(text: &str) -> String {
|
||||
utf8_percent_encode(text, NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use url::Url;
|
||||
|
||||
use super::LoginQuery;
|
||||
|
||||
/// The set of parameters required for an OIDC authorization request.
|
||||
#[derive(serde::Deserialize, Debug, Clone)]
|
||||
pub struct AuthorizationQuery {
|
||||
pub client_id: String,
|
||||
pub client_secret: Option<String>,
|
||||
pub redirect_uri: Url,
|
||||
pub scope: String,
|
||||
pub state: String,
|
||||
pub code_challenge: String,
|
||||
pub code_challenge_method: String,
|
||||
pub response_type: String,
|
||||
pub response_mode: Option<String>,
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
impl From<LoginQuery> for AuthorizationQuery {
|
||||
fn from(value: LoginQuery) -> Self {
|
||||
let LoginQuery {
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uri,
|
||||
scope,
|
||||
state,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
response_type,
|
||||
response_mode,
|
||||
username,
|
||||
..
|
||||
} = value;
|
||||
|
||||
Self {
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uri,
|
||||
scope,
|
||||
state,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
response_type,
|
||||
response_mode: Some(response_mode),
|
||||
username: Some(username),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use askama::Template;
|
||||
use axum::http::StatusCode;
|
||||
use oxide_auth::frontends::simple::request::Body;
|
||||
|
||||
use super::{AuthorizationQuery, ConsentPageTemplate, OidcResponse, encode};
|
||||
|
||||
/// A web consent solicitor form for the OIDC authentication flow.
|
||||
///
|
||||
/// Asks the resource owner for their consent to let a client access their data
|
||||
/// on this server.
|
||||
#[must_use]
|
||||
pub fn oidc_consent_form(hostname: &str, query: &AuthorizationQuery) -> OidcResponse {
|
||||
// The target request route.
|
||||
let route = "/_matrix/client/unstable/org.matrix.msc2964/authorize";
|
||||
let nonce = rand::random::<u64>().to_string();
|
||||
let body = Some(Body::Text(consent_page(hostname, query, route, &nonce)));
|
||||
|
||||
OidcResponse {
|
||||
status: StatusCode::OK,
|
||||
location: None,
|
||||
www_authenticate: None,
|
||||
body,
|
||||
nonce: Some(nonce),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the html contents of the user consent page.
|
||||
fn consent_page(hostname: &str, query: &AuthorizationQuery, route: &str, nonce: &str) -> String {
|
||||
let response_mode = query
|
||||
.response_mode
|
||||
.as_deref()
|
||||
.unwrap_or("fragment")
|
||||
.to_owned();
|
||||
let user_id = query
|
||||
.username
|
||||
.clone()
|
||||
.expect("user_id in authorization query");
|
||||
let template = ConsentPageTemplate {
|
||||
nonce,
|
||||
hostname,
|
||||
route,
|
||||
user_id: &encode(&user_id),
|
||||
client_id: &encode(query.client_id.as_str()),
|
||||
client_secret: query.client_secret.as_deref(),
|
||||
redirect_uri: &encode(query.redirect_uri.as_str()),
|
||||
scope: &encode(query.scope.as_str()),
|
||||
state: &encode(query.state.as_str()),
|
||||
code_challenge: &encode(query.code_challenge.as_str()),
|
||||
code_challenge_method: &encode(query.code_challenge_method.as_str()),
|
||||
response_type: &encode(query.response_type.as_str()),
|
||||
response_mode: &encode(response_mode.as_str()),
|
||||
};
|
||||
|
||||
template.render().expect("consent page render")
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use axum::{
|
||||
http::{StatusCode, header::InvalidHeaderValue},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use oxide_auth::frontends::{dev::OAuthError, simple::endpoint::Error};
|
||||
|
||||
use super::OidcRequest;
|
||||
|
||||
#[derive(Debug)]
|
||||
/// The error type for Oxide Auth operations
|
||||
pub enum OidcError {
|
||||
/// Errors occurring in Endpoint operations
|
||||
Endpoint(OAuthError),
|
||||
/// Errors occurring in Endpoint operations
|
||||
Header(InvalidHeaderValue),
|
||||
/// Errors with the request encoding
|
||||
Encoding,
|
||||
/// Request body could not be parsed as a form
|
||||
Form,
|
||||
/// Request query was absent or could not be parsed
|
||||
Query,
|
||||
/// Request query was absent or could not be parsed
|
||||
Body,
|
||||
/// The Authorization header was invalid
|
||||
Authorization,
|
||||
/// General internal server error
|
||||
InternalError(Option<String>),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for OidcError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match *self {
|
||||
| Self::Endpoint(ref e) => write!(f, "Endpoint, {e}"),
|
||||
| Self::Header(ref e) => write!(f, "Couldn't set header, {e}"),
|
||||
| Self::Encoding => write!(f, "Error decoding request"),
|
||||
| Self::Form => write!(f, "Request is not a form"),
|
||||
| Self::Query => write!(f, "No query present"),
|
||||
| Self::Body => write!(f, "No body present"),
|
||||
| Self::Authorization => write!(f, "Request has invalid Authorization headers"),
|
||||
| Self::InternalError(None) => write!(f, "An internal server error occurred"),
|
||||
| Self::InternalError(Some(ref e)) =>
|
||||
write!(f, "An internal server error occurred: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for OidcError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match *self {
|
||||
| Self::Endpoint(ref e) => e.source(),
|
||||
| Self::Header(ref e) => e.source(),
|
||||
| _ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for OidcError {
|
||||
fn into_response(self) -> Response {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Error<OidcRequest>> for OidcError {
|
||||
fn from(e: Error<OidcRequest>) -> Self {
|
||||
match e {
|
||||
| Error::Web(e) => e,
|
||||
| Error::OAuth(e) => e.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OAuthError> for OidcError {
|
||||
fn from(e: OAuthError) -> Self { Self::Endpoint(e) }
|
||||
}
|
||||
|
||||
impl From<InvalidHeaderValue> for OidcError {
|
||||
fn from(e: InvalidHeaderValue) -> Self { Self::Header(e) }
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use std::{borrow::Cow, str::FromStr};
|
||||
|
||||
use askama::Template;
|
||||
use axum::http::StatusCode;
|
||||
use oxide_auth::{endpoint::QueryParameter, frontends::simple::request::Body};
|
||||
use url::Url;
|
||||
|
||||
use super::{AuthorizationQuery, LoginPageTemplate, OidcRequest, OidcResponse};
|
||||
|
||||
/// The set of query parameters a client needs to get authorization.
|
||||
#[derive(serde::Deserialize, Debug, Clone)]
|
||||
pub struct LoginQuery {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub client_id: String,
|
||||
pub client_secret: Option<String>,
|
||||
pub redirect_uri: Url,
|
||||
pub scope: String,
|
||||
pub state: String,
|
||||
pub code_challenge: String,
|
||||
pub code_challenge_method: String,
|
||||
pub response_type: String,
|
||||
pub response_mode: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LoginError(pub String);
|
||||
|
||||
impl TryFrom<OidcRequest> for LoginQuery {
|
||||
type Error = LoginError;
|
||||
|
||||
fn try_from(value: OidcRequest) -> Result<Self, LoginError> {
|
||||
let body = value.body().expect("body in OidcRequest");
|
||||
|
||||
let Some(username) = body.unique_value("username") else {
|
||||
return Err(LoginError("missing field: username".to_owned()));
|
||||
};
|
||||
let Some(password) = body.unique_value("password") else {
|
||||
return Err(LoginError("missing field: password".to_owned()));
|
||||
};
|
||||
let Some(client_id) = body.unique_value("client_id") else {
|
||||
return Err(LoginError("missing field: client_id".to_owned()));
|
||||
};
|
||||
let Some(redirect_uri) = body.unique_value("redirect_uri") else {
|
||||
return Err(LoginError("missing field: redirect_uri".to_owned()));
|
||||
};
|
||||
let Some(scope) = body.unique_value("scope") else {
|
||||
return Err(LoginError("missing field: scope".to_owned()));
|
||||
};
|
||||
let Some(state) = body.unique_value("state") else {
|
||||
return Err(LoginError("missing field: state".to_owned()));
|
||||
};
|
||||
let Some(code_challenge) = body.unique_value("code_challenge") else {
|
||||
return Err(LoginError("missing field: code_challenge".to_owned()));
|
||||
};
|
||||
let Some(code_challenge_method) = body.unique_value("code_challenge_method") else {
|
||||
return Err(LoginError("missing field: code_challenge_method".to_owned()));
|
||||
};
|
||||
let Some(response_type) = body.unique_value("response_type") else {
|
||||
return Err(LoginError("missing field: response_type".to_owned()));
|
||||
};
|
||||
let Ok(redirect_uri) = Url::from_str(&redirect_uri) else {
|
||||
return Err(LoginError("invalid field: redirect_uri".to_owned()));
|
||||
};
|
||||
// response_mode is not strictly needed : it must be the literal "fragment"
|
||||
// when over https. It's required by the spec but Fractal doesn't provide it.
|
||||
let response_mode = body
|
||||
.unique_value("response_mode")
|
||||
.unwrap_or(Cow::Borrowed("fragment"));
|
||||
let client_secret = body.unique_value("client_secret").map(|s| s.to_string());
|
||||
|
||||
Ok(Self {
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
client_id: client_id.to_string(),
|
||||
client_secret,
|
||||
redirect_uri,
|
||||
scope: scope.to_string(),
|
||||
state: state.to_string(),
|
||||
code_challenge: code_challenge.to_string(),
|
||||
code_challenge_method: code_challenge_method.to_string(),
|
||||
response_type: response_type.to_string(),
|
||||
response_mode: response_mode.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A web login form for the OIDC authentication flow.
|
||||
///
|
||||
/// The returned `OidcResponse` handles CSP headers to allow that form.
|
||||
#[must_use]
|
||||
pub fn oidc_login_form(hostname: &str, query: &AuthorizationQuery) -> OidcResponse {
|
||||
// The target request route.
|
||||
let route = "/_matrix/client/unstable/org.matrix.msc2964/login";
|
||||
let nonce = rand::random::<u64>().to_string();
|
||||
let body = Some(Body::Text(login_page(hostname, query, route, &nonce)));
|
||||
|
||||
OidcResponse {
|
||||
status: StatusCode::OK,
|
||||
location: None,
|
||||
www_authenticate: None,
|
||||
body,
|
||||
nonce: Some(nonce),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the html contents of the login page.
|
||||
fn login_page(hostname: &str, query: &AuthorizationQuery, route: &str, nonce: &str) -> String {
|
||||
let response_mode = query.response_mode.as_deref().unwrap_or("fragment");
|
||||
let template = LoginPageTemplate {
|
||||
nonce,
|
||||
hostname,
|
||||
route,
|
||||
client_id: query.client_id.as_str(),
|
||||
client_secret: query.client_secret.as_deref(),
|
||||
redirect_uri: query.redirect_uri.as_str(),
|
||||
scope: query.scope.as_str(),
|
||||
state: query.state.as_str(),
|
||||
code_challenge: query.code_challenge.as_str(),
|
||||
code_challenge_method: query.code_challenge_method.as_str(),
|
||||
response_type: query.response_type.as_str(),
|
||||
response_mode,
|
||||
};
|
||||
|
||||
template.render().expect("login template render")
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
extract::{Form, FromRequest, FromRequestParts, Query, Request},
|
||||
http::header,
|
||||
};
|
||||
use oxide_auth::endpoint::{NormalizedParameter, QueryParameter, WebRequest};
|
||||
|
||||
use super::{OidcError, OidcResponse};
|
||||
|
||||
/// An OIDC authentication request.
|
||||
///
|
||||
/// Expected to receive GET and POST requests to the `authorize` endpoint, or
|
||||
/// POST requests to the `login` endpoint.
|
||||
///
|
||||
/// Mostly adapted from the OAuthRequest struct in the [oxide-auth-axum] crate.
|
||||
/// [oxide-auth-axum]: https://docs.rs/oxide-auth-axum
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OidcRequest {
|
||||
pub(crate) auth: Option<String>,
|
||||
pub(crate) query: Option<NormalizedParameter>,
|
||||
pub(crate) body: Option<NormalizedParameter>,
|
||||
}
|
||||
|
||||
impl OidcRequest {
|
||||
/// Fetch the authorization header from the request
|
||||
#[must_use]
|
||||
pub fn authorization_header(&self) -> Option<&str> { self.auth.as_deref() }
|
||||
|
||||
/// Fetch the query for this request
|
||||
#[must_use]
|
||||
pub fn query(&self) -> Option<&NormalizedParameter> { self.query.as_ref() }
|
||||
|
||||
/// Fetch the query mutably
|
||||
pub fn query_mut(&mut self) -> Option<&mut NormalizedParameter> { self.query.as_mut() }
|
||||
|
||||
/// Fetch the body of the request
|
||||
#[must_use]
|
||||
pub fn body(&self) -> Option<&NormalizedParameter> { self.body.as_ref() }
|
||||
}
|
||||
|
||||
impl WebRequest for OidcRequest {
|
||||
type Error = OidcError;
|
||||
type Response = OidcResponse;
|
||||
|
||||
fn query(&mut self) -> Result<Cow<'_, dyn QueryParameter + 'static>, Self::Error> {
|
||||
self.query
|
||||
.as_ref()
|
||||
.map(|q| {
|
||||
let q: &dyn QueryParameter = q;
|
||||
Cow::Borrowed(q)
|
||||
})
|
||||
.ok_or(OidcError::Query)
|
||||
}
|
||||
|
||||
fn urlbody(&mut self) -> Result<Cow<'_, dyn QueryParameter + 'static>, Self::Error> {
|
||||
self.body
|
||||
.as_ref()
|
||||
.map(|q| {
|
||||
let q: &dyn QueryParameter = q;
|
||||
Cow::Borrowed(q)
|
||||
})
|
||||
.ok_or(OidcError::Body)
|
||||
}
|
||||
|
||||
fn authheader(&mut self) -> Result<Option<Cow<'_, str>>, Self::Error> {
|
||||
Ok(self.auth.as_deref().map(Cow::Borrowed))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequest<S> for OidcRequest
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = OidcError;
|
||||
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let mut all_auth = req.headers().get_all(header::AUTHORIZATION).iter();
|
||||
let optional = all_auth.next();
|
||||
|
||||
let auth = if all_auth.next().is_some() {
|
||||
return Err(OidcError::Authorization);
|
||||
} else {
|
||||
optional.and_then(|hv| hv.to_str().ok().map(str::to_owned))
|
||||
};
|
||||
|
||||
let (mut parts, body) = req.into_parts();
|
||||
let query = Query::from_request_parts(&mut parts, state)
|
||||
.await
|
||||
.ok()
|
||||
.map(|q: Query<NormalizedParameter>| q.0);
|
||||
|
||||
let req = Request::from_parts(parts, body);
|
||||
let body = Form::from_request(req, state)
|
||||
.await
|
||||
.ok()
|
||||
.map(|b: Form<NormalizedParameter>| b.0);
|
||||
|
||||
// If the query is empty and the body has a request, copy it over
|
||||
// because login forms are POST requests but OAuth flow expects
|
||||
// arguments in query.
|
||||
let query = match query {
|
||||
| None => body.clone(),
|
||||
| Some(params) => {
|
||||
//if params == NormalizedParameter::new() {
|
||||
if params.unique_value("client_id").is_none() {
|
||||
body.clone()
|
||||
} else {
|
||||
Some(params)
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Ok(Self { auth, query, body })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Response, StatusCode, header},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use oxide_auth::{
|
||||
endpoint::{OwnerConsent, OwnerSolicitor, Solicitation, WebRequest, WebResponse},
|
||||
frontends::simple::request::Body as OAuthRequestBody,
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
use super::{LoginQuery, OidcError, OidcRequest, oidc_consent_form};
|
||||
|
||||
/// A Web response that can be processed by the OIDC authentication flow before
|
||||
/// being sent over.
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct OidcResponse {
|
||||
pub(crate) status: StatusCode,
|
||||
pub(crate) location: Option<Url>,
|
||||
pub(crate) www_authenticate: Option<String>,
|
||||
pub(crate) body: Option<OAuthRequestBody>,
|
||||
pub(crate) nonce: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for OidcResponse {
|
||||
fn into_response(self) -> Response<Body> {
|
||||
let csp_src = match self.nonce {
|
||||
| Some(nonce) => &format!("default-src 'nonce-{nonce}';"),
|
||||
| None => "default-src 'none';",
|
||||
};
|
||||
let csp_form_action =
|
||||
"form-action 'self' http://localhost http://127.0.0.1 http://[::1];";
|
||||
let content_csp = format!("{csp_src} {csp_form_action}");
|
||||
let content_type = match self.body {
|
||||
| Some(OAuthRequestBody::Json(_)) => "application/json",
|
||||
| _ => "text/html",
|
||||
};
|
||||
let mut response = Response::builder()
|
||||
.status(self.status)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CONTENT_SECURITY_POLICY, content_csp);
|
||||
if let Some(location) = self.location {
|
||||
response = response.header(header::LOCATION, location.as_str());
|
||||
}
|
||||
// Transform from OAuthRequestBody to String.
|
||||
let body_content = self.body.map(|b| b.as_str().to_owned()).unwrap_or_default();
|
||||
|
||||
response.body(body_content.into()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
/// OidcResponse uses [super::oidc_consent_form] to be turned into an owner
|
||||
/// consent solicitation.
|
||||
impl OwnerSolicitor<OidcRequest> for OidcResponse {
|
||||
fn check_consent(
|
||||
&mut self,
|
||||
request: &mut OidcRequest,
|
||||
_: Solicitation<'_>,
|
||||
) -> OwnerConsent<<OidcRequest as WebRequest>::Response> {
|
||||
// TODO find a way to pass the hostname to the template.
|
||||
let hostname = "Continuwuity";
|
||||
let query: LoginQuery = request
|
||||
.clone()
|
||||
.try_into()
|
||||
.expect("login query from OidcRequest");
|
||||
|
||||
OwnerConsent::InProgress(oidc_consent_form(hostname, &query.into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl WebResponse for OidcResponse {
|
||||
type Error = OidcError;
|
||||
|
||||
fn ok(&mut self) -> Result<(), Self::Error> {
|
||||
self.status = StatusCode::OK;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A response which will redirect the user-agent to which the response is
|
||||
/// issued.
|
||||
fn redirect(&mut self, url: Url) -> Result<(), Self::Error> {
|
||||
self.status = StatusCode::FOUND;
|
||||
self.location = Some(url);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the response status to 400.
|
||||
fn client_error(&mut self) -> Result<(), Self::Error> {
|
||||
self.status = StatusCode::BAD_REQUEST;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the response status to 401 and add a `WWW-Authenticate` header.
|
||||
fn unauthorized(&mut self, header_value: &str) -> Result<(), Self::Error> {
|
||||
self.status = StatusCode::UNAUTHORIZED;
|
||||
self.www_authenticate = Some(header_value.to_owned());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A pure text response with no special media type set.
|
||||
fn body_text(&mut self, text: &str) -> Result<(), Self::Error> {
|
||||
self.body = Some(OAuthRequestBody::Text(text.to_owned()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Json response data, with media type `aplication/json.
|
||||
fn body_json(&mut self, data: &str) -> Result<(), Self::Error> {
|
||||
self.body = Some(OAuthRequestBody::Json(data.to_owned()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{% extends "_layout.html.j2" %}
|
||||
{%- block content -%}
|
||||
<div class="panel">
|
||||
<h1 class-"project-name">{{ hostname }}</h1>
|
||||
<p>
|
||||
'{{ client_id }}' (at {{ redirect_uri }}) is requesting permission for '{{ scope }}'
|
||||
</p>
|
||||
<form method="post">
|
||||
<input type="submit" value="Accept" formaction="{{ route }}?client_id={{ client_id }}{%- if let Some(secret)
|
||||
= client_secret -%}&client_secret={{ secret }}{%- endif -%}&redirect_uri={{ redirect_uri }}&scope={{ scope
|
||||
}}&state={{ state }}&code_challenge={{ code_challenge }}&code_challenge_method={{ code_challenge_method
|
||||
}}&response_type={{ response_type }}&response_mode={{ response_mode }}&allow={{ user_id }}">
|
||||
<input type="submit" value="Deny" formaction="{{ route }}?client_id={{ client_id }}{%- if let Some(secret) = client_secret -%}&client_secret={{ secret }}{%- endif -%}&redirect_uri={{ redirect_uri }}&scope={{scope }}&state={{ state }}&code_challenge={{ code_challenge }}&code_challenge_method={{ code_challenge_method }}&response_type={{ response_type }}&response_mode={{ response_mode }}&deny=true">
|
||||
</form>
|
||||
</div>
|
||||
{%- endblock content -%}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "_layout.html.j2" %}
|
||||
{%- block content -%}
|
||||
<div class="panel">
|
||||
<h1 class-"project-name">{{ hostname }}</h1>
|
||||
<form action="{{ route }}" method="post">
|
||||
<input type="text" name="username" placeholder="Username" required>
|
||||
<input type="password" name="password" placeholder="Password" required>
|
||||
<input type="hidden" name="client_id" value="{{ client_id }}">
|
||||
{%- if let Some(secret) = client_secret -%}
|
||||
<input type="hidden" name="client_secret" value="{{ secret }}">
|
||||
{%- endif -%}
|
||||
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
|
||||
<input type="hidden" name="scope" value="{{ scope }}">
|
||||
<input type="hidden" name="state" value="{{ state }}">
|
||||
<input type="hidden" name="code_challenge" value="{{ code_challenge }}">
|
||||
<input type="hidden" name="code_challenge_method" value="{{ code_challenge_method }}">
|
||||
<input type="hidden" name="response_type" value="{{ response_type }}">
|
||||
<input type="hidden" name="response_mode" value="{{ response_mode }}">
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
{%- endblock content -%}
|
||||
Reference in New Issue
Block a user