Compare commits

..

10 Commits

134 changed files with 4070 additions and 2703 deletions

View File

@@ -1,9 +1,9 @@
# Local build and dev artifacts
target/
!target/debug/conduwuit
# Docker files
Dockerfile*
docker/
# IDE files
.vscode

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
container: [ "ubuntu-latest", "ubuntu-previous", "debian-latest", "debian-oldstable" ]
container: ["ubuntu-latest", "ubuntu-previous", "debian-latest", "debian-oldstable"]
container:
image: "ghcr.io/tcpipuk/act-runner:${{ matrix.container }}"
@@ -30,28 +30,6 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "distribution=$DISTRIBUTION" >> $GITHUB_OUTPUT
echo "Debian distribution: $DISTRIBUTION ($VERSION)"
- name: Work around llvm-project#153385
id: llvm-workaround
run: |
if [ -f /usr/share/apt/default-sequoia.config ]; then
echo "Applying workaround for llvm-project#153385"
mkdir -p /etc/crypto-policies/back-ends/
cp /usr/share/apt/default-sequoia.config /etc/crypto-policies/back-ends/apt-sequoia.config
sed -i 's/\(sha1\.second_preimage_resistance = \)2026-02-01/\12026-06-01/' /etc/crypto-policies/back-ends/apt-sequoia.config
else
echo "No workaround needed for llvm-project#153385"
fi
- name: Pick compatible clang version
id: clang-version
run: |
# both latest need to use clang-23, but oldstable and previous can just use clang
if [[ "${{ matrix.container }}" == "ubuntu-latest" || "${{ matrix.container }}" == "debian-latest" ]]; then
echo "Using clang-23 package for ${{ matrix.container }}"
echo "version=clang-23" >> $GITHUB_OUTPUT
else
echo "Using default clang package for ${{ matrix.container }}"
echo "version=clang" >> $GITHUB_OUTPUT
fi
- name: Checkout repository with full history
uses: actions/checkout@v6
@@ -127,7 +105,7 @@ jobs:
run: |
apt-get update -y
# Build dependencies for rocksdb
apt-get install -y liburing-dev ${{ steps.clang-version.outputs.version }}
apt-get install -y clang liburing-dev
- name: Run cargo-deb
id: cargo-deb

2
.github/FUNDING.yml vendored
View File

@@ -1,4 +1,4 @@
github: [JadedBlueEyes, nexy7574, gingershaped]
github: [JadedBlueEyes, nexy7574]
custom:
- https://ko-fi.com/nexy7574
- https://ko-fi.com/JadedBlueEyes

View File

@@ -23,7 +23,7 @@ repos:
- id: check-added-large-files
- repo: https://github.com/crate-ci/typos
rev: v1.43.4
rev: v1.41.0
hooks:
- id: typos
- id: typos

View File

@@ -6,13 +6,14 @@ extend-exclude = ["*.csr", "*.lock", "pnpm-lock.yaml"]
extend-ignore-re = [
"(?Rm)^.*(#|//|<!--)\\s*spellchecker:disable-line(\\s*-->)$", # Ignore a line by making it trail with a `spellchecker:disable-line` comment
"^[0-9a-f]{7,}$", # Commit hashes
"4BA7",
# some heuristics for base64 strings
"[A-Za-z0-9+=]{72,}",
"([A-Za-z0-9+=]|\\\\\\s\\*){72,}",
"[0-9+][A-Za-z0-9+]{30,}[a-z0-9+]",
"\\$[A-Z0-9+][A-Za-z0-9+]{6,}[a-z0-9+]",
"\\b[a-z0-9+/=][A-Za-z0-9+/=]{7,}[a-z0-9+/=][A-Z]\\b",
# In the renovate config
".ontainer"
]

View File

@@ -1,150 +1,58 @@
# Continuwuity v0.5.5 (2026-02-15)
## Features
- Added unstable support for [MSC4406:
`M_SENDER_IGNORED`](https://github.com/matrix-org/matrix-spec-proposals/pull/4406).
Contributed by @nex ([#1308](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1308))
- Introduce a resolver command to allow flushing a server from the cache or to flush the complete cache. Contributed by
@Omar007 ([#1349](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1349))
- Improved the handling of restricted join rules and improved the performance of local-first joins. Contributed by
@nex. ([#1368](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1368))
- You can now set a custom User Agent for URL previews; the default one has been modified to be less likely to be
rejected. Contributed by @trashpanda ([#1372](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1372))
- Improved the first-time setup experience for new homeserver administrators:
- Account registration is disabled on the first run, except for with a new special registration token that is logged
to the console.
- Other helpful information is logged to the console as well, including a giant warning if open registration is
enabled.
- The default index page now says to check the console for setup instructions if no accounts have been created.
- Once the first admin account is created, an improved welcome message is sent to the admin room.
Contributed by @ginger.
## Bugfixes
- Fixed invites sent to other users in the same homeserver not being properly sent down sync. Users with missing or
broken invites should clear their client caches after updating to make them appear. ([#1249](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1249))
- LDAP-enabled servers will no longer have all admins demoted when LDAP-controlled admins are not configured.
Contributed by @Jade ([#1307](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1307))
- Fixed sliding sync not resolving wildcard state key requests, enabling Video/Audio calls in Element X. ([#1370](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1370))
## Misc
- #1344
# Continuwuity v0.5.4 (2026-02-08)
## Features
- The announcement checker will now announce errors it encounters in the first run to the admin room, plus a few other
misc improvements. Contributed by @Jade ([#1288](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1288))
- Drastically improved the performance and reliability of account deactivations. Contributed by
@nex ([#1314](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1314))
- Refuse to process requests for and events in rooms that we no longer have any local users in (reduces state resets
and improves performance). Contributed by
@nex ([#1316](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1316))
- Added server-specific admin API routes to ban and unban rooms, for use with moderation bots. Contributed by @nex
([#1301](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1301))
## Bugfixes
- Fix the generated configuration containing uncommented optional sections. Contributed by
@Jade ([#1290](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1290))
- Fixed specification non-compliance when handling remote media errors. Contributed by
@nex ([#1298](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1298))
- UIAA requests which check for out-of-band success (sent by matrix-js-sdk) will no longer create unhelpful errors in
the logs. Contributed by @ginger ([#1305](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1305))
- Use exists instead of contains to save writing to a buffer in `src/service/users/mod.rs`: `is_login_disabled`.
Contributed
by @aprilgrimoire. ([#1340](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1340))
- Fixed backtraces being swallowed during panics. Contributed by
@jade ([#1337](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1337))
- Fixed a potential vulnerability that could allow an evil remote server to return malicious events during the room join
and knock process. Contributed by @nex, reported by violet & [mat](https://matdoes.dev).
- Fixed a race condition that could result in outlier PDUs being incorrectly marked as visible to a remote server.
Contributed by @nex, reported by violet & [mat](https://matdoes.dev).
- ACLs are no longer case-sensitive. Contributed by @nex, reported by [vel](matrix:u/vel:nhjkl.com?action=chat).
## Docs
- Fixed Fedora install instructions. Contributed by
@julian45 ([#1342](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1342))
# Continuwuity 0.5.3 (2026-01-12)
## Features
- Improve the display of nested configuration with the `!admin server show-config` command. Contributed by
@Jade ([#1279](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1279))
- Improve the display of nested configuration with the `!admin server show-config` command. Contributed by @Jade (#1279)
## Bugfixes
- Fixed `M_BAD_JSON` error when sending invites to other servers or when providing joins. Contributed by
@nex ([#1286](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1286))
- Fixed `M_BAD_JSON` error when sending invites to other servers or when providing joins. Contributed by @nex (#1286)
## Docs
- Improve admin command documentation generation. Contributed by
@ginger ([#1280](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1280))
- Improve admin command documentation generation. Contributed by @ginger (#1280)
## Misc
- Improve timeout-related code for federation and URL previews. Contributed by
@Jade ([#1278](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1278))
- Improve timeout-related code for federation and URL previews. Contributed by @Jade (#1278)
# Continuwuity 0.5.2 (2026-01-09)
## Features
- Added support for issuing additional registration tokens, stored in the database, which supplement the existing
registration token hardcoded in the config file. These tokens may optionally expire after a certain number of uses or
after a certain amount of time has passed. Additionally, the `registration_token_file` configuration option is
superseded by this feature and **has been removed**. Use the new `!admin token` command family to manage registration
tokens. Contributed by @ginger (#783).
- Implemented a configuration defined admin list independent of the admin room. Contributed by
@Terryiscool160. ([#1253](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1253))
- Added support for invite and join anti-spam via Draupnir and Meowlnir, similar to that of synapse-http-antispam.
Contributed by @nex. ([#1263](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1263))
- Implemented account locking functionality, to complement user suspension. Contributed by
@nex. ([#1266](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1266))
- Added admin command to forcefully log out all of a user's existing sessions. Contributed by
@nex. ([#1271](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1271))
- Implemented toggling the ability for an account to log in without mutating any of its data. Contributed by @nex. (
[#1272](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1272))
- Add support for custom room create event timestamps, to allow generating custom prefixes in hashed room IDs.
Contributed by @nex. ([#1277](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1277))
- Certain potentially dangerous admin commands are now restricted to only be usable in the admin room and server
console. Contributed by @ginger.
- Added support for issuing additional registration tokens, stored in the database, which supplement the existing registration token hardcoded in the config file. These tokens may optionally expire after a certain number of uses or after a certain amount of time has passed. Additionally, the `registration_token_file` configuration option is superseded by this feature and **has been removed**. Use the new `!admin token` command family to manage registration tokens. Contributed by @ginger (#783).
- Implemented a configuration defined admin list independent of the admin room. Contributed by @Terryiscool160. (#1253)
- Added support for invite and join anti-spam via Draupnir and Meowlnir, similar to that of synapse-http-antispam. Contributed by @nex. (#1263)
- Implemented account locking functionality, to complement user suspension. Contributed by @nex. (#1266)
- Added admin command to forcefully log out all of a user's existing sessions. Contributed by @nex. (#1271)
- Implemented toggling the ability for an account to log in without mutating any of its data. Contributed by @nex. (#1272)
- Add support for custom room create event timestamps, to allow generating custom prefixes in hashed room IDs. Contributed by @nex. (#1277)
- Certain potentially dangerous admin commands are now restricted to only be usable in the admin room and server console. Contributed by @ginger.
## Bugfixes
- Fixed unreliable room summary fetching and improved error messages. Contributed by
@nex. ([#1257](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1257))
- Client requested timeout parameter is now applied to e2ee key lookups and claims. Related federation requests are now
also concurrent. Contributed by @nex. ([#1261](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1261))
- Fixed the whoami endpoint returning HTTP 404 instead of HTTP 403, which confused some appservices. Contributed by
@nex. ([#1276](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1276))
- Fixed unreliable room summary fetching and improved error messages. Contributed by @nex. (#1257)
- Client requested timeout parameter is now applied to e2ee key lookups and claims. Related federation requests are now also concurrent. Contributed by @nex. (#1261)
- Fixed the whoami endpoint returning HTTP 404 instead of HTTP 403, which confused some appservices. Contributed by @nex. (#1276)
## Misc
- The `console` feature is now enabled by default, allowing the server console to be used for running admin commands
directly. To automatically open the console on startup, set the `admin_console_automatic` config option to `true`.
Contributed by @ginger.
- The `console` feature is now enabled by default, allowing the server console to be used for running admin commands directly. To automatically open the console on startup, set the `admin_console_automatic` config option to `true`. Contributed by @ginger.
- We now (finally) document our container image mirrors. Contributed by @Jade
# Continuwuity 0.5.0 (2025-12-30)
**This release contains a CRITICAL vulnerability patch, and you must update as soon as possible**
## Features
- Enabled the OTLP exporter in default builds, and allow configuring the exporter protocol. (
@Jade). ([#1251](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1251))
- Enabled the OTLP exporter in default builds, and allow configuring the exporter protocol. (@Jade). (#1251)
## Bug Fixes
- Don't allow admin room upgrades, as this can break the admin room (
@timedout) ([#1245](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1245))
- Fix invalid creators in power levels during upgrade to v12 (
@timedout) ([#1245](https://forgejo.ellis.link/continuwuation/continuwuity/pulls/1245))
- Don't allow admin room upgrades, as this can break the admin room (@timedout) (#1245)
- Fix invalid creators in power levels during upgrade to v12 (@timedout) (#1245)

596
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@ license = "Apache-2.0"
# See also `rust-toolchain.toml`
readme = "README.md"
repository = "https://forgejo.ellis.link/continuwuation/continuwuity"
version = "0.5.5"
version = "0.5.3"
[workspace.metadata.crane]
name = "conduwuit"
@@ -158,7 +158,7 @@ features = ["raw_value"]
# Used for appservice registration files
[workspace.dependencies.serde-saphyr]
version = "0.0.18"
version = "0.0.14"
# Used to load forbidden room/user regex from config
[workspace.dependencies.serde_regex]
@@ -342,7 +342,7 @@ version = "0.1.2"
# Used for matrix spec type definitions and helpers
[workspace.dependencies.ruma]
git = "https://forgejo.ellis.link/continuwuation/ruwuma"
rev = "b496b7f38d517149361a882e75d3fd4faf210441"
rev = "85d00fb5746cba23904234b4fd3c838dcf141541"
features = [
"compat",
"rand",
@@ -378,8 +378,7 @@ features = [
"unstable-msc4210", # remove legacy mentions
"unstable-extensible-events",
"unstable-pdu",
"unstable-msc4155",
"unstable-msc4143", # livekit well_known response
"unstable-msc4155"
]
[workspace.dependencies.rust-rocksdb]
@@ -549,11 +548,9 @@ features = ["sync", "tls-rustls", "rustls-provider"]
[workspace.dependencies.resolv-conf]
version = "0.7.5"
[workspace.dependencies.yansi]
version = "1.0.1"
[workspace.dependencies.askama]
version = "0.14.0"
# Used by stitched ordering
[workspace.dependencies.indexmap]
version = "2.13.0"
#
# Patches

View File

@@ -57,10 +57,9 @@ ### What are the project's goals?
### Can I try it out?
Check out the [documentation](https://continuwuity.org) for installation instructions, or join one of these vetted public homeservers running Continuwuity to get a feel for things!
Check out the [documentation](https://continuwuity.org) for installation instructions.
- https://continuwuity.rocks -- A public demo server operated by the Continuwuity Team.
- https://federated.nexus -- Federated Nexus is a community resource hosting multiple FOSS (especially federated) services, including Matrix and Forgejo.
There are currently no open registration Continuwuity instances available.
### What are we working on?

View File

@@ -2,7 +2,11 @@
set -euo pipefail
# The root path where complement is available.
# Path to Complement's source code
#
# The `COMPLEMENT_SRC` environment variable is set in the Nix dev shell, which
# points to a store path containing the Complement source code. It's likely you
# want to just pass that as the first argument to use it here.
COMPLEMENT_SRC="${COMPLEMENT_SRC:-$1}"
# A `.jsonl` file to write test logs to
@@ -11,10 +15,7 @@ LOG_FILE="${2:-complement_test_logs.jsonl}"
# A `.jsonl` file to write test results to
RESULTS_FILE="${3:-complement_test_results.jsonl}"
# The base docker image to use for complement tests
# You can build the default with `docker build -t continuwuity:complement -f ./docker/complement.Dockerfile .`
# after running `cargo build`. Only the debug binary is used.
COMPLEMENT_BASE_IMAGE="${COMPLEMENT_BASE_IMAGE:-continuwuity:complement}"
COMPLEMENT_BASE_IMAGE="${COMPLEMENT_BASE_IMAGE:-complement-conduwuit:main}"
# Complement tests that are skipped due to flakiness/reliability issues or we don't implement such features and won't for a long time
SKIPPED_COMPLEMENT_TESTS='TestPartialStateJoin.*|TestRoomDeleteAlias/Parallel/Regular_users_can_add_and_delete_aliases_when_m.*|TestRoomDeleteAlias/Parallel/Can_delete_canonical_alias|TestUnbanViaInvite.*|TestRoomState/Parallel/GET_/publicRooms_lists.*"|TestRoomDeleteAlias/Parallel/Users_with_sufficient_power-level_can_delete_other.*'
@@ -33,6 +34,25 @@ toplevel="$(git rev-parse --show-toplevel)"
pushd "$toplevel" > /dev/null
if [ ! -f "complement_oci_image.tar.gz" ]; then
echo "building complement conduwuit image"
# if using macOS, use linux-complement
#bin/nix-build-and-cache just .#linux-complement
bin/nix-build-and-cache just .#complement
#nix build -L .#complement
echo "complement conduwuit image tar.gz built at \"result\""
echo "loading into docker"
docker load < result
popd > /dev/null
else
echo "skipping building a complement conduwuit image as complement_oci_image.tar.gz was already found, loading this"
docker load < complement_oci_image.tar.gz
popd > /dev/null
fi
echo ""
echo "running go test with:"
@@ -52,16 +72,24 @@ env \
set -o pipefail
# Post-process the results into an easy-to-compare format, sorted by Test name for reproducible results
jq -s -c 'sort_by(.Test)[]' < "$LOG_FILE" | jq -c '
cat "$LOG_FILE" | jq -s -c 'sort_by(.Test)[]' | jq -c '
select(
(.Action == "pass" or .Action == "fail" or .Action == "skip")
and .Test != null
) | {Action: .Action, Test: .Test}
' > "$RESULTS_FILE"
#if command -v gotestfmt &> /dev/null; then
# echo "using gotestfmt on $LOG_FILE"
# grep '{"Time":' "$LOG_FILE" | gotestfmt > "complement_test_logs_gotestfmt.log"
#fi
echo ""
echo ""
echo "complement logs saved at $LOG_FILE"
echo "complement results saved at $RESULTS_FILE"
#if command -v gotestfmt &> /dev/null; then
# echo "complement logs in gotestfmt pretty format outputted at complement_test_logs_gotestfmt.log (use an editor/terminal/pager that interprets ANSI colours and UTF-8 emojis)"
#fi
echo ""
echo ""

View File

@@ -0,0 +1 @@
The announcement checker will now announce errors it encounters in the first run to the admin room, plus a few other misc improvements. Contributed by @Jade

View File

@@ -0,0 +1 @@
Fix the generated configuration containing uncommented optional sections. Contributed by @Jade

1
changelog.d/1298.bugfix Normal file
View File

@@ -0,0 +1 @@
Fixed specification non-compliance when handling remote media errors. Contributed by @nex.

View File

@@ -1,67 +0,0 @@
#!/usr/bin/env bash
set -xe
# If we have no $SERVER_NAME set, abort
if [ -z "$SERVER_NAME" ]; then
echo "SERVER_NAME is not set, aborting"
exit 1
fi
# If /complement/ca/ca.crt or /complement/ca/ca.key are missing, abort
if [ ! -f /complement/ca/ca.crt ] || [ ! -f /complement/ca/ca.key ]; then
echo "/complement/ca/ca.crt or /complement/ca/ca.key is missing, aborting"
exit 1
fi
# Add the root cert to the local trust store
echo 'Installing Complement CA certificate to local trust store'
cp /complement/ca/ca.crt /usr/local/share/ca-certificates/complement-ca.crt
update-ca-certificates
# Sign a certificate for our $SERVER_NAME
echo "Generating and signing certificate for $SERVER_NAME"
openssl genrsa -out "/$SERVER_NAME.key" 2048
echo "Generating CSR for $SERVER_NAME"
openssl req -new -sha256 \
-key "/$SERVER_NAME.key" \
-out "/$SERVER_NAME.csr" \
-subj "/C=US/ST=CA/O=Continuwuity, Inc./CN=$SERVER_NAME"\
-addext "subjectAltName=DNS:$SERVER_NAME"
openssl req -in "$SERVER_NAME.csr" -noout -text
echo "Signing certificate for $SERVER_NAME with Complement CA"
cat <<EOF > ./cert.ext
authorityKeyIdentifier=keyid,issuer
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment, dataEncipherment, nonRepudiation
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = *.docker.internal
DNS.2 = hs1
DNS.3 = hs2
DNS.4 = hs3
DNS.5 = hs4
DNS.6 = $SERVER_NAME
IP.1 = 127.0.0.1
EOF
openssl x509 \
-req \
-in "/$SERVER_NAME.csr" \
-CA /complement/ca/ca.crt \
-CAkey /complement/ca/ca.key \
-CAcreateserial \
-out "/$SERVER_NAME.crt" \
-days 1 \
-sha256 \
-extfile ./cert.ext
# Tell continuwuity where to find the certs
export CONTINUWUITY_TLS__KEY="/$SERVER_NAME.key"
export CONTINUWUITY_TLS__CERTS="/$SERVER_NAME.crt"
# And who it is
export CONTINUWUITY_SERVER_NAME="$SERVER_NAME"
echo "Starting Continuwuity with SERVER_NAME=$SERVER_NAME"
# Start continuwuity
/usr/local/bin/conduwuit --config /etc/continuwuity/config.toml

View File

@@ -1,53 +0,0 @@
# ============================================= #
# Complement pre-filled configuration file #
#
# DANGER: THIS FILE FORCES INSECURE VALUES. #
# DO NOT USE OUTSIDE THE TEST SUITE ENV! #
# ============================================= #
[global]
address = "0.0.0.0"
allow_device_name_federation = true
allow_guest_registration = true
allow_public_room_directory_over_federation = true
allow_public_room_directory_without_auth = true
allow_registration = true
database_path = "/database"
log = "trace,h2=debug,hyper=debug"
port = [8008, 8448]
trusted_servers = []
only_query_trusted_key_servers = false
query_trusted_key_servers_first = false
query_trusted_key_servers_first_on_join = false
yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse = true
ip_range_denylist = []
url_preview_domain_contains_allowlist = ["*"]
url_preview_domain_explicit_denylist = ["*"]
media_compat_file_link = false
media_startup_check = true
prune_missing_media = true
log_colors = true
admin_room_notices = false
allow_check_for_updates = false
intentionally_unknown_config_option_for_testing = true
rocksdb_log_level = "info"
rocksdb_max_log_files = 1
rocksdb_recovery_mode = 0
rocksdb_paranoid_file_checks = true
log_guest_registrations = false
allow_legacy_media = true
startup_netburst = true
startup_netburst_keep = -1
allow_invalid_tls_certificates_yes_i_know_what_the_fuck_i_am_doing_with_this_and_i_know_this_is_insecure = true
dns_timeout = 60
dns_attempts = 20
request_conn_timeout = 60
request_timeout = 120
well_known_conn_timeout = 60
well_known_timeout = 60
federation_idle_timeout = 300
sender_timeout = 300
sender_idle_timeout = 300
sender_retry_backoff_limit = 300
[global.tls]
dual_protocol = true

View File

@@ -433,7 +433,7 @@
# If you would like registration only via token reg, please configure
# `registration_token`.
#
#allow_registration = true
#allow_registration = false
# If registration is enabled, and this setting is true, new users
# registered after the first admin user will be automatically suspended
@@ -1474,10 +1474,6 @@
#
#url_preview_check_root_domain = false
# User agent that is used specifically when fetching url previews.
#
#url_preview_user_agent = "continuwuity/<version> (bot; +https://continuwuity.org)"
# List of forbidden room aliases and room IDs as strings of regex
# patterns.
#
@@ -1824,17 +1820,6 @@
#
#support_mxid =
# A list of MatrixRTC foci URLs which will be served as part of the
# MSC4143 client endpoint at /.well-known/matrix/client. If you're
# setting up livekit, you'd want something like:
# rtc_focus_server_urls = [
# { type = "livekit", livekit_service_url = "https://livekit.example.com" },
# ]
#
# To disable, set this to be an empty vector (`[]`).
#
#rtc_focus_server_urls = []
[global.blurhashing]
# blurhashing x component, 4 is recommended by https://blurha.sh/

View File

@@ -48,7 +48,7 @@ EOF
# Developer tool versions
# renovate: datasource=github-releases depName=cargo-bins/cargo-binstall
ENV BINSTALL_VERSION=1.17.5
ENV BINSTALL_VERSION=1.16.7
# renovate: datasource=github-releases depName=psastras/sbom-rs
ENV CARGO_SBOM_VERSION=0.9.1
# renovate: datasource=crate depName=lddtree

View File

@@ -1,11 +0,0 @@
FROM ubuntu:latest
EXPOSE 8008
EXPOSE 8448
RUN apt-get update && apt-get install -y ca-certificates liburing2 && rm -rf /var/lib/apt/lists/*
RUN mkdir -p /etc/continuwuity /var/lib/continuwuity /usr/local/bin/
COPY complement/complement-entrypoint.sh /usr/local/bin/complement-entrypoint.sh
COPY complement/complement.config.toml /etc/continuwuity/config.toml
COPY target/debug/conduwuit /usr/local/bin/conduwuit
RUN chmod +x /usr/local/bin/conduwuit /usr/local/bin/complement-entrypoint.sh
#HEALTHCHECK --interval=30s --timeout=5s CMD curl --fail http://localhost:8008/_continuwuity/server_version || exit 1
ENTRYPOINT ["/usr/local/bin/complement-entrypoint.sh"]

View File

@@ -18,7 +18,7 @@ RUN --mount=type=cache,target=/etc/apk/cache apk add \
# Developer tool versions
# renovate: datasource=github-releases depName=cargo-bins/cargo-binstall
ENV BINSTALL_VERSION=1.17.5
ENV BINSTALL_VERSION=1.16.7
# renovate: datasource=github-releases depName=psastras/sbom-rs
ENV CARGO_SBOM_VERSION=0.9.1
# renovate: datasource=crate depName=lddtree

View File

@@ -34,14 +34,6 @@
"name": "troubleshooting",
"label": "Troubleshooting"
},
"security",
{
"type": "dir-section-header",
"name": "community",
"label": "Community",
"collapsible": true,
"collapsed": false
},
{
"type": "divider"
},
@@ -71,5 +63,7 @@
},
{
"type": "divider"
}
},
"community",
"security"
]

View File

@@ -19,21 +19,16 @@
{
"text": "Admin Command Reference",
"link": "/reference/admin/"
},
{
"text": "Server Reference",
"link": "/reference/server"
}
]
},
{
"text": "Community",
"items": [
{
"text": "Community Guidelines",
"link": "/community/guidelines"
},
{
"text": "Become a Partnered Homeserver!",
"link": "/community/ops-guidelines"
}
]
"link": "/community"
},
{
"text": "Security",

View File

@@ -1,12 +0,0 @@
[
{
"type": "file",
"name": "guidelines",
"label": "Community Guidelines"
},
{
"type": "file",
"name": "ops-guidelines",
"label": "Partnered Homeserver Guidelines"
}
]

View File

@@ -1,32 +0,0 @@
# Partnered Homeserver Operator Requirements
> _So you want to be an officially sanctioned public Continuwuity homeserver operator?_
Thank you for your interest in the project! There's a few things we need from you first to make sure your homeserver meets our quality standards and that you are prepared to handle the additional workload introduced by operating a public chat service.
## Stuff you must have
if you don't do these things we will tell you to go away
- Your homeserver must be running an up-to-date version of Continuwuity
- You must have a CAPTCHA, external registration system, or apply-to-join system that provides one-time-use invite codes (we do not accept fully open nor static token registration)
- Your homeserver must have support details listed in [`/.well-known/matrix/support`](https://spec.matrix.org/v1.17/client-server-api/#getwell-knownmatrixsupport)
- Your rules and guidelines must align with [the project's own code of conduct](guidelines).
- You must be reasonably responsive (i.e. don't leave us hanging for a week if we alert you to an issue on your server)
- Your homeserver's community rooms (if any) must be protected by a moderation bot subscribed to policy lists like the Community Moderation Effort (you can get one from https://asgard.chat if you don't want to run your own)
## Stuff we encourage you to have
not strictly required but we will consider your request more strongly if you have it
- You should have automated moderation tooling that can automatically suspend abusive users on your homeserver who are added to policy lists
- You should have multiple server administrators (increased bus factor)
- You should have a terms of service and privacy policy prominently available
## Stuff you get
- Prominent listing in our README!
- A gold star sticker
- Access to a low noise room for more direct communication with maintainers and collaboration with fellow operators
- Read-only access to the continuwuity internal ban list
- Early notice of upcoming releases
## Sound good?
To get started, ping a team member in [our main chatroom](https://matrix.to/#/#continuwuity:continuwuity.org) and ask to be added to the list.

View File

@@ -1,18 +1,17 @@
# RPM Installation Guide
Continuwuity is available as RPM packages for Fedora and compatible distributions.
We do not currently have infrastructure to build RPMs for RHEL and compatible distributions, but this is a work in progress.
Continuwuity is available as RPM packages for Fedora, RHEL, and compatible distributions.
The RPM packaging files are maintained in the `fedora/` directory:
- `continuwuity.spec.rpkg` - RPM spec file using rpkg macros for building from git
- `continuwuity.service` - Systemd service file for the server
- `RPM-GPG-KEY-continuwuity.asc` - GPG public key for verifying signed packages
RPM packages built by CI are signed with our GPG key (RSA, ID: `6595 E8DB 9191 D39A 46D6 A514 4BA7 F590 DF0B AA1D`). # spellchecker:disable-line
RPM packages built by CI are signed with our GPG key (Ed25519, ID: `5E0FF73F411AAFCA`).
```bash
# Import the signing key
sudo rpm --import https://forgejo.ellis.link/api/packages/continuwuation/rpm/repository.key
sudo rpm --import https://forgejo.ellis.link/continuwuation/continuwuity/raw/branch/main/fedora/RPM-GPG-KEY-continuwuity.asc
# Verify a downloaded package
rpm --checksig continuwuity-*.rpm
@@ -24,7 +23,7 @@ ## Installation methods
```bash
# Add the repository and install
sudo dnf config-manager addrepo --from-repofile=https://forgejo.ellis.link/api/packages/continuwuation/rpm/stable.repo
sudo dnf config-manager addrepo --from-repofile=https://forgejo.ellis.link/api/packages/continuwuation/rpm/stable/continuwuation.repo
sudo dnf install continuwuity
```
@@ -32,7 +31,7 @@ # Add the repository and install
```bash
# Add the dev repository and install
sudo dnf config-manager addrepo --from-repofile=https://forgejo.ellis.link/api/packages/continuwuation/rpm/dev.repo
sudo dnf config-manager addrepo --from-repofile=https://forgejo.ellis.link/api/packages/continuwuation/rpm/dev/continuwuation.repo
sudo dnf install continuwuity
```
@@ -40,10 +39,23 @@ # Add the dev repository and install
```bash
# Branch names are sanitized (slashes become hyphens, lowercase only)
sudo dnf config-manager addrepo --from-repofile=https://forgejo.ellis.link/api/packages/continuwuation/rpm/tom-new-feature.repo
sudo dnf config-manager addrepo --from-repofile=https://forgejo.ellis.link/api/packages/continuwuation/rpm/tom-new-feature/continuwuation.repo
sudo dnf install continuwuity
```
**Direct installation** without adding repository
```bash
# Latest stable release
sudo dnf install https://forgejo.ellis.link/api/packages/continuwuation/rpm/stable/continuwuity
# Latest development build
sudo dnf install https://forgejo.ellis.link/api/packages/continuwuation/rpm/dev/continuwuity
# Specific feature branch
sudo dnf install https://forgejo.ellis.link/api/packages/continuwuation/rpm/branch-name/continuwuity
```
**Manual repository configuration** (alternative method)
```bash
@@ -53,7 +65,7 @@ # Branch names are sanitized (slashes become hyphens, lowercase only)
baseurl=https://forgejo.ellis.link/api/packages/continuwuation/rpm/stable
enabled=1
gpgcheck=1
gpgkey=https://forgejo.ellis.link/api/packages/continuwuation/rpm/repository.key
gpgkey=https://forgejo.ellis.link/continuwuation/continuwuity/raw/branch/main/fedora/RPM-GPG-KEY-continuwuity.asc
EOF
sudo dnf install continuwuity

View File

@@ -269,7 +269,7 @@ # If federation is enabled
```
- To check if your server can communicate with other homeservers, use the
[Matrix Federation Tester](https://federationtester.mtrnord.blog/). If you can
[Matrix Federation Tester](https://federationtester.matrix.org/). If you can
register but cannot join federated rooms, check your configuration and verify
that port 8448 is open and forwarded correctly.

View File

@@ -19,16 +19,6 @@
src: /assets/logo.svg
alt: continuwuity logo
beforeFeatures:
- title: Matrix for Discord users
details: New to Matrix? Learn how Matrix compares to Discord
link: https://joinmatrix.org/guide/matrix-vs-discord/
buttonText: Find Out the Difference
- title: How Matrix Works
details: Learn how Matrix works under the hood, and what that means
link: https://matrix.org/docs/matrix-concepts/elements-of-matrix/
buttonText: Read the Guide
features:
- title: 🚀 High Performance
details: Built with Rust for exceptional speed and efficiency. Designed to run smoothly even on modest hardware.

View File

@@ -6,10 +6,10 @@
"message": "Welcome to Continuwuity! Important announcements about the project will appear here."
},
{
"id": 9,
"id": 8,
"mention_room": false,
"date": "2026-02-09",
"message": "Yesterday we released [v0.5.4](https://forgejo.ellis.link/continuwuation/continuwuity/releases/tag/v0.5.4). Bugfixes, performance improvements and more moderation features! There's also a security fix, so please update as soon as possible. Don't forget to join [our announcements channel](https://matrix.to/#/!jIdNjSM5X-V5JVx2h2kAhUZIIQ08GyzPL55NFZAH1vM/%2489TY9CqRg4-ff1MGo3Ulc5r5X4pakfdzT-99RD8Docc?via=ellis.link&via=explodie.org&via=matrix.org) to get important information sooner <3 "
"date": "2026-01-12",
"message": "Hey everyone!\n\nJust letting you know we've released [v0.5.3](https://forgejo.ellis.link/continuwuation/continuwuity/releases/tag/v0.5.3) - this one is a bit of a hotfix for an issue with inviting and allowing others to join rooms.\n\nIf you appreceate the round-the-clock work we've been doing to keep your servers secure over this holiday period, we'd really appreciate your support - you can sponsor individuals on our team using the 'sponsor' button at the top of [our GitHub repository](https://github.com/continuwuity/continuwuity). If you can't do that, even a star helps - spreading the word and advocating for our project helps keep it going.\n\nHave a lovely rest of your year \\\n[Jade \\(she/her\\)](https://matrix.to/#/%40jade%3Aellis.link) \n🩵"
}
]
}

View File

@@ -112,19 +112,6 @@ ### `!admin query resolver overrides-cache`
Query the overrides cache
### `!admin query resolver flush-cache`
Flush a given server from the resolver caches or flush them completely
* Examples:
* Flush a specific server:
`!admin query resolver flush-cache matrix.example.com`
* Flush all resolver caches completely:
`!admin query resolver flush-cache --all`
## `!admin query pusher`
pusher service

View File

@@ -20,16 +20,6 @@ ### Lost access to admin room
## General potential issues
### Configuration not working as expected
Sometimes you can make a mistake in your configuration that
means things don't get passed to Continuwuity correctly.
This is particularly easy to do with environment variables.
To check what configuration Continuwuity actually sees, you can
use the `!admin server show-config` command in your admin room.
Beware that this prints out any secrets in your configuration,
so you might want to delete the result afterwards!
### Potential DNS issues when using Docker
Docker's DNS setup for containers in a non-default network intercepts queries to
@@ -149,7 +139,7 @@ ### Database corruption
## Debugging
Note that users should not really need to debug things. If you find yourself
Note that users should not really be debugging things. If you find yourself
debugging and find the issue, please let us know and/or how we can fix it.
Various debug commands can be found in `!admin debug`.
@@ -188,31 +178,6 @@ ### Pinging servers
and simply fetches a string on a static JSON endpoint. It is very low cost both
bandwidth and computationally.
### Enabling backtraces for errors
Continuwuity can capture backtraces (stack traces) for errors to help diagnose
issues. Backtraces show the exact sequence of function calls that led to an
error, which is invaluable for debugging.
To enable backtraces, set the `RUST_BACKTRACE` environment variable before starting Continuwuity:
```bash
# For both panics and errors
RUST_BACKTRACE=1 ./conduwuit
```
For systemd deployments, add this to your service file:
```ini
[Service]
Environment="RUST_BACKTRACE=1"
```
Backtrace capture has a performance cost. Avoid leaving it on.
You can also enable it only for panics by setting
`RUST_BACKTRACE=1` and `RUST_LIB_BACKTRACE=0`.
### Allocator memory stats
When using jemalloc with jemallocator's `stats` feature (`--enable-stats`), you

1862
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,9 +22,10 @@
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"@rspress/core": "^2.0.0",
"@rspress/plugin-client-redirects": "^2.0.0",
"@rspress/plugin-sitemap": "^2.0.0",
"@rspress/core": "^2.0.0-rc.1",
"@rspress/plugin-client-redirects": "^2.0.0-alpha.12",
"@rspress/plugin-preview": "^2.0.0-beta.35",
"@rspress/plugin-sitemap": "^2.0.0-beta.23",
"typescript": "^5.9.3"
}
}

View File

@@ -1,4 +1,5 @@
import { defineConfig } from '@rspress/core';
import { pluginPreview } from '@rspress/plugin-preview';
import { pluginSitemap } from '@rspress/plugin-sitemap';
import { pluginClientRedirects } from '@rspress/plugin-client-redirects';
@@ -40,7 +41,7 @@ export default defineConfig({
},
},
plugins: [pluginSitemap({
plugins: [pluginPreview(), pluginSitemap({
siteUrl: 'https://continuwuity.org', // TODO: Set automatically in build pipeline
}),
pluginClientRedirects({
@@ -53,9 +54,6 @@ export default defineConfig({
}, {
from: '/server_reference',
to: '/reference/server'
}, {
from: '/community$',
to: '/community/guidelines'
}
]
})],

View File

@@ -1,5 +1,5 @@
use clap::Subcommand;
use conduwuit::{Err, Result, utils::time};
use conduwuit::{Result, utils::time};
use futures::StreamExt;
use ruma::OwnedServerName;
@@ -7,7 +7,6 @@
#[admin_command_dispatch]
#[derive(Debug, Subcommand)]
#[allow(clippy::enum_variant_names)]
/// Resolver service and caches
pub enum ResolverCommand {
/// Query the destinations cache
@@ -19,14 +18,6 @@ pub enum ResolverCommand {
OverridesCache {
name: Option<String>,
},
/// Flush a specific server from the resolver caches or everything
FlushCache {
name: Option<OwnedServerName>,
#[arg(short, long)]
all: bool,
},
}
#[admin_command]
@@ -78,18 +69,3 @@ async fn overrides_cache(&self, server_name: Option<String>) -> Result {
Ok(())
}
#[admin_command]
async fn flush_cache(&self, name: Option<OwnedServerName>, all: bool) -> Result {
if all {
self.services.resolver.cache.clear().await;
writeln!(self, "Resolver caches cleared!").await
} else if let Some(name) = name {
self.services.resolver.cache.del_destination(&name);
self.services.resolver.cache.del_override(&name);
self.write_str(&format!("Cleared {name} from resolver caches!"))
.await
} else {
Err!("Missing name. Supply a name or use --all to flush the whole cache.")
}
}

View File

@@ -3,9 +3,12 @@
fmt::Write as _,
};
use api::client::{full_user_deactivate, join_room_by_id_helper, leave_room, remote_leave_room};
use api::client::{
full_user_deactivate, join_room_by_id_helper, leave_all_rooms, leave_room, remote_leave_room,
update_avatar_url, update_displayname,
};
use conduwuit::{
Err, Result, debug_warn, error, info,
Err, Result, debug, debug_warn, error, info, is_equal_to,
matrix::{Event, pdu::PduBuilder},
utils::{self, ReadyExt},
warn,
@@ -140,6 +143,7 @@ pub(super) async fn create_user(&self, username: String, password: Option<String
self.services.globals.server_name().to_owned(),
room_server_name.to_owned(),
],
None,
&None,
)
.await
@@ -167,8 +171,27 @@ pub(super) async fn create_user(&self, username: String, password: Option<String
// we dont add a device since we're not the user, just the creator
// Make the first user to register an administrator and disable first-run mode.
self.services.firstrun.empower_first_user(&user_id).await?;
// if this account creation is from the CLI / --execute, invite the first user
// to admin room
if let Ok(admin_room) = self.services.admin.get_admin_room().await {
if self
.services
.rooms
.state_cache
.room_joined_count(&admin_room)
.await
.is_ok_and(is_equal_to!(1))
{
self.services
.admin
.make_user_admin(&user_id)
.boxed()
.await?;
warn!("Granting {user_id} admin privileges as the first user");
}
} else {
debug!("create_user admin command called without an admin room being available");
}
self.write_str(&format!("Created user with user_id: {user_id} and password: `{password}`"))
.await
@@ -204,6 +227,9 @@ pub(super) async fn deactivate(&self, no_leave_rooms: bool, user_id: String) ->
full_user_deactivate(self.services, &user_id, &all_joined_rooms)
.boxed()
.await?;
update_displayname(self.services, &user_id, None, &all_joined_rooms).await;
update_avatar_url(self.services, &user_id, None, None, &all_joined_rooms).await;
leave_all_rooms(self.services, &user_id).await;
}
self.write_str(&format!("User {user_id} has been deactivated"))
@@ -380,6 +406,10 @@ pub(super) async fn deactivate_all(&self, no_leave_rooms: bool, force: bool) ->
full_user_deactivate(self.services, &user_id, &all_joined_rooms)
.boxed()
.await?;
update_displayname(self.services, &user_id, None, &all_joined_rooms).await;
update_avatar_url(self.services, &user_id, None, None, &all_joined_rooms)
.await;
leave_all_rooms(self.services, &user_id).await;
}
},
}
@@ -529,6 +559,7 @@ pub(super) async fn force_join_list_of_local_users(
&room_id,
Some(String::from(BULK_JOIN_REASON)),
&servers,
None,
&None,
)
.await
@@ -614,6 +645,7 @@ pub(super) async fn force_join_all_local_users(
&room_id,
Some(String::from(BULK_JOIN_REASON)),
&servers,
None,
&None,
)
.await
@@ -653,7 +685,8 @@ pub(super) async fn force_join_room(
self.services.globals.user_is_local(&user_id),
"Parsed user_id must be a local user"
);
join_room_by_id_helper(self.services, &user_id, &room_id, None, &servers, &None).await?;
join_room_by_id_helper(self.services, &user_id, &room_id, None, &servers, None, &None)
.await?;
self.write_str(&format!("{user_id} has been joined to {room_id}.",))
.await

View File

@@ -3,7 +3,7 @@
use axum::extract::State;
use axum_client_ip::InsecureClientIp;
use conduwuit::{
Err, Error, Event, Result, debug_info, err, error, info,
Err, Error, Event, Result, debug_info, err, error, info, is_equal_to,
matrix::pdu::PduBuilder,
utils::{self, ReadyExt, stream::BroadbandExt},
warn,
@@ -26,7 +26,6 @@
events::{
GlobalAccountDataEventType, StateEventType,
room::{
member::{MembershipState, RoomMemberEventContent},
message::RoomMessageEventContent,
power_levels::{RoomPowerLevels, RoomPowerLevelsEventContent},
},
@@ -148,12 +147,7 @@ pub(crate) async fn register_route(
let is_guest = body.kind == RegistrationKind::Guest;
let emergency_mode_enabled = services.config.emergency_password.is_some();
// Allow registration if it's enabled in the config file or if this is the first
// run (so the first user account can be created)
let allow_registration =
services.config.allow_registration || services.firstrun.is_first_run();
if !allow_registration && body.appservice_info.is_none() {
if !services.config.allow_registration && body.appservice_info.is_none() {
match (body.username.as_ref(), body.initial_device_display_name.as_ref()) {
| (Some(username), Some(device_display_name)) => {
info!(
@@ -190,10 +184,17 @@ pub(crate) async fn register_route(
)));
}
if is_guest && !services.config.allow_guest_registration {
if is_guest
&& (!services.config.allow_guest_registration
|| (services.config.allow_registration
&& services
.registration_tokens
.get_config_file_token()
.is_some()))
{
info!(
"Guest registration disabled, rejecting guest registration attempt, initial device \
name: \"{}\"",
"Guest registration disabled / registration enabled with token configured, \
rejecting guest registration attempt, initial device name: \"{}\"",
body.initial_device_display_name.as_deref().unwrap_or("")
);
return Err!(Request(GuestAccessForbidden("Guest registration is disabled.")));
@@ -307,63 +308,54 @@ pub(crate) async fn register_route(
let skip_auth = body.appservice_info.is_some() || is_guest;
// Populate required UIAA flows
if services.firstrun.is_first_run() {
// Registration token forced while in first-run mode
if services
.registration_tokens
.iterate_tokens()
.next()
.await
.is_some()
{
// Registration token required
uiaainfo.flows.push(AuthFlow {
stages: vec![AuthType::RegistrationToken],
});
} else {
if services
.registration_tokens
.iterate_tokens()
.next()
.await
.is_some()
}
if services.config.recaptcha_private_site_key.is_some() {
if let Some(pubkey) = &services.config.recaptcha_site_key {
// ReCaptcha required
uiaainfo
.flows
.push(AuthFlow { stages: vec![AuthType::ReCaptcha] });
uiaainfo.params = serde_json::value::to_raw_value(&serde_json::json!({
"m.login.recaptcha": {
"public_key": pubkey,
},
}))
.expect("Failed to serialize recaptcha params");
}
}
if uiaainfo.flows.is_empty() && !skip_auth {
// Registration isn't _disabled_, but there's no captcha configured and no
// registration tokens currently set. Bail out by default unless open
// registration was explicitly enabled.
if !services
.config
.yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse
{
// Registration token required
uiaainfo.flows.push(AuthFlow {
stages: vec![AuthType::RegistrationToken],
});
return Err!(Request(Forbidden(
"This server is not accepting registrations at this time."
)));
}
if services.config.recaptcha_private_site_key.is_some() {
if let Some(pubkey) = &services.config.recaptcha_site_key {
// ReCaptcha required
uiaainfo
.flows
.push(AuthFlow { stages: vec![AuthType::ReCaptcha] });
uiaainfo.params = serde_json::value::to_raw_value(&serde_json::json!({
"m.login.recaptcha": {
"public_key": pubkey,
},
}))
.expect("Failed to serialize recaptcha params");
}
}
if uiaainfo.flows.is_empty() && !skip_auth {
// Registration isn't _disabled_, but there's no captcha configured and no
// registration tokens currently set. Bail out by default unless open
// registration was explicitly enabled.
if !services
.config
.yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse
{
return Err!(Request(Forbidden(
"This server is not accepting registrations at this time."
)));
}
// We have open registration enabled (😧), provide a dummy stage
uiaainfo = UiaaInfo {
flows: vec![AuthFlow { stages: vec![AuthType::Dummy] }],
completed: Vec::new(),
params: Box::default(),
session: None,
auth_error: None,
};
}
// We have open registration enabled (😧), provide a dummy stage
uiaainfo = UiaaInfo {
flows: vec![AuthFlow { stages: vec![AuthType::Dummy] }],
completed: Vec::new(),
params: Box::default(),
session: None,
auth_error: None,
};
}
if !skip_auth {
@@ -521,29 +513,39 @@ pub(crate) async fn register_route(
}
}
// If this is the first real user, grant them admin privileges except for guest
// users
// Note: the server user is generated first
if !is_guest {
// Make the first user to register an administrator and disable first-run mode.
let was_first_user = services.firstrun.empower_first_user(&user_id).await?;
// If the registering user was not the first and we're suspending users on
// register, suspend them.
if !was_first_user && services.config.suspend_on_register {
// Note that we can still do auto joins for suspended users
services
.users
.suspend_account(&user_id, &services.globals.server_user)
.await;
// And send an @room notice to the admin room, to prompt admins to review the
// new user and ideally unsuspend them if deemed appropriate.
if services.server.config.admin_room_notices {
if let Ok(admin_room) = services.admin.get_admin_room().await {
if services
.rooms
.state_cache
.room_joined_count(&admin_room)
.await
.is_ok_and(is_equal_to!(1))
{
services.admin.make_user_admin(&user_id).boxed().await?;
warn!("Granting {user_id} admin privileges as the first user");
} else if services.config.suspend_on_register {
// This is not an admin, suspend them.
// Note that we can still do auto joins for suspended users
services
.admin
.send_loud_message(RoomMessageEventContent::text_plain(format!(
"User {user_id} has been suspended as they are not the first user on \
this server. Please review and unsuspend them if appropriate."
)))
.await
.ok();
.users
.suspend_account(&user_id, &services.globals.server_user)
.await;
// And send an @room notice to the admin room, to prompt admins to review the
// new user and ideally unsuspend them if deemed appropriate.
if services.server.config.admin_room_notices {
services
.admin
.send_loud_message(RoomMessageEventContent::text_plain(format!(
"User {user_id} has been suspended as they are not the first user \
on this server. Please review and unsuspend them if appropriate."
)))
.await
.ok();
}
}
}
}
@@ -580,6 +582,7 @@ pub(crate) async fn register_route(
&room_id,
Some("Automatically joining this room upon registration".to_owned()),
&[services.globals.server_name().to_owned(), room_server_name.to_owned()],
None,
&body.appservice_info,
)
.boxed()
@@ -812,6 +815,9 @@ pub(crate) async fn deactivate_route(
.collect()
.await;
super::update_displayname(&services, sender_user, None, &all_joined_rooms).await;
super::update_avatar_url(&services, sender_user, None, None, &all_joined_rooms).await;
full_user_deactivate(&services, sender_user, &all_joined_rooms)
.boxed()
.await?;
@@ -901,6 +907,9 @@ pub async fn full_user_deactivate(
) -> Result<()> {
services.users.deactivate_account(user_id).await.ok();
super::update_displayname(services, user_id, None, all_joined_rooms).await;
super::update_avatar_url(services, user_id, None, None, all_joined_rooms).await;
services
.users
.all_profile_keys(user_id)
@@ -909,11 +918,9 @@ pub async fn full_user_deactivate(
})
.await;
// TODO: Rescind all user invites
let mut pdu_queue: Vec<(PduBuilder, &OwnedRoomId)> = Vec::new();
for room_id in all_joined_rooms {
let state_lock = services.rooms.state.mutex.lock(room_id).await;
let room_power_levels = services
.rooms
.state_accessor
@@ -941,33 +948,30 @@ pub async fn full_user_deactivate(
if user_can_demote_self {
let mut power_levels_content = room_power_levels.unwrap_or_default();
power_levels_content.users.remove(user_id);
let pl_evt = PduBuilder::state(String::new(), &power_levels_content);
pdu_queue.push((pl_evt, room_id));
// ignore errors so deactivation doesn't fail
match services
.rooms
.timeline
.build_and_append_pdu(
PduBuilder::state(String::new(), &power_levels_content),
user_id,
Some(room_id),
&state_lock,
)
.await
{
| Err(e) => {
warn!(%room_id, %user_id, "Failed to demote user's own power level: {e}");
},
| _ => {
info!("Demoted {user_id} in {room_id} as part of account deactivation");
},
}
}
// Leave the room
pdu_queue.push((
PduBuilder::state(user_id.to_string(), &RoomMemberEventContent {
avatar_url: None,
blurhash: None,
membership: MembershipState::Leave,
displayname: None,
join_authorized_via_users_server: None,
reason: None,
is_direct: None,
third_party_invite: None,
redact_events: None,
}),
room_id,
));
// TODO: Redact all messages sent by the user in the room
}
super::update_all_rooms(services, pdu_queue, user_id).await;
for room_id in all_joined_rooms {
services.rooms.state_cache.forget(room_id, user_id);
}
super::leave_all_rooms(services, user_id).boxed().await;
Ok(())
}

View File

@@ -16,10 +16,7 @@
use crate::{
Ruma,
client::{
is_ignored_pdu,
message::{event_filter, ignored_filter, lazy_loading_witness, visibility_filter},
},
client::message::{event_filter, ignored_filter, lazy_loading_witness, visibility_filter},
};
const LIMIT_MAX: usize = 100;
@@ -81,9 +78,6 @@ pub(crate) async fn get_context_route(
return Err!(Request(NotFound("Event not found.")));
}
// Return M_SENDER_IGNORED if the sender of base_event is ignored (MSC4406)
is_ignored_pdu(&services, &base_pdu, sender_user).await?;
let base_count = base_id.pdu_count();
let base_event = ignored_filter(&services, (base_count, base_pdu), sender_user);

View File

@@ -3,7 +3,7 @@
use axum::extract::State;
use axum_client_ip::InsecureClientIp;
use conduwuit::{
Err, Result, debug, debug_info, debug_warn, err, error, info, is_true,
Err, Result, debug, debug_info, debug_warn, err, error, info,
matrix::{
StateKey,
event::{gen_event_id, gen_event_id_canonical_json},
@@ -15,7 +15,6 @@
utils::{
self, shuffle,
stream::{IterStream, ReadyExt},
to_canonical_object,
},
warn,
};
@@ -26,7 +25,7 @@
api::{
client::{
error::ErrorKind,
membership::{join_room_by_id, join_room_by_id_or_alias},
membership::{ThirdPartySigned, join_room_by_id, join_room_by_id_or_alias},
},
federation::{self},
},
@@ -34,7 +33,7 @@
events::{
StateEventType,
room::{
join_rules::JoinRule,
join_rules::{AllowRule, JoinRule},
member::{MembershipState, RoomMemberEventContent},
},
},
@@ -48,13 +47,9 @@
timeline::pdu_fits,
},
};
use tokio::join;
use super::{banned_room_check, validate_remote_member_event_stub};
use crate::{
Ruma,
server::{select_authorising_user, user_can_perform_restricted_join},
};
use crate::Ruma;
/// # `POST /_matrix/client/r0/rooms/{roomId}/join`
///
@@ -120,6 +115,7 @@ pub(crate) async fn join_room_by_id_route(
&body.room_id,
body.reason.clone(),
&servers,
body.third_party_signed.as_ref(),
&body.appservice_info,
)
.boxed()
@@ -251,6 +247,7 @@ pub(crate) async fn join_room_by_id_or_alias_route(
&room_id,
body.reason.clone(),
&servers,
body.third_party_signed.as_ref(),
appservice_info,
)
.boxed()
@@ -265,6 +262,7 @@ pub async fn join_room_by_id_helper(
room_id: &RoomId,
reason: Option<String>,
servers: &[OwnedServerName],
third_party_signed: Option<&ThirdPartySigned>,
appservice_info: &Option<RegistrationInfo>,
) -> Result<join_room_by_id::v3::Response> {
let state_lock = services.rooms.state.mutex.lock(room_id).await;
@@ -352,9 +350,17 @@ pub async fn join_room_by_id_helper(
}
if server_in_room {
join_room_by_id_helper_local(services, sender_user, room_id, reason, servers, state_lock)
.boxed()
.await?;
join_room_by_id_helper_local(
services,
sender_user,
room_id,
reason,
servers,
third_party_signed,
state_lock,
)
.boxed()
.await?;
} else {
// Ask a remote server if we are not participating in this room
join_room_by_id_helper_remote(
@@ -363,6 +369,7 @@ pub async fn join_room_by_id_helper(
room_id,
reason,
servers,
third_party_signed,
state_lock,
)
.boxed()
@@ -378,6 +385,7 @@ async fn join_room_by_id_helper_remote(
room_id: &RoomId,
reason: Option<String>,
servers: &[OwnedServerName],
_third_party_signed: Option<&ThirdPartySigned>,
state_lock: RoomMutexGuard,
) -> Result {
info!("Joining {room_id} over federation.");
@@ -387,10 +395,11 @@ async fn join_room_by_id_helper_remote(
info!("make_join finished");
let room_version_id = make_join_response.room_version.unwrap_or(RoomVersionId::V1);
let Some(room_version_id) = make_join_response.room_version else {
return Err!(BadServerResponse("Remote room version is not supported by conduwuit"));
};
if !services.server.supported_room_version(&room_version_id) {
// How did we get here?
return Err!(BadServerResponse(
"Remote room version {room_version_id} is not supported by conduwuit"
));
@@ -419,6 +428,10 @@ async fn join_room_by_id_helper_remote(
}
};
join_event_stub.insert(
"origin".to_owned(),
CanonicalJsonValue::String(services.globals.server_name().as_str().to_owned()),
);
join_event_stub.insert(
"origin_server_ts".to_owned(),
CanonicalJsonValue::Integer(
@@ -730,45 +743,87 @@ async fn join_room_by_id_helper_local(
room_id: &RoomId,
reason: Option<String>,
servers: &[OwnedServerName],
_third_party_signed: Option<&ThirdPartySigned>,
state_lock: RoomMutexGuard,
) -> Result {
info!("Joining room locally");
debug_info!("We can join locally");
let join_rules = services.rooms.state_accessor.get_join_rules(room_id).await;
let (room_version, join_rules, is_invited) = join!(
services.rooms.state.get_room_version(room_id),
services.rooms.state_accessor.get_join_rules(room_id),
services.rooms.state_cache.is_invited(sender_user, room_id)
);
let room_version = room_version?;
let mut auth_user: Option<OwnedUserId> = None;
if !is_invited && matches!(join_rules, JoinRule::Restricted(_) | JoinRule::KnockRestricted(_))
{
use RoomVersionId::*;
if !matches!(room_version, V1 | V2 | V3 | V4 | V5 | V6 | V7) {
// This is a restricted room, check if we can complete the join requirements
// locally.
let needs_auth_user =
user_can_perform_restricted_join(services, sender_user, room_id, &room_version)
.await;
if needs_auth_user.is_ok_and(is_true!()) {
// If there was an error or the value is false, we'll try joining over
// federation. Since it's Ok(true), we can authorise this locally.
// If we can't select a local user, this will remain None, the join will fail,
// and we'll fall back to federation.
auth_user = select_authorising_user(services, room_id, sender_user, &state_lock)
.await
.ok();
let mut restricted_join_authorized = None;
match join_rules {
| JoinRule::Restricted(restricted) | JoinRule::KnockRestricted(restricted) => {
for restriction in restricted.allow {
match restriction {
| AllowRule::RoomMembership(membership) => {
if services
.rooms
.state_cache
.is_joined(sender_user, &membership.room_id)
.await
{
restricted_join_authorized = Some(true);
break;
}
},
| AllowRule::UnstableSpamChecker => {
match services
.antispam
.meowlnir_accept_make_join(room_id.to_owned(), sender_user.to_owned())
.await
{
| Ok(()) => {
restricted_join_authorized = Some(true);
break;
},
| Err(_) =>
return Err!(Request(Forbidden(
"Antispam rejected join request."
))),
}
},
| _ => {},
}
}
}
},
| _ => {},
}
let join_authorized_via_users_server = if restricted_join_authorized.is_none() {
None
} else {
match restricted_join_authorized.unwrap() {
| true => services
.rooms
.state_cache
.local_users_in_room(room_id)
.filter(|user| {
trace!("Checking if {user} can invite {sender_user} to {room_id}");
services.rooms.state_accessor.user_can_invite(
room_id,
user,
sender_user,
&state_lock,
)
})
.boxed()
.next()
.await
.map(ToOwned::to_owned),
| false => {
warn!(
"Join authorization failed for restricted join in room {room_id} for user \
{sender_user}"
);
return Err!(Request(Forbidden("You are not authorized to join this room.")));
},
}
};
let content = RoomMemberEventContent {
displayname: services.users.displayname(sender_user).await.ok(),
avatar_url: services.users.avatar_url(sender_user).await.ok(),
blurhash: services.users.blurhash(sender_user).await.ok(),
reason: reason.clone(),
join_authorized_via_users_server: auth_user,
join_authorized_via_users_server,
..RoomMemberEventContent::new(MembershipState::Join)
};
@@ -784,7 +839,6 @@ async fn join_room_by_id_helper_local(
)
.await
else {
info!("Joined room locally");
return Ok(());
};
@@ -792,13 +846,138 @@ async fn join_room_by_id_helper_local(
return Err(error);
}
info!(
warn!(
?error,
remote_servers = %servers.len(),
"Could not join room locally, attempting remote join",
servers = %servers.len(),
"Could not join restricted room locally, attempting remote join",
);
join_room_by_id_helper_remote(services, sender_user, room_id, reason, servers, state_lock)
.await
let Ok((make_join_response, remote_server)) =
make_join_request(services, sender_user, room_id, servers).await
else {
return Err(error);
};
let Some(room_version_id) = make_join_response.room_version else {
return Err!(BadServerResponse("Remote room version is not supported by conduwuit"));
};
if !services.server.supported_room_version(&room_version_id) {
return Err!(BadServerResponse(
"Remote room version {room_version_id} is not supported by conduwuit"
));
}
let mut join_event_stub: CanonicalJsonObject =
serde_json::from_str(make_join_response.event.get()).map_err(|e| {
err!(BadServerResponse("Invalid make_join event json received from server: {e:?}"))
})?;
validate_remote_member_event_stub(
&MembershipState::Join,
sender_user,
room_id,
&join_event_stub,
)?;
let join_authorized_via_users_server = join_event_stub
.get("content")
.map(|s| {
s.as_object()?
.get("join_authorised_via_users_server")?
.as_str()
})
.and_then(|s| OwnedUserId::try_from(s.unwrap_or_default()).ok());
join_event_stub.insert(
"origin".to_owned(),
CanonicalJsonValue::String(services.globals.server_name().as_str().to_owned()),
);
join_event_stub.insert(
"origin_server_ts".to_owned(),
CanonicalJsonValue::Integer(
utils::millis_since_unix_epoch()
.try_into()
.expect("Timestamp is valid js_int value"),
),
);
join_event_stub.insert(
"content".to_owned(),
to_canonical_value(RoomMemberEventContent {
displayname: services.users.displayname(sender_user).await.ok(),
avatar_url: services.users.avatar_url(sender_user).await.ok(),
blurhash: services.users.blurhash(sender_user).await.ok(),
reason,
join_authorized_via_users_server,
..RoomMemberEventContent::new(MembershipState::Join)
})
.expect("event is valid, we just created it"),
);
// We keep the "event_id" in the pdu only in v1 or
// v2 rooms
match room_version_id {
| RoomVersionId::V1 | RoomVersionId::V2 => {},
| _ => {
join_event_stub.remove("event_id");
},
}
// In order to create a compatible ref hash (EventID) the `hashes` field needs
// to be present
services
.server_keys
.hash_and_sign_event(&mut join_event_stub, &room_version_id)?;
// Generate event id
let event_id = gen_event_id(&join_event_stub, &room_version_id)?;
// Add event_id back
join_event_stub
.insert("event_id".to_owned(), CanonicalJsonValue::String(event_id.clone().into()));
// It has enough fields to be called a proper event now
let join_event = join_event_stub;
let send_join_response = services
.sending
.send_synapse_request(
&remote_server,
federation::membership::create_join_event::v2::Request {
room_id: room_id.to_owned(),
event_id: event_id.clone(),
omit_members: false,
pdu: services
.sending
.convert_to_outgoing_federation_event(join_event.clone())
.await,
},
)
.await?;
if let Some(signed_raw) = send_join_response.room_state.event {
let (signed_event_id, signed_value) =
gen_event_id_canonical_json(&signed_raw, &room_version_id).map_err(|e| {
err!(Request(BadJson(warn!("Could not convert event to canonical JSON: {e}"))))
})?;
if signed_event_id != event_id {
return Err!(Request(BadJson(
warn!(%signed_event_id, %event_id, "Server {remote_server} sent event with wrong event ID")
)));
}
drop(state_lock);
services
.rooms
.event_handler
.handle_incoming_pdu(&remote_server, room_id, &signed_event_id, signed_value, true)
.boxed()
.await?;
} else {
return Err(error);
}
Ok(())
}
async fn make_join_request(
@@ -807,16 +986,17 @@ async fn make_join_request(
room_id: &RoomId,
servers: &[OwnedServerName],
) -> Result<(federation::membership::prepare_join_event::v1::Response, OwnedServerName)> {
let mut make_join_counter: usize = 1;
let mut make_join_response_and_server =
Err!(BadServerResponse("No server available to assist in joining."));
let mut make_join_counter: usize = 0;
let mut incompatible_room_version_count: usize = 0;
for remote_server in servers {
if services.globals.server_is_ours(remote_server) {
continue;
}
info!(
"Asking {remote_server} for make_join (attempt {make_join_counter}/{})",
servers.len()
);
info!("Asking {remote_server} for make_join ({make_join_counter})");
let make_join_response = services
.sending
.send_federation_request(
@@ -832,56 +1012,44 @@ async fn make_join_request(
trace!("make_join response: {:?}", make_join_response);
make_join_counter = make_join_counter.saturating_add(1);
match make_join_response {
| Ok(response) => {
info!("Received make_join response from {remote_server}");
if let Err(e) = validate_remote_member_event_stub(
&MembershipState::Join,
sender_user,
room_id,
&to_canonical_object(&response.event)?,
) {
warn!("make_join response from {remote_server} failed validation: {e}");
continue;
}
return Ok((response, remote_server.clone()));
},
| Err(e) => match e.kind() {
| ErrorKind::UnableToAuthorizeJoin => {
info!(
"{remote_server} was unable to verify the joining user satisfied \
restricted join requirements: {e}. Will continue trying."
);
},
| ErrorKind::UnableToGrantJoin => {
info!(
"{remote_server} believes the joining user satisfies restricted join \
rules, but is unable to authorise a join for us. Will continue trying."
);
},
| ErrorKind::IncompatibleRoomVersion { room_version } => {
warn!(
"{remote_server} reports the room we are trying to join is \
v{room_version}, which we do not support."
);
return Err(e);
},
| ErrorKind::Forbidden { .. } => {
warn!("{remote_server} refuses to let us join: {e}.");
return Err(e);
},
| ErrorKind::NotFound => {
info!(
"{remote_server} does not know about {room_id}: {e}. Will continue \
trying."
);
},
| _ => {
info!("{remote_server} failed to make_join: {e}. Will continue trying.");
},
},
if let Err(ref e) = make_join_response {
if matches!(
e.kind(),
ErrorKind::IncompatibleRoomVersion { .. } | ErrorKind::UnsupportedRoomVersion
) {
incompatible_room_version_count =
incompatible_room_version_count.saturating_add(1);
}
if incompatible_room_version_count > 15 {
info!(
"15 servers have responded with M_INCOMPATIBLE_ROOM_VERSION or \
M_UNSUPPORTED_ROOM_VERSION, assuming that conduwuit does not support the \
room version {room_id}: {e}"
);
make_join_response_and_server =
Err!(BadServerResponse("Room version is not supported by Conduwuit"));
return make_join_response_and_server;
}
if make_join_counter > 40 {
warn!(
"40 servers failed to provide valid make_join response, assuming no server \
can assist in joining."
);
make_join_response_and_server =
Err!(BadServerResponse("No server available to assist in joining."));
return make_join_response_and_server;
}
}
make_join_response_and_server = make_join_response.map(|r| (r, remote_server.clone()));
if make_join_response_and_server.is_ok() {
break;
}
}
info!("All {} servers were unable to assist in joining {room_id} :(", servers.len());
Err!(BadServerResponse("No server available to assist in joining."))
make_join_response_and_server
}

View File

@@ -10,7 +10,7 @@
},
result::FlatOk,
trace,
utils::{self, shuffle, stream::IterStream, to_canonical_object},
utils::{self, shuffle, stream::IterStream},
warn,
};
use futures::{FutureExt, StreamExt};
@@ -253,6 +253,7 @@ async fn knock_room_by_id_helper(
room_id,
reason.clone(),
servers,
None,
&None,
)
.await
@@ -740,17 +741,6 @@ async fn make_knock_request(
trace!("make_knock response: {make_knock_response:?}");
make_knock_counter = make_knock_counter.saturating_add(1);
if let Ok(r) = &make_knock_response {
if let Err(e) = validate_remote_member_event_stub(
&MembershipState::Knock,
sender_user,
room_id,
&to_canonical_object(&r.event)?,
) {
warn!("make_knock response from {remote_server} failed validation: {e}");
continue;
}
}
make_knock_response_and_server = make_knock_response.map(|r| (r, remote_server.clone()));

View File

@@ -231,7 +231,7 @@ pub(crate) fn validate_remote_member_event_stub(
};
if event_membership != &membership.as_str() {
return Err!(BadServerResponse(
"Remote server returned member event with incorrect membership type"
"Remote server returned member event with incorrect room_id"
));
}

View File

@@ -1,7 +1,7 @@
use axum::extract::State;
use axum_client_ip::InsecureClientIp;
use conduwuit::{
Err, Error, Result, at, debug_warn,
Err, Result, at, debug_warn,
matrix::{
event::{Event, Matches},
pdu::PduCount,
@@ -26,7 +26,7 @@
DeviceId, RoomId, UserId,
api::{
Direction,
client::{error::ErrorKind, filter::RoomEventFilter, message::get_message_events},
client::{filter::RoomEventFilter, message::get_message_events},
},
events::{
AnyStateEvent, StateEventType,
@@ -279,30 +279,23 @@ pub(crate) async fn ignored_filter(
is_ignored_pdu(services, pdu, user_id)
.await
.unwrap_or(true)
.eq(&false)
.then_some(item)
}
/// Determine whether a PDU should be ignored for a given recipient user.
/// Returns True if this PDU should be ignored, returns False otherwise.
///
/// The error SenderIgnored is returned if the sender or the sender's server is
/// ignored by the relevant user. If the error cannot be returned to the user,
/// it should equate to a true value (i.e. ignored).
#[inline]
pub(crate) async fn is_ignored_pdu<Pdu>(
services: &Services,
event: &Pdu,
recipient_user: &UserId,
) -> Result<bool>
) -> bool
where
Pdu: Event + Send + Sync,
{
// exclude Synapse's dummy events from bloating up response bodies. clients
// don't need to see this.
if event.kind().to_cow_str() == "org.matrix.dummy_event" {
return Ok(true);
return true;
}
let sender_user = event.sender();
@@ -317,27 +310,21 @@ pub(crate) async fn is_ignored_pdu<Pdu>(
if !type_ignored {
// We cannot safely ignore this type
return Ok(false);
return false;
}
if server_ignored {
// the sender's server is ignored, so ignore this event
return Err(Error::BadRequest(
ErrorKind::SenderIgnored { sender: None },
"The sender's server is ignored by this server.",
));
return true;
}
if user_ignored && !services.config.send_messages_from_ignored_users_to_client {
// the recipient of this PDU has the sender ignored, and we're not
// configured to send ignored messages to clients
return Err(Error::BadRequest(
ErrorKind::SenderIgnored { sender: Some(event.sender().to_owned()) },
"You have ignored this sender.",
));
return true;
}
Ok(false)
false
}
#[inline]

View File

@@ -1,6 +1,6 @@
use axum::extract::State;
use conduwuit::{
Err, Result, at, debug_warn, err,
Err, Result, at, debug_warn,
matrix::{Event, event::RelationTypeEqual, pdu::PduCount},
utils::{IterStream, ReadyExt, result::FlatOk, stream::WidebandExt},
};
@@ -18,7 +18,7 @@
events::{TimelineEventType, relation::RelationType},
};
use crate::{Ruma, client::is_ignored_pdu};
use crate::Ruma;
/// # `GET /_matrix/client/r0/rooms/{roomId}/relations/{eventId}/{relType}/{eventType}`
pub(crate) async fn get_relating_events_with_rel_type_and_event_type_route(
@@ -118,14 +118,6 @@ async fn paginate_relations_with_filter(
debug_warn!(req_evt = %target, %room_id, "Event relations requested by {sender_user} but is not allowed to see it, returning 404");
return Err!(Request(NotFound("Event not found.")));
}
let target_pdu = services
.rooms
.timeline
.get_pdu(target)
.await
.map_err(|_| err!(Request(NotFound("Event not found."))))?;
// Return M_SENDER_IGNORED if the sender of base_event is ignored (MSC4406)
is_ignored_pdu(services, &target_pdu, sender_user).await?;
let start: PduCount = from
.map(str::parse)
@@ -167,7 +159,6 @@ async fn paginate_relations_with_filter(
.ready_take_while(|(count, _)| Some(*count) != to)
.take(limit)
.wide_filter_map(|item| visibility_filter(services, sender_user, item))
.wide_filter_map(|item| ignored_filter(services, item, sender_user))
.then(async |mut pdu| {
if let Err(e) = services
.rooms
@@ -223,17 +214,3 @@ async fn visibility_filter<Pdu: Event + Send + Sync>(
.await
.then_some(item)
}
async fn ignored_filter<Pdu: Event + Send + Sync>(
services: &Services,
item: (PduCount, Pdu),
sender_user: &UserId,
) -> Option<(PduCount, Pdu)> {
let (_, pdu) = &item;
if is_ignored_pdu(services, pdu, sender_user).await.ok()? {
None
} else {
Some(item)
}
}

View File

@@ -29,7 +29,7 @@ pub(crate) async fn get_room_event_route(
let (mut event, visible) = try_join(event, visible).await?;
if !visible || is_ignored_pdu(services, &event, body.sender_user()).await? {
if !visible || is_ignored_pdu(services, &event, body.sender_user()).await {
return Err!(Request(Forbidden("You don't have permission to view this event.")));
}

View File

@@ -107,7 +107,7 @@ pub(super) async fn ldap_login(
) -> Result<OwnedUserId> {
let (user_dn, is_ldap_admin) = match services.config.ldap.bind_dn.as_ref() {
| Some(bind_dn) if bind_dn.contains("{username}") =>
(bind_dn.replace("{username}", lowercased_user_id.localpart()), None),
(bind_dn.replace("{username}", lowercased_user_id.localpart()), false),
| _ => {
debug!("Searching user in LDAP");
@@ -144,16 +144,12 @@ pub(super) async fn ldap_login(
.await?;
}
// Only sync admin status if LDAP can actually determine it.
// None means LDAP cannot determine admin status (manual config required).
if let Some(is_ldap_admin) = is_ldap_admin {
let is_conduwuit_admin = services.admin.user_is_admin(lowercased_user_id).await;
let is_conduwuit_admin = services.admin.user_is_admin(lowercased_user_id).await;
if is_ldap_admin && !is_conduwuit_admin {
Box::pin(services.admin.make_user_admin(lowercased_user_id)).await?;
} else if !is_ldap_admin && is_conduwuit_admin {
Box::pin(services.admin.revoke_admin(lowercased_user_id)).await?;
}
if is_ldap_admin && !is_conduwuit_admin {
Box::pin(services.admin.make_user_admin(lowercased_user_id)).await?;
} else if !is_ldap_admin && is_conduwuit_admin {
Box::pin(services.admin.revoke_admin(lowercased_user_id)).await?;
}
Ok(user_id)

View File

@@ -30,8 +30,7 @@
api::client::sync::sync_events::{self, DeviceLists, UnreadNotificationsCount},
directory::RoomTypeFilter,
events::{
AnyRawAccountDataEvent, AnySyncEphemeralRoomEvent, AnySyncStateEvent, StateEventType,
TimelineEventType,
AnyRawAccountDataEvent, AnySyncEphemeralRoomEvent, StateEventType, TimelineEventType,
room::member::{MembershipState, RoomMemberEventContent},
typing::TypingEventContent,
},
@@ -534,9 +533,6 @@ async fn process_rooms<'a, Rooms>(
}
});
let required_state =
collect_required_state(services, room_id, required_state_request).await;
let room_events: Vec<_> = timeline_pdus
.iter()
.stream()
@@ -555,6 +551,21 @@ async fn process_rooms<'a, Rooms>(
}
}
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
@@ -678,51 +689,6 @@ async fn process_rooms<'a, Rooms>(
Ok(rooms)
}
/// Collect the required state events for a room
async fn collect_required_state(
services: &Services,
room_id: &RoomId,
required_state_request: &BTreeSet<TypeStateKey>,
) -> Vec<Raw<AnySyncStateEvent>> {
let mut required_state = Vec::new();
let mut wildcard_types: HashSet<&StateEventType> = HashSet::new();
for (event_type, state_key) in required_state_request {
if wildcard_types.contains(event_type) {
continue;
}
if state_key.as_str() == "*" {
wildcard_types.insert(event_type);
if let Ok(keys) = services
.rooms
.state_accessor
.room_state_keys(room_id, event_type)
.await
{
for key in keys {
if let Ok(event) = services
.rooms
.state_accessor
.room_state_get(room_id, event_type, &key)
.await
{
required_state.push(Event::into_format(event));
}
}
}
} else if let Ok(event) = services
.rooms
.state_accessor
.room_state_get(room_id, event_type, state_key)
.await
{
required_state.push(Event::into_format(event));
}
}
required_state
}
async fn collect_typing_events(
services: &Services,
sender_user: &UserId,

View File

@@ -27,7 +27,6 @@ pub(crate) async fn well_known_client(
identity_server: None,
sliding_sync_proxy: Some(SlidingSyncProxyInfo { url: client_url }),
tile_server: None,
rtc_foci: services.config.well_known.rtc_focus_server_urls.clone(),
})
}

View File

@@ -2,7 +2,7 @@
use axum::extract::State;
use conduwuit::{
Err, Event, PduCount, Result, info,
Event, PduCount, Result,
result::LogErr,
utils::{IterStream, ReadyExt, stream::TryTools},
};
@@ -34,18 +34,6 @@ pub(crate) async fn get_backfill_route(
}
.check()
.await?;
if !services
.rooms
.state_cache
.server_in_room(services.globals.server_name(), &body.room_id)
.await
{
info!(
origin = body.origin().as_str(),
"Refusing to serve backfill for room we aren't participating in"
);
return Err!(Request(NotFound("This server is not participating in that room.")));
}
let limit = body
.limit

View File

@@ -1,5 +1,5 @@
use axum::extract::State;
use conduwuit::{Err, Result, err, info};
use conduwuit::{Result, err};
use ruma::{MilliSecondsSinceUnixEpoch, RoomId, api::federation::event::get_event};
use super::AccessCheck;
@@ -38,19 +38,6 @@ pub(crate) async fn get_event_route(
.check()
.await?;
if !services
.rooms
.state_cache
.server_in_room(services.globals.server_name(), room_id)
.await
{
info!(
origin = body.origin().as_str(),
"Refusing to serve state for room we aren't participating in"
);
return Err!(Request(NotFound("This server is not participating in that room.")));
}
Ok(get_event::v1::Response {
origin: services.globals.server_name().to_owned(),
origin_server_ts: MilliSecondsSinceUnixEpoch::now(),

View File

@@ -1,7 +1,7 @@
use std::{borrow::Borrow, iter::once};
use axum::extract::State;
use conduwuit::{Err, Error, Result, info, utils::stream::ReadyExt};
use conduwuit::{Error, Result, utils::stream::ReadyExt};
use futures::StreamExt;
use ruma::{
RoomId,
@@ -29,19 +29,6 @@ pub(crate) async fn get_event_authorization_route(
.check()
.await?;
if !services
.rooms
.state_cache
.server_in_room(services.globals.server_name(), &body.room_id)
.await
{
info!(
origin = body.origin().as_str(),
"Refusing to serve state for room we aren't participating in"
);
return Err!(Request(NotFound("This server is not participating in that room.")));
}
let event = services
.rooms
.timeline

View File

@@ -1,9 +1,6 @@
use std::collections::{HashSet, VecDeque};
use axum::extract::State;
use conduwuit::{Err, Event, Result, debug, info, trace, utils::to_canonical_object, warn};
use ruma::{OwnedEventId, api::federation::event::get_missing_events};
use serde_json::{json, value::RawValue};
use conduwuit::{Result, debug, debug_error, utils::to_canonical_object};
use ruma::api::federation::event::get_missing_events;
use super::AccessCheck;
use crate::Ruma;
@@ -29,95 +26,65 @@ pub(crate) async fn get_missing_events_route(
.check()
.await?;
if !services
.rooms
.state_cache
.server_in_room(services.globals.server_name(), &body.room_id)
.await
{
info!(
origin = body.origin().as_str(),
"Refusing to serve state for room we aren't participating in"
);
return Err!(Request(NotFound("This server is not participating in that room.")));
}
let limit = body
.limit
.try_into()
.unwrap_or(LIMIT_DEFAULT)
.min(LIMIT_MAX);
let room_version = services.rooms.state.get_room_version(&body.room_id).await?;
let mut queued_events = body.latest_events.clone();
// the vec will never have more entries the limit
let mut events = Vec::with_capacity(limit);
let mut queue: VecDeque<OwnedEventId> = VecDeque::from(body.latest_events.clone());
let mut results: Vec<Box<RawValue>> = Vec::with_capacity(limit);
let mut seen: HashSet<OwnedEventId> = HashSet::from_iter(body.earliest_events.clone());
while let Some(next_event_id) = queue.pop_front() {
if seen.contains(&next_event_id) {
trace!(%next_event_id, "already seen event, skipping");
let mut i: usize = 0;
while i < queued_events.len() && events.len() < limit {
let Ok(pdu) = services.rooms.timeline.get_pdu(&queued_events[i]).await else {
debug!(
body.origin = body.origin.as_ref().map(tracing::field::display),
"Event {} does not exist locally, skipping", &queued_events[i]
);
i = i.saturating_add(1);
continue;
}
if results.len() >= limit {
debug!(%next_event_id, "reached limit of events to return, breaking");
break;
}
let mut pdu = match services.rooms.timeline.get_pdu(&next_event_id).await {
| Ok(pdu) => pdu,
| Err(e) => {
warn!("could not find event {next_event_id} while walking missing events: {e}");
continue;
},
};
if pdu.room_id_or_hash() != body.room_id {
return Err!(Request(Unknown(
"Event {next_event_id} is not in room {}",
body.room_id
)));
if body.earliest_events.contains(&queued_events[i]) {
i = i.saturating_add(1);
continue;
}
if !services
.rooms
.state_accessor
.server_can_see_event(body.origin(), &body.room_id, pdu.event_id())
.server_can_see_event(body.origin(), &body.room_id, &queued_events[i])
.await
{
debug!(%next_event_id, origin = %body.origin(), "redacting event origin cannot see");
pdu.redact(&room_version, json!({}))?;
debug!(
body.origin = body.origin.as_ref().map(tracing::field::display),
"Server cannot see {:?} in {:?}, skipping", pdu.event_id, pdu.room_id
);
i = i.saturating_add(1);
continue;
}
trace!(
%next_event_id,
prev_events = ?pdu.prev_events().collect::<Vec<_>>(),
"adding event to results and queueing prev events"
);
queue.extend(pdu.prev_events.clone());
seen.insert(next_event_id.clone());
if body.latest_events.contains(&next_event_id) {
continue; // Don't include latest_events in results,
// but do include their prev_events in the queue
}
results.push(
services
.sending
.convert_to_outgoing_federation_event(to_canonical_object(pdu)?)
.await,
);
trace!(
%next_event_id,
queue_len = queue.len(),
seen_len = seen.len(),
results_len = results.len(),
"event added to results"
);
let Ok(event) = to_canonical_object(&pdu) else {
debug_error!(
body.origin = body.origin.as_ref().map(tracing::field::display),
"Failed to convert PDU in database to canonical JSON: {pdu:?}"
);
i = i.saturating_add(1);
continue;
};
let prev_events = pdu.prev_events.iter().map(ToOwned::to_owned);
let event = services
.sending
.convert_to_outgoing_federation_event(event)
.await;
queued_events.extend(prev_events);
events.push(event);
}
if !queue.is_empty() {
debug!("limit reached before queue was empty");
}
results.reverse(); // return oldest first
Ok(get_missing_events::v1::Response { events: results })
Ok(get_missing_events::v1::Response { events })
}

View File

@@ -1,6 +1,6 @@
use axum::extract::State;
use conduwuit::{
Err, Result, info,
Err, Result,
utils::stream::{BroadbandExt, IterStream},
};
use conduwuit_service::rooms::spaces::{
@@ -23,19 +23,6 @@ pub(crate) async fn get_hierarchy_route(
return Err!(Request(NotFound("Room does not exist.")));
}
if !services
.rooms
.state_cache
.server_in_room(services.globals.server_name(), &body.room_id)
.await
{
info!(
origin = body.origin().as_str(),
"Refusing to serve state for room we aren't participating in"
);
return Err!(Request(NotFound("This server is not participating in that room.")));
}
let room_id = &body.room_id;
let suggested_only = body.suggested_only;
let ref identifier = Identifier::ServerName(body.origin());

View File

@@ -2,7 +2,7 @@
use axum_client_ip::InsecureClientIp;
use base64::{Engine as _, engine::general_purpose};
use conduwuit::{
Err, Error, PduEvent, Result, err, error,
Err, Error, PduEvent, Result, err,
matrix::{Event, event::gen_event_id},
utils::{self, hash::sha256},
warn,
@@ -199,27 +199,20 @@ pub(crate) async fn create_invite_route(
for appservice in services.appservice.read().await.values() {
if appservice.is_user_match(&recipient_user) {
let request = ruma::api::appservice::event::push_events::v1::Request {
events: vec![pdu.to_format()],
txn_id: general_purpose::URL_SAFE_NO_PAD
.encode(sha256::hash(pdu.event_id.as_bytes()))
.into(),
ephemeral: Vec::new(),
to_device: Vec::new(),
};
services
.sending
.send_appservice_request(appservice.registration.clone(), request)
.await
.map_err(|e| {
error!(
"failed to notify appservice {} about incoming invite: {e}",
appservice.registration.id
);
err!(BadServerResponse(
"Failed to notify appservice about incoming invite."
))
})?;
.send_appservice_request(
appservice.registration.clone(),
ruma::api::appservice::event::push_events::v1::Request {
events: vec![pdu.to_format()],
txn_id: general_purpose::URL_SAFE_NO_PAD
.encode(sha256::hash(pdu.event_id.as_bytes()))
.into(),
ephemeral: Vec::new(),
to_device: Vec::new(),
},
)
.await?;
}
}
}

View File

@@ -16,8 +16,6 @@
},
};
use serde_json::value::to_raw_value;
use service::rooms::state::RoomMutexGuard;
use tokio::join;
use crate::Ruma;
@@ -32,18 +30,6 @@ pub(crate) async fn create_join_event_template_route(
if !services.rooms.metadata.exists(&body.room_id).await {
return Err!(Request(NotFound("Room is unknown to this server.")));
}
if !services
.rooms
.state_cache
.server_in_room(services.globals.server_name(), &body.room_id)
.await
{
info!(
origin = body.origin().as_str(),
"Refusing to serve make_join for room we aren't participating in"
);
return Err!(Request(NotFound("This server is not participating in that room.")));
}
if body.user_id.server_name() != body.origin() {
return Err!(Request(BadJson("Not allowed to join on behalf of another server/user.")));
@@ -87,24 +73,16 @@ pub(crate) async fn create_join_event_template_route(
}
let state_lock = services.rooms.state.mutex.lock(&body.room_id).await;
let (is_invited, is_joined) = join!(
services
.rooms
.state_cache
.is_invited(&body.user_id, &body.room_id),
services
.rooms
.state_cache
.is_joined(&body.user_id, &body.room_id)
);
let is_invited = services
.rooms
.state_cache
.is_invited(&body.user_id, &body.room_id)
.await;
let join_authorized_via_users_server: Option<OwnedUserId> = {
use RoomVersionId::*;
if is_joined || is_invited {
// User is already joined or invited and consequently does not need an
// authorising user
None
} else if matches!(room_version_id, V1 | V2 | V3 | V4 | V5 | V6 | V7) {
// room version does not support restricted join rules
if matches!(room_version_id, V1 | V2 | V3 | V4 | V5 | V6 | V7) || is_invited {
// room version does not support restricted join rules, or the user is currently
// already invited
None
} else if user_can_perform_restricted_join(
&services,
@@ -114,10 +92,32 @@ pub(crate) async fn create_join_event_template_route(
)
.await?
{
Some(
select_authorising_user(&services, &body.room_id, &body.user_id, &state_lock)
.await?,
)
let Some(auth_user) = services
.rooms
.state_cache
.local_users_in_room(&body.room_id)
.filter(|user| {
services.rooms.state_accessor.user_can_invite(
&body.room_id,
user,
&body.user_id,
&state_lock,
)
})
.boxed()
.next()
.await
.map(ToOwned::to_owned)
else {
info!(
"No local user is able to authorize the join of {} into {}",
&body.user_id, &body.room_id
);
return Err!(Request(UnableToGrantJoin(
"No user on this server is able to assist in joining."
)));
};
Some(auth_user)
} else {
None
}
@@ -147,7 +147,9 @@ pub(crate) async fn create_join_event_template_route(
)
.await?;
drop(state_lock);
pdu_json.remove("event_id");
// room v3 and above removed the "event_id" field from remote PDU format
maybe_strip_event_id(&mut pdu_json, &room_version_id)?;
Ok(prepare_join_event::v1::Response {
room_version: Some(room_version_id),
@@ -155,38 +157,6 @@ pub(crate) async fn create_join_event_template_route(
})
}
/// Attempts to find a user who is able to issue an invite in the target room.
pub(crate) async fn select_authorising_user(
services: &Services,
room_id: &RoomId,
user_id: &UserId,
state_lock: &RoomMutexGuard,
) -> Result<OwnedUserId> {
let auth_user = services
.rooms
.state_cache
.local_users_in_room(room_id)
.filter(|user| {
services
.rooms
.state_accessor
.user_can_invite(room_id, user, user_id, state_lock)
})
.boxed()
.next()
.await
.map(ToOwned::to_owned);
match auth_user {
| Some(auth_user) => Ok(auth_user),
| None => {
Err!(Request(UnableToGrantJoin(
"No user on this server is able to assist in joining."
)))
},
}
}
/// Checks whether the given user can join the given room via a restricted join.
pub(crate) async fn user_can_perform_restricted_join(
services: &Services,
@@ -198,9 +168,12 @@ pub(crate) async fn user_can_perform_restricted_join(
// restricted rooms are not supported on <=v7
if matches!(room_version_id, V1 | V2 | V3 | V4 | V5 | V6 | V7) {
// This should be impossible as it was checked earlier on, but retain this check
// for safety.
unreachable!("user_can_perform_restricted_join got incompatible room version");
return Ok(false);
}
if services.rooms.state_cache.is_joined(user_id, room_id).await {
// joining user is already joined, there is nothing we need to do
return Ok(false);
}
let Ok(join_rules_event_content) = services
@@ -220,31 +193,17 @@ pub(crate) async fn user_can_perform_restricted_join(
let (JoinRule::Restricted(r) | JoinRule::KnockRestricted(r)) =
join_rules_event_content.join_rule
else {
// This is not a restricted room
return Ok(false);
};
if r.allow.is_empty() {
// This will never be authorisable, return forbidden.
return Err!(Request(Forbidden("You are not invited to this room.")));
debug_info!("{room_id} is restricted but the allow key is empty");
return Ok(false);
}
let mut could_satisfy = true;
for allow_rule in &r.allow {
match allow_rule {
| AllowRule::RoomMembership(membership) => {
if !services
.rooms
.state_cache
.server_in_room(services.globals.server_name(), &membership.room_id)
.await
{
// Since we can't check this room, mark could_satisfy as false
// so that we can return M_UNABLE_TO_AUTHORIZE_JOIN later.
could_satisfy = false;
continue;
}
if services
.rooms
.state_cache
@@ -268,8 +227,6 @@ pub(crate) async fn user_can_perform_restricted_join(
| Err(_) => Err!(Request(Forbidden("Antispam rejected join request."))),
},
| _ => {
// We don't recognise this join rule, so we cannot satisfy the request.
could_satisfy = false;
debug_info!(
"Unsupported allow rule in restricted join for room {}: {:?}",
room_id,
@@ -279,23 +236,9 @@ pub(crate) async fn user_can_perform_restricted_join(
}
}
if could_satisfy {
// We were able to check all the restrictions and can be certain that the
// prospective member is not permitted to join.
Err!(Request(Forbidden(
"You do not belong to any of the rooms or spaces required to join this room."
)))
} else {
// We were unable to check all the restrictions. This usually means we aren't in
// one of the rooms this one is restricted to, ergo can't check its state for
// the user's membership, and consequently the user *might* be able to join if
// they ask another server.
Err!(Request(UnableToAuthorizeJoin(
"You do not belong to any of the recognised rooms or spaces required to join this \
room, but this server is unable to verify every requirement. You may be able to \
join via another server."
)))
}
Err!(Request(UnableToAuthorizeJoin(
"Joining user is not known to be in any required room."
)))
}
pub(crate) fn maybe_strip_event_id(

View File

@@ -1,6 +1,6 @@
use RoomVersionId::*;
use axum::extract::State;
use conduwuit::{Err, Error, Result, debug_warn, info, matrix::pdu::PduBuilder, warn};
use conduwuit::{Err, Error, Result, debug_warn, matrix::pdu::PduBuilder, warn};
use ruma::{
RoomVersionId,
api::{client::error::ErrorKind, federation::knock::create_knock_event_template},
@@ -20,18 +20,6 @@ pub(crate) async fn create_knock_event_template_route(
if !services.rooms.metadata.exists(&body.room_id).await {
return Err!(Request(NotFound("Room is unknown to this server.")));
}
if !services
.rooms
.state_cache
.server_in_room(services.globals.server_name(), &body.room_id)
.await
{
info!(
origin = body.origin().as_str(),
"Refusing to serve make_knock for room we aren't participating in"
);
return Err!(Request(NotFound("This server is not participating in that room.")));
}
if body.user_id.server_name() != body.origin() {
return Err!(Request(BadJson("Not allowed to knock on behalf of another server/user.")));

View File

@@ -1,5 +1,5 @@
use axum::extract::State;
use conduwuit::{Err, Result, info, matrix::pdu::PduBuilder};
use conduwuit::{Err, Result, matrix::pdu::PduBuilder};
use ruma::{
api::federation::membership::prepare_leave_event,
events::room::member::{MembershipState, RoomMemberEventContent},
@@ -20,19 +20,6 @@ pub(crate) async fn create_leave_event_template_route(
return Err!(Request(NotFound("Room is unknown to this server.")));
}
if !services
.rooms
.state_cache
.server_in_room(services.globals.server_name(), &body.room_id)
.await
{
info!(
origin = body.origin().as_str(),
"Refusing to serve make_leave for room we aren't participating in"
);
return Err!(Request(NotFound("This server is not participating in that room.")));
}
if body.user_id.server_name() != body.origin() {
return Err!(Request(Forbidden(
"Not allowed to leave on behalf of another server/user."

View File

@@ -36,18 +36,6 @@ async fn create_join_event(
if !services.rooms.metadata.exists(room_id).await {
return Err!(Request(NotFound("Room is unknown to this server.")));
}
if !services
.rooms
.state_cache
.server_in_room(services.globals.server_name(), room_id)
.await
{
info!(
origin = origin.as_str(),
"Refusing to serve send_join for room we aren't participating in"
);
return Err!(Request(NotFound("This server is not participating in that room.")));
}
// ACL check origin server
services

View File

@@ -1,6 +1,6 @@
use axum::extract::State;
use conduwuit::{
Err, Result, err, info,
Err, Result, err,
matrix::{event::gen_event_id_canonical_json, pdu::PduEvent},
warn,
};
@@ -54,19 +54,6 @@ pub(crate) async fn create_knock_event_v1_route(
return Err!(Request(NotFound("Room is unknown to this server.")));
}
if !services
.rooms
.state_cache
.server_in_room(services.globals.server_name(), &body.room_id)
.await
{
info!(
origin = body.origin().as_str(),
"Refusing to serve send_knock for room we aren't participating in"
);
return Err!(Request(NotFound("This server is not participating in that room.")));
}
// ACL check origin server
services
.rooms

View File

@@ -1,7 +1,7 @@
#![allow(deprecated)]
use axum::extract::State;
use conduwuit::{Err, Result, err, info, matrix::event::gen_event_id_canonical_json};
use conduwuit::{Err, Result, err, matrix::event::gen_event_id_canonical_json};
use conduwuit_service::Services;
use futures::FutureExt;
use ruma::{
@@ -50,19 +50,6 @@ async fn create_leave_event(
return Err!(Request(NotFound("Room is unknown to this server.")));
}
if !services
.rooms
.state_cache
.server_in_room(services.globals.server_name(), room_id)
.await
{
info!(
origin = origin.as_str(),
"Refusing to serve backfill for room we aren't participating in"
);
return Err!(Request(NotFound("This server is not participating in that room.")));
}
// ACL check origin
services
.rooms

View File

@@ -1,7 +1,7 @@
use std::{borrow::Borrow, iter::once};
use axum::extract::State;
use conduwuit::{Err, Result, at, err, info, utils::IterStream};
use conduwuit::{Result, at, err, utils::IterStream};
use futures::{FutureExt, StreamExt, TryStreamExt};
use ruma::{OwnedEventId, api::federation::event::get_room_state};
@@ -24,19 +24,6 @@ pub(crate) async fn get_room_state_route(
.check()
.await?;
if !services
.rooms
.state_cache
.server_in_room(services.globals.server_name(), &body.room_id)
.await
{
info!(
origin = body.origin().as_str(),
"Refusing to serve state for room we aren't participating in"
);
return Err!(Request(NotFound("This server is not participating in that room.")));
}
let shortstatehash = services
.rooms
.state_accessor

View File

@@ -1,7 +1,7 @@
use std::{borrow::Borrow, iter::once};
use axum::extract::State;
use conduwuit::{Err, Result, at, err, info};
use conduwuit::{Result, at, err};
use futures::{StreamExt, TryStreamExt};
use ruma::{OwnedEventId, api::federation::event::get_room_state_ids};
@@ -25,19 +25,6 @@ pub(crate) async fn get_room_state_ids_route(
.check()
.await?;
if !services
.rooms
.state_cache
.server_in_room(services.globals.server_name(), &body.room_id)
.await
{
info!(
origin = body.origin().as_str(),
"Refusing to serve state for room we aren't participating in"
);
return Err!(Request(NotFound("This server is not participating in that room.")));
}
let shortstatehash = services
.rooms
.state_accessor

View File

@@ -1,6 +1,6 @@
use conduwuit::{Err, Result, implement, is_false};
use conduwuit_service::Services;
use futures::{FutureExt, future::OptionFuture, join};
use futures::{FutureExt, StreamExt, future::OptionFuture, join};
use ruma::{EventId, RoomId, ServerName};
pub(super) struct AccessCheck<'a> {
@@ -31,6 +31,15 @@ pub(super) async fn check(&self) -> Result {
.state_cache
.server_in_room(self.origin, self.room_id);
// if any user on our homeserver is trying to knock this room, we'll need to
// acknowledge bans or leaves
let user_is_knocking = self
.services
.rooms
.state_cache
.room_members_knocked(self.room_id)
.count();
let server_can_see: OptionFuture<_> = self
.event_id
.map(|event_id| {
@@ -42,14 +51,14 @@ pub(super) async fn check(&self) -> Result {
})
.into();
let (world_readable, server_in_room, server_can_see, acl_check) =
join!(world_readable, server_in_room, server_can_see, acl_check);
let (world_readable, server_in_room, server_can_see, acl_check, user_is_knocking) =
join!(world_readable, server_in_room, server_can_see, acl_check, user_is_knocking);
if !acl_check {
return Err!(Request(Forbidden("Server access denied.")));
}
if !world_readable && !server_in_room {
if !world_readable && !server_in_room && user_is_knocking == 0 {
return Err!(Request(Forbidden("Server is not in room.")));
}

View File

@@ -19,7 +19,7 @@
use regex::RegexSet;
use ruma::{
OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedUserId, RoomVersionId,
api::client::discovery::{discover_homeserver::RtcFocusInfo, discover_support::ContactRole},
api::client::discovery::discover_support::ContactRole,
};
use serde::{Deserialize, de::IgnoredAny};
use url::Url;
@@ -559,7 +559,7 @@ pub struct Config {
///
/// If you would like registration only via token reg, please configure
/// `registration_token`.
#[serde(default = "true_fn")]
#[serde(default)]
pub allow_registration: bool,
/// If registration is enabled, and this setting is true, new users
@@ -1696,11 +1696,6 @@ pub struct Config {
#[serde(default)]
pub url_preview_check_root_domain: bool,
/// User agent that is used specifically when fetching url previews.
///
/// default: "continuwuity/<version> (bot; +https://continuwuity.org)"
pub url_preview_user_agent: Option<String>,
/// List of forbidden room aliases and room IDs as strings of regex
/// patterns.
///
@@ -2116,19 +2111,6 @@ pub struct WellKnownConfig {
/// If no email or mxid is specified, all of the server's admins will be
/// listed.
pub support_mxid: Option<OwnedUserId>,
/// A list of MatrixRTC foci URLs which will be served as part of the
/// MSC4143 client endpoint at /.well-known/matrix/client. If you're
/// setting up livekit, you'd want something like:
/// rtc_focus_server_urls = [
/// { type = "livekit", livekit_service_url = "https://livekit.example.com" },
/// ]
///
/// To disable, set this to be an empty vector (`[]`).
///
/// default: []
#[serde(default = "default_rtc_focus_urls")]
pub rtc_focus_server_urls: Vec<RtcFocusInfo>,
}
#[derive(Clone, Copy, Debug, Deserialize, Default)]
@@ -2626,9 +2608,6 @@ fn default_rocksdb_stats_level() -> u8 { 1 }
#[inline]
pub fn default_default_room_version() -> RoomVersionId { RoomVersionId::V11 }
#[must_use]
pub fn default_rtc_focus_urls() -> Vec<RtcFocusInfo> { vec![] }
fn default_ip_range_denylist() -> Vec<String> {
vec![
"127.0.0.0/8".to_owned(),

View File

@@ -14,25 +14,6 @@
impl axum::response::IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
let status = self.status_code();
if status.is_server_error() {
error!(
error = %self,
error_debug = ?self,
kind = ?self.kind(),
status = %status,
"Server error"
);
} else if status.is_client_error() {
use crate::debug_error;
debug_error!(
error = %self,
kind = ?self.kind(),
status = %status,
"Client error"
);
}
let response: UiaaResponse = self.into();
response
.try_into_http_response::<BytesMut>()
@@ -85,8 +66,7 @@ pub(super) fn bad_request_code(kind: &ErrorKind) -> StatusCode {
| Unrecognized => StatusCode::METHOD_NOT_ALLOWED,
// 404
| NotFound | NotImplemented | FeatureDisabled | SenderIgnored { .. } =>
StatusCode::NOT_FOUND,
| NotFound | NotImplemented | FeatureDisabled => StatusCode::NOT_FOUND,
// 403
| GuestAccessForbidden

View File

@@ -8,11 +8,9 @@
use std::sync::OnceLock;
static BRANDING: &str = "continuwuity";
static WEBSITE: &str = "https://continuwuity.org";
static SEMANTIC: &str = env!("CARGO_PKG_VERSION");
static VERSION: OnceLock<String> = OnceLock::new();
static VERSION_UA: OnceLock<String> = OnceLock::new();
static USER_AGENT: OnceLock<String> = OnceLock::new();
#[inline]
@@ -21,18 +19,11 @@ pub fn name() -> &'static str { BRANDING }
#[inline]
pub fn version() -> &'static str { VERSION.get_or_init(init_version) }
#[inline]
pub fn version_ua() -> &'static str { VERSION_UA.get_or_init(init_version_ua) }
#[inline]
pub fn user_agent() -> &'static str { USER_AGENT.get_or_init(init_user_agent) }
fn init_user_agent() -> String { format!("{}/{} (bot; +{WEBSITE})", name(), version_ua()) }
fn init_version_ua() -> String {
conduwuit_build_metadata::version_tag()
.map_or_else(|| SEMANTIC.to_owned(), |extra| format!("{SEMANTIC}+{extra}"))
}
fn init_user_agent() -> String { format!("{}/{}", name(), version()) }
fn init_version() -> String {
conduwuit_build_metadata::version_tag()

View File

@@ -230,7 +230,6 @@ tracing-opentelemetry.workspace = true
tracing-subscriber.workspace = true
tracing.workspace = true
tracing-journald = { workspace = true, optional = true }
parking_lot.workspace = true
[target.'cfg(all(not(target_env = "msvc"), target_os = "linux"))'.dependencies]

View File

@@ -1,36 +0,0 @@
use std::{thread, time::Duration};
/// Runs a loop that checks for deadlocks every 10 seconds.
///
/// Note that this requires the `deadlock_detection` parking_lot feature to be
/// enabled.
pub(crate) fn deadlock_detection_thread() {
loop {
thread::sleep(Duration::from_secs(10));
let deadlocks = parking_lot::deadlock::check_deadlock();
if deadlocks.is_empty() {
continue;
}
eprintln!("{} deadlocks detected", deadlocks.len());
for (i, threads) in deadlocks.iter().enumerate() {
eprintln!("Deadlock #{i}");
for t in threads {
eprintln!("Thread Id {:#?}", t.thread_id());
eprintln!("{:#?}", t.backtrace());
}
}
}
}
/// Spawns the deadlock detection thread.
///
/// This thread will run in the background and check for deadlocks every 10
/// seconds. When a deadlock is detected, it will print detailed information to
/// stderr.
pub(crate) fn spawn() {
thread::Builder::new()
.name("deadlock_detector".to_owned())
.spawn(deadlock_detection_thread)
.expect("failed to spawn deadlock detection thread");
}

View File

@@ -5,10 +5,8 @@
use conduwuit_core::{debug_info, error};
mod clap;
mod deadlock;
mod logging;
mod mods;
mod panic;
mod restart;
mod runtime;
mod sentry;
@@ -21,16 +19,11 @@
pub use crate::clap::Args;
pub fn run() -> Result<()> {
panic::init();
let args = clap::parse();
run_with_args(&args)
}
pub fn run_with_args(args: &Args) -> Result<()> {
// Spawn deadlock detection thread
deadlock::spawn();
let runtime = runtime::new(args)?;
let server = Server::new(args, Some(runtime.handle()))?;

View File

@@ -1,34 +0,0 @@
use std::{backtrace::Backtrace, panic};
/// Initialize the panic hook to capture backtraces at the point of panic.
/// This is needed to capture the backtrace before the unwind destroys it.
pub(crate) fn init() {
let default_hook = panic::take_hook();
panic::set_hook(Box::new(move |info| {
let backtrace = Backtrace::force_capture();
let location_str = info.location().map_or_else(String::new, |loc| {
format!(" at {}:{}:{}", loc.file(), loc.line(), loc.column())
});
let message = if let Some(s) = info.payload().downcast_ref::<&str>() {
(*s).to_owned()
} else if let Some(s) = info.payload().downcast_ref::<String>() {
s.clone()
} else {
"Box<dyn Any>".to_owned()
};
let thread_name = std::thread::current()
.name()
.map_or_else(|| "<unnamed>".to_owned(), ToOwned::to_owned);
eprintln!(
"\nthread '{thread_name}' panicked{location_str}: \
{message}\n\nBacktrace:\n{backtrace}"
);
default_hook(info);
}));
}

View File

@@ -39,15 +39,7 @@ pub(crate) async fn run(services: Arc<Services>) -> Result<()> {
.runtime()
.spawn(serve::serve(services.clone(), handle.clone(), tx.subscribe()));
// Run startup admin commands.
// This has to be done after the admin service is initialized otherwise it
// panics.
services.admin.startup_execute().await?;
// Print first-run banner if necessary. This needs to be done after the startup
// admin commands are run in case one of them created the first user.
services.firstrun.print_first_run_banner();
// Focal point
debug!("Running");
let res = tokio::select! {
res = &mut listener => res.map_err(Error::from).unwrap_or_else(Err),

View File

@@ -79,7 +79,6 @@ zstd_compression = [
]
[dependencies]
askama.workspace = true
async-trait.workspace = true
base64.workspace = true
bytes.workspace = true
@@ -119,7 +118,7 @@ webpage.optional = true
blurhash.workspace = true
blurhash.optional = true
recaptcha-verify = { version = "0.1.5", default-features = false }
yansi.workspace = true
indexmap.workspace = true
[target.'cfg(all(unix, target_os = "linux"))'.dependencies]
sd-notify.workspace = true

View File

@@ -26,7 +26,7 @@ pub(super) async fn console_auto_stop(&self) {
/// Execute admin commands after startup
#[implement(super::Service)]
pub async fn startup_execute(&self) -> Result {
pub(super) async fn startup_execute(&self) -> Result {
// List of commands to execute
let commands = &self.services.server.config.admin_execute;

View File

@@ -9,6 +9,7 @@
RoomAccountDataEventType, StateEventType,
room::{
member::{MembershipState, RoomMemberEventContent},
message::RoomMessageEventContent,
power_levels::RoomPowerLevelsEventContent,
},
tag::{TagEvent, TagEventContent, TagInfo},
@@ -125,6 +126,23 @@ pub async fn make_user_admin(&self, user_id: &UserId) -> Result {
}
}
if self.services.server.config.admin_room_notices {
let welcome_message = String::from(
"## Thank you for trying out Continuwuity!\n\nContinuwuity is a hard fork of conduwuit, which is also a hard fork of Conduit, currently in Beta. The Beta status initially was inherited from Conduit, however overtime this Beta status is rapidly becoming less and less relevant as our codebase significantly diverges more and more. Continuwuity is quite stable and very usable as a daily driver and for a low-medium sized homeserver. There is still a lot of more work to be done, but it is in a far better place than the project was in early 2024.\n\nHelpful links:\n> Source code: https://forgejo.ellis.link/continuwuation/continuwuity\n> Documentation: https://continuwuity.org/\n> Report issues: https://forgejo.ellis.link/continuwuation/continuwuity/issues\n\nFor a list of available commands, send the following message in this room: `!admin --help`\n\nHere are some rooms you can join (by typing the command into your client) -\n\nContinuwuity space: `/join #space:continuwuity.org`\nContinuwuity main room (Ask questions and get notified on updates): `/join #continuwuity:continuwuity.org`\nContinuwuity offtopic room: `/join #offtopic:continuwuity.org`",
);
// Send welcome message
self.services
.timeline
.build_and_append_pdu(
PduBuilder::timeline(&RoomMessageEventContent::text_markdown(welcome_message)),
server_user,
Some(&room_id),
&state_lock,
)
.await?;
}
Ok(())
}

View File

@@ -137,6 +137,7 @@ async fn worker(self: Arc<Self>) -> Result<()> {
let mut signals = self.services.server.signal.subscribe();
let receiver = self.channel.1.clone();
self.startup_execute().await?;
self.console_auto_start().await;
loop {
@@ -405,10 +406,6 @@ pub async fn get_admins(&self) -> Vec<OwnedUserId> {
/// Checks whether a given user is an admin of this server
pub async fn user_is_admin(&self, user_id: &UserId) -> bool {
if self.services.globals.server_user == user_id {
return true;
}
if self
.services
.server

View File

@@ -18,7 +18,7 @@
use std::{sync::Arc, time::Duration};
use async_trait::async_trait;
use conduwuit::{Result, Server, debug, error, warn};
use conduwuit::{Result, Server, debug, error, info, warn};
use database::{Deserialized, Map};
use rand::Rng;
use ruma::events::{Mentions, room::message::RoomMessageEventContent};
@@ -155,6 +155,11 @@ async fn check(&self) -> Result<()> {
#[tracing::instrument(skip_all)]
async fn handle(&self, announcement: &CheckForAnnouncementsResponseEntry) {
if let Some(date) = &announcement.date {
info!("[announcements] {date} {:#}", announcement.message);
} else {
info!("[announcements] {:#}", announcement.message);
}
let mut message = RoomMessageEventContent::text_markdown(format!(
"### New announcement{}\n\n{}",
announcement

View File

@@ -36,11 +36,6 @@ fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
.clone()
.and_then(Either::right);
let url_preview_user_agent = config
.url_preview_user_agent
.clone()
.unwrap_or_else(|| conduwuit::version::user_agent().to_owned());
Ok(Arc::new(Self {
default: base(config)?
.dns_resolver(resolver.resolver.clone())
@@ -54,7 +49,6 @@ fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
.dns_resolver(resolver.resolver.clone())
.timeout(Duration::from_secs(config.url_preview_timeout))
.redirect(redirect::Policy::limited(3))
.user_agent(url_preview_user_agent)
.build()?,
extern_media: base(config)?

View File

@@ -7,25 +7,12 @@
error, implement,
};
use crate::registration_tokens::{ValidToken, ValidTokenSource};
pub struct Service {
server: Arc<Server>,
}
const SIGNAL: &str = "SIGUSR1";
impl Service {
/// Get the registration token set in the config file, if it exists.
#[must_use]
pub fn get_config_file_token(&self) -> Option<ValidToken> {
self.registration_token.clone().map(|token| ValidToken {
token,
source: ValidTokenSource::ConfigFile,
})
}
}
#[async_trait]
impl crate::Service for Service {
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {

View File

@@ -1,302 +0,0 @@
use std::{
io::IsTerminal,
sync::{Arc, OnceLock},
};
use askama::Template;
use async_trait::async_trait;
use conduwuit::{Result, info, utils::ReadyExt};
use futures::StreamExt;
use ruma::{UserId, events::room::message::RoomMessageEventContent};
use crate::{
Dep, admin, config, globals,
registration_tokens::{self, ValidToken, ValidTokenSource},
users,
};
pub struct Service {
services: Services,
/// Represents the state of first run mode.
///
/// First run mode is either active or inactive at server start. It may
/// transition from active to inactive, but only once, and can never
/// transition the other way. Additionally, whether the server is in first
/// run mode or not can only be determined when all services are
/// constructed. The outer `OnceLock` represents the unknown state of first
/// run mode, and the inner `OnceLock` enforces the one-time transition from
/// active to inactive.
///
/// Consequently, this marker may be in one of three states:
/// 1. OnceLock<uninitialized>, representing the unknown state of first run
/// mode during server startup. Once server startup is complete, the
/// marker transitions to state 2 or directly to state 3.
/// 2. OnceLock<OnceLock<uninitialized>>, representing first run mode being
/// active. The marker may only transition to state 3 from here.
/// 3. OnceLock<OnceLock<()>>, representing first run mode being inactive.
/// The marker may not transition out of this state.
first_run_marker: OnceLock<OnceLock<()>>,
/// A single-use registration token which may be used to create the first
/// account.
first_account_token: String,
}
struct Services {
config: Dep<config::Service>,
users: Dep<users::Service>,
globals: Dep<globals::Service>,
admin: Dep<admin::Service>,
}
#[async_trait]
impl crate::Service for Service {
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
Ok(Arc::new(Self {
services: Services {
config: args.depend::<config::Service>("config"),
users: args.depend::<users::Service>("users"),
globals: args.depend::<globals::Service>("globals"),
admin: args.depend::<admin::Service>("admin"),
},
// marker starts in an indeterminate state
first_run_marker: OnceLock::new(),
first_account_token: registration_tokens::Service::generate_token_string(),
}))
}
fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
async fn worker(self: Arc<Self>) -> Result {
// first run mode will be enabled if there are no local users
let is_first_run = self
.services
.users
.list_local_users()
.ready_filter(|user| *user != self.services.globals.server_user)
.next()
.await
.is_none();
self.first_run_marker
.set(if is_first_run {
// first run mode is active (empty inner lock)
OnceLock::new()
} else {
// first run mode is inactive (already filled inner lock)
OnceLock::from(())
})
.expect("Service worker should only be called once");
Ok(())
}
}
impl Service {
/// Check if first run mode is active.
pub fn is_first_run(&self) -> bool {
self.first_run_marker
.get()
.expect("First run mode should not be checked during server startup")
.get()
.is_none()
}
/// Disable first run mode and begin normal operation.
///
/// Returns true if first run mode was successfully disabled, and false if
/// first run mode was already disabled.
fn disable_first_run(&self) -> bool {
self.first_run_marker
.get()
.expect("First run mode should not be disabled during server startup")
.set(())
.is_ok()
}
/// If first-run mode is active, grant admin powers to the specified user
/// and disable first-run mode.
///
/// Returns Ok(true) if the specified user was the first user, and Ok(false)
/// if they were not.
pub async fn empower_first_user(&self, user: &UserId) -> Result<bool> {
#[derive(Template)]
#[template(path = "welcome.md.j2")]
struct WelcomeMessage<'a> {
config: &'a Dep<config::Service>,
domain: &'a str,
}
// If first run mode isn't active, do nothing.
if !self.disable_first_run() {
return Ok(false);
}
self.services.admin.make_user_admin(user).await?;
// Send the welcome message
let welcome_message = WelcomeMessage {
config: &self.services.config,
domain: self.services.globals.server_name().as_str(),
}
.render()
.expect("should have been able to render welcome message template");
self.services
.admin
.send_loud_message(RoomMessageEventContent::text_markdown(welcome_message))
.await?;
info!("{user} has been invited to the admin room as the first user.");
Ok(true)
}
/// Get the single-use registration token which may be used to create the
/// first account.
pub fn get_first_account_token(&self) -> Option<ValidToken> {
if self.is_first_run() {
Some(ValidToken {
token: self.first_account_token.clone(),
source: ValidTokenSource::FirstAccount,
})
} else {
None
}
}
pub fn print_first_run_banner(&self) {
use yansi::Paint;
// This function is specially called by the core after all other
// services have started. It runs last to ensure that the banner it
// prints comes after any other logging which may occur on startup.
if !self.is_first_run() {
return;
}
eprintln!();
eprintln!("{}", "============".bold());
eprintln!(
"Welcome to {} {}!",
"Continuwuity".bold().bright_magenta(),
conduwuit::version::version().bold()
);
eprintln!();
eprintln!(
"In order to use your new homeserver, you need to create its first user account."
);
eprintln!(
"Open your Matrix client of choice and register an account on {} using the \
registration token {} . Pick your own username and password!",
self.services.globals.server_name().bold().green(),
self.first_account_token.as_str().bold().green()
);
match (
self.services.config.allow_registration,
self.services.config.get_config_file_token().is_some(),
) {
| (true, true) => {
eprintln!(
"{} until you create an account using the token above.",
"The registration token you set in your configuration will not function"
.red()
);
},
| (true, false) => {
eprintln!(
"{} until you create an account using the token above.",
"Nobody else will be able to register".green()
);
},
| (false, true) => {
eprintln!(
"{} because you have disabled registration in your configuration. If this \
is not desired, set `allow_registration` to true and restart Continuwuity.",
"The registration token you set in your configuration will not be usable"
.yellow()
);
},
| (false, false) => {
eprintln!(
"{} to allow you to create an account. Because registration is not enabled \
in your configuration, it will be disabled again once your account is \
created.",
"Registration has been temporarily enabled".yellow()
);
},
}
eprintln!(
"{} https://matrix.org/ecosystem/clients/",
"Find a list of Matrix clients here:".bold()
);
if self.services.config.suspend_on_register {
eprintln!(
"{} Because you enabled suspend-on-register in your configuration, accounts \
created after yours will be automatically suspended.",
"Your account will not be suspended when you register.".green()
);
}
if self
.services
.config
.yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse
{
eprintln!();
eprintln!(
"{}",
"You have enabled open registration in your configuration! You almost certainly \
do not want to do this."
.bold()
.on_red()
);
eprintln!(
"{}",
"Servers with open, unrestricted registration are prone to abuse by spammers. \
Users on your server may be unable to join chatrooms which block open \
registration servers."
.red()
);
eprintln!(
"If you enabled it only for the purpose of creating the first account, {} and \
create the first account using the token above.",
"disable it now, restart Continuwuity,".red(),
);
// TODO link to a guide on setting up reCAPTCHA
}
if self.services.config.emergency_password.is_some() {
eprintln!();
eprintln!(
"{}",
"You have set an emergency password for the server user! You almost certainly \
do not want to do this."
.red()
);
eprintln!(
"If you set the password only for the purpose of creating the first account, {} \
and create the first account using the token above.",
"disable it now, restart Continuwuity,".red(),
);
}
eprintln!();
if std::io::stdin().is_terminal() && self.services.config.admin_console_automatic {
eprintln!(
"You may also create the first user through the admin console below using the \
`users create-user` command."
);
} else {
eprintln!(
"If you're running the server interactively, you may also create the first user \
through the admin console using the `users create-user` command. Press Ctrl-C \
to open the console."
);
}
eprintln!("If you need assistance setting up your homeserver, make a Matrix account on another homeserver and join our chatroom: https://matrix.to/#/#continuwuity:continuwuity.org");
eprintln!("{}", "============".bold());
}
}

View File

@@ -1,7 +1,7 @@
use std::{cmp, collections::HashMap, future::ready};
use std::{cmp, collections::HashMap};
use conduwuit::{
Err, Event, Pdu, Result, debug, debug_info, debug_warn, error, info,
Err, Pdu, Result, debug, debug_info, debug_warn, error, info,
result::NotFound,
utils::{
IterStream, ReadyExt,
@@ -15,9 +15,8 @@
use ruma::{
OwnedRoomId, OwnedUserId, RoomId, UserId,
events::{
AnyStrippedStateEvent, GlobalAccountDataEventType, StateEventType,
push_rules::PushRulesEvent,
room::member::{MembershipState, RoomMemberEventContent},
GlobalAccountDataEventType, StateEventType, push_rules::PushRulesEvent,
room::member::MembershipState,
},
push::Ruleset,
serde::Raw,
@@ -163,14 +162,6 @@ async fn migrate(services: &Services) -> Result<()> {
populate_userroomid_leftstate_table(services).await?;
}
if db["global"]
.get(FIXED_LOCAL_INVITE_STATE_MARKER)
.await
.is_not_found()
{
fix_local_invite_state(services).await?;
}
assert_eq!(
services.globals.db.database_version().await,
DATABASE_VERSION,
@@ -730,46 +721,3 @@ async fn populate_userroomid_leftstate_table(services: &Services) -> Result {
db.db.sort()?;
Ok(())
}
const FIXED_LOCAL_INVITE_STATE_MARKER: &str = "fix_local_invite_state";
async fn fix_local_invite_state(services: &Services) -> Result {
// Clean up the effects of !1249 by caching stripped state for invites
type KeyVal<'a> = (Key<'a>, Raw<Vec<AnyStrippedStateEvent>>);
type Key<'a> = (&'a UserId, &'a RoomId);
let db = &services.db;
let cork = db.cork_and_sync();
let userroomid_invitestate = services.db["userroomid_invitestate"].clone();
// for each user invited to a room
let fixed = userroomid_invitestate.stream()
// if they're a local user on this homeserver
.try_filter(|((user_id, _), _): &KeyVal<'_>| ready(services.globals.user_is_local(user_id)))
.and_then(async |((user_id, room_id), stripped_state): KeyVal<'_>| Ok::<_, conduwuit::Error>((user_id.to_owned(), room_id.to_owned(), stripped_state.deserialize()?)))
.try_fold(0_usize, async |mut fixed, (user_id, room_id, stripped_state)| {
// and their invite state is None
if stripped_state.is_empty()
// and they are actually invited to the room
&& let Ok(membership_event) = services.rooms.state_accessor.room_state_get(&room_id, &StateEventType::RoomMember, user_id.as_str()).await
&& membership_event.get_content::<RoomMemberEventContent>().is_ok_and(|content| content.membership == MembershipState::Invite)
// and the invite was sent by a local user
&& services.globals.user_is_local(&membership_event.sender) {
// build and save stripped state for their invite in the database
let stripped_state = services.rooms.state.summary_stripped(&membership_event, &room_id).await;
userroomid_invitestate.put((&user_id, &room_id), Json(stripped_state));
fixed = fixed.saturating_add(1);
}
Ok(fixed)
})
.await?;
drop(cork);
info!(?fixed, "Fixed local invite state cache entries.");
db["global"].insert(FIXED_LOCAL_INVITE_STATE_MARKER, []);
db.db.sort()?;
Ok(())
}

View File

@@ -18,7 +18,6 @@
pub mod config;
pub mod emergency;
pub mod federation;
pub mod firstrun;
pub mod globals;
pub mod key_backups;
pub mod media;

View File

@@ -1,17 +1,14 @@
mod data;
use std::{future::ready, pin::Pin, sync::Arc};
use std::sync::Arc;
use conduwuit::{Err, Result, utils};
use data::Data;
pub use data::{DatabaseTokenInfo, TokenExpires};
use futures::{
Stream, StreamExt,
stream::{iter, once},
};
use futures::{Stream, StreamExt, stream};
use ruma::OwnedUserId;
use crate::{Dep, config, firstrun};
use crate::{Dep, config};
const RANDOM_TOKEN_LENGTH: usize = 16;
@@ -22,7 +19,6 @@ pub struct Service {
struct Services {
config: Dep<config::Service>,
firstrun: Dep<firstrun::Service>,
}
/// A validated registration token which may be used to create an account.
@@ -50,9 +46,6 @@ pub enum ValidTokenSource {
ConfigFile,
/// A database token which has been checked to be valid.
Database(DatabaseTokenInfo),
/// The single-use token which may be used to create the homeserver's first
/// account.
FirstAccount,
}
impl std::fmt::Display for ValidTokenSource {
@@ -60,7 +53,6 @@ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
| Self::ConfigFile => write!(f, "Token defined in config."),
| Self::Database(info) => info.fmt(f),
| Self::FirstAccount => write!(f, "Initial setup token."),
}
}
}
@@ -71,7 +63,6 @@ fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
db: Data::new(args.db),
services: Services {
config: args.depend::<config::Service>("config"),
firstrun: args.depend::<firstrun::Service>("firstrun"),
},
}))
}
@@ -80,51 +71,45 @@ fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
}
impl Service {
/// Generate a random string suitable to be used as a registration token.
#[must_use]
pub fn generate_token_string() -> String { utils::random_string(RANDOM_TOKEN_LENGTH) }
/// Issue a new registration token and save it in the database.
pub fn issue_token(
&self,
creator: OwnedUserId,
expires: Option<TokenExpires>,
) -> (String, DatabaseTokenInfo) {
let token = Self::generate_token_string();
let token = utils::random_string(RANDOM_TOKEN_LENGTH);
let info = DatabaseTokenInfo::new(creator, expires);
self.db.save_token(&token, &info);
(token, info)
}
/// Get all the "special" registration tokens that aren't defined in the
/// database.
fn iterate_static_tokens(&self) -> impl Iterator<Item = ValidToken> {
// This does not include the first-account token, because it's special:
// no other registration tokens are valid when it is set.
self.services.config.get_config_file_token().into_iter()
/// Get the registration token set in the config file, if it exists.
pub fn get_config_file_token(&self) -> Option<ValidToken> {
self.services
.config
.registration_token
.clone()
.map(|token| ValidToken {
token,
source: ValidTokenSource::ConfigFile,
})
}
/// Validate a registration token.
pub async fn validate_token(&self, token: String) -> Option<ValidToken> {
// Check for the first-account token first
if let Some(first_account_token) = self.services.firstrun.get_first_account_token() {
if first_account_token == *token {
return Some(first_account_token);
}
// If the first-account token is set, no other tokens are valid
return None;
// Check the registration token in the config first
if self
.get_config_file_token()
.is_some_and(|valid_token| valid_token == *token)
{
return Some(ValidToken {
token,
source: ValidTokenSource::ConfigFile,
});
}
// Then static registration tokens
for static_token in self.iterate_static_tokens() {
if static_token == *token {
return Some(static_token);
}
}
// Then check the database
// Now check the database
if let Some(token_info) = self.db.lookup_token_info(&token).await
&& token_info.is_valid()
{
@@ -141,14 +126,14 @@ pub async fn validate_token(&self, token: String) -> Option<ValidToken> {
/// Mark a valid token as having been used to create a new account.
pub fn mark_token_as_used(&self, ValidToken { token, source }: ValidToken) {
match source {
| ValidTokenSource::ConfigFile => {
// we don't track uses of the config file token, do nothing
},
| ValidTokenSource::Database(mut info) => {
info.uses = info.uses.saturating_add(1);
self.db.save_token(&token, &info);
},
| _ => {
// Do nothing for other token sources.
},
}
}
@@ -159,6 +144,7 @@ pub fn mark_token_as_used(&self, ValidToken { token, source }: ValidToken) {
pub fn revoke_token(&self, ValidToken { token, source }: ValidToken) -> Result {
match source {
| ValidTokenSource::ConfigFile => {
// the config file token cannot be revoked
Err!(
"The token set in the config file cannot be revoked. Edit the config file \
to change it."
@@ -168,19 +154,11 @@ pub fn revoke_token(&self, ValidToken { token, source }: ValidToken) -> Result {
self.db.revoke_token(&token);
Ok(())
},
| ValidTokenSource::FirstAccount => {
Err!("The initial setup token cannot be revoked.")
},
}
}
/// Iterate over all valid registration tokens.
pub fn iterate_tokens(&self) -> Pin<Box<dyn Stream<Item = ValidToken> + Send + '_>> {
// If the first-account token is set, no other tokens are valid
if let Some(first_account_token) = self.services.firstrun.get_first_account_token() {
return once(ready(first_account_token)).boxed();
}
pub fn iterate_tokens(&self) -> impl Stream<Item = ValidToken> + Send + '_ {
let db_tokens = self
.db
.iterate_and_clean_tokens()
@@ -189,6 +167,6 @@ pub fn iterate_tokens(&self) -> Pin<Box<dyn Stream<Item = ValidToken> + Send + '
source: ValidTokenSource::Database(info),
});
iter(self.iterate_static_tokens()).chain(db_tokens).boxed()
stream::iter(self.get_config_file_token()).chain(db_tokens)
}
}

View File

@@ -4,83 +4,18 @@
};
use conduwuit::{
Err, Event, PduEvent, Result, debug::INFO_SPAN_LEVEL, debug_error, debug_info, defer, err,
implement, info, trace, utils::stream::IterStream, warn,
Err, Event, Result, debug::INFO_SPAN_LEVEL, defer, err, implement, utils::stream::IterStream,
warn,
};
use futures::{
FutureExt, TryFutureExt, TryStreamExt,
future::{OptionFuture, try_join4},
};
use ruma::{
CanonicalJsonValue, EventId, OwnedUserId, RoomId, ServerName, UserId,
events::{
StateEventType, TimelineEventType,
room::member::{MembershipState, RoomMemberEventContent},
},
future::{OptionFuture, try_join5},
};
use ruma::{CanonicalJsonValue, EventId, RoomId, ServerName, UserId, events::StateEventType};
use tracing::debug;
use crate::rooms::timeline::{RawPduId, pdu_fits};
async fn should_rescind_invite(
services: &crate::rooms::event_handler::Services,
content: &mut BTreeMap<String, CanonicalJsonValue>,
sender: &UserId,
room_id: &RoomId,
) -> Result<Option<PduEvent>> {
// We insert a bogus event ID since we can't actually calculate the right one
content.insert("event_id".to_owned(), CanonicalJsonValue::String("$rescind".to_owned()));
let pdu_event = serde_json::from_value::<PduEvent>(
serde_json::to_value(&content).expect("CanonicalJsonObj is a valid JsonValue"),
)
.map_err(|e| err!("invalid PDU: {e}"))?;
if pdu_event.room_id().is_none_or(|r| r != room_id)
&& pdu_event.sender() != sender
&& pdu_event.event_type() != &TimelineEventType::RoomMember
&& pdu_event.state_key().is_none_or(|v| v == sender.as_str())
{
return Ok(None);
}
let target_user_id = UserId::parse(pdu_event.state_key().unwrap())?;
if pdu_event
.get_content::<RoomMemberEventContent>()?
.membership
!= MembershipState::Leave
{
return Ok(None); // Not a leave event
}
// Does the target user have a pending invite?
let Ok(pending_invite_state) = services
.state_cache
.invite_state(target_user_id, room_id)
.await
else {
return Ok(None); // No pending invite, so nothing to rescind
};
for event in pending_invite_state {
if event
.get_field::<String>("type")?
.is_some_and(|t| t == "m.room.member")
|| event
.get_field::<OwnedUserId>("state_key")?
.is_some_and(|s| s == *target_user_id)
|| event
.get_field::<OwnedUserId>("sender")?
.is_some_and(|s| s == *sender)
|| event
.get_field::<RoomMemberEventContent>("content")?
.is_some_and(|c| c.membership == MembershipState::Invite)
{
return Ok(Some(pdu_event));
}
}
Ok(None)
}
/// When receiving an event one needs to:
/// 0. Check the server is in the room
/// 1. Skip the PDU if we already know about it
@@ -134,9 +69,8 @@ pub async fn handle_incoming_pdu<'a>(
);
return Err!(Request(TooLarge("PDU is too large")));
}
trace!("processing incoming pdu from {origin} for room {room_id} with event id {event_id}");
// 1.1 Check we even know about the room
// 1.1 Check the server is in the room
let meta_exists = self.services.metadata.exists(room_id).map(Ok);
// 1.2 Check if the room is disabled
@@ -157,59 +91,28 @@ pub async fn handle_incoming_pdu<'a>(
.then(|| self.acl_check(sender.server_name(), room_id))
.into();
let (meta_exists, is_disabled, (), ()) = try_join4(
// Fetch create event
let create_event =
self.services
.state_accessor
.room_state_get(room_id, &StateEventType::RoomCreate, "");
let (meta_exists, is_disabled, (), (), ref create_event) = try_join5(
meta_exists,
is_disabled,
origin_acl_check,
sender_acl_check.map(|o| o.unwrap_or(Ok(()))),
create_event,
)
.await
.inspect_err(|e| debug_error!("failed to handle incoming PDU: {e}"))?;
if is_disabled {
return Err!(Request(Forbidden("Federation of this room is disabled by this server.")));
}
if !self
.services
.state_cache
.server_in_room(self.services.globals.server_name(), room_id)
.await
{
// Is this a federated invite rescind?
// copied from https://github.com/element-hq/synapse/blob/7e4588a/synapse/handlers/federation_event.py#L255-L300
if value.get("type").and_then(|t| t.as_str()) == Some("m.room.member") {
if let Some(pdu) =
should_rescind_invite(&self.services, &mut value.clone(), sender, room_id).await?
{
debug_info!(
"Invite to {room_id} appears to have been rescinded by {sender}, marking as \
left"
);
self.services
.state_cache
.mark_as_left(sender, room_id, Some(pdu))
.await;
return Ok(None);
}
}
info!(
%origin,
"Dropping inbound PDU for room we aren't participating in"
);
return Err!(Request(NotFound("This server is not participating in that room.")));
}
.await?;
if !meta_exists {
return Err!(Request(NotFound("Room is unknown to this server")));
}
// Fetch create event
let create_event = &(self
.services
.state_accessor
.room_state_get(room_id, &StateEventType::RoomCreate, "")
.await?);
if is_disabled {
return Err!(Request(Forbidden("Federation of this room is disabled by this server.")));
}
let (incoming_pdu, val) = self
.handle_outlier_pdu(origin, create_event, event_id, room_id, value, false)

View File

@@ -56,7 +56,7 @@ pub async fn parse_incoming_pdu(&self, pdu: &RawJsonValue) -> Result<Parsed> {
.state
.get_room_version(&room_id)
.await
.unwrap_or(RoomVersionId::V1);
.map_err(|_| err!("Server is not in room {room_id}"))?;
let (event_id, value) = gen_event_id_canonical_json(pdu, &room_version_id).map_err(|e| {
err!(Request(InvalidParam("Could not convert event to canonical json: {e}")))
})?;

View File

@@ -58,11 +58,7 @@ pub async fn ask_policy_server(
.state_accessor
.room_state_get_content(room_id, &StateEventType::RoomPolicy, "")
.await
.inspect_err(|e| {
if !e.is_not_found() {
debug_error!("failed to load room policy server state event: {e}");
}
})
.inspect_err(|e| debug_error!("failed to load room policy server state event: {e}"))
.map(|c: RoomPolicyEventContent| c)
else {
debug!("room has no policy server configured");

View File

@@ -1,7 +1,7 @@
use std::borrow::Borrow;
use conduwuit::{
Pdu, Result, err, implement,
Result, err, implement,
matrix::{Event, StateKey},
};
use futures::{Stream, StreamExt, TryFutureExt};
@@ -84,7 +84,7 @@ pub async fn room_state_get(
room_id: &RoomId,
event_type: &StateEventType,
state_key: &str,
) -> Result<Pdu> {
) -> Result<impl Event> {
self.services
.state
.get_room_shortstatehash(room_id)

View File

@@ -1,4 +1,4 @@
use conduwuit::{implement, utils::stream::ReadyExt, warn};
use conduwuit::{implement, utils::stream::ReadyExt};
use futures::StreamExt;
use ruma::{
EventId, RoomId, ServerName,
@@ -19,12 +19,7 @@ pub async fn server_can_see_event(
event_id: &EventId,
) -> bool {
let Ok(shortstatehash) = self.pdu_shortstatehash(event_id).await else {
warn!(
"Unable to visibility check event {} in room {} for server {}: shortstatehash not \
found",
event_id, room_id, origin
);
return false;
return true;
};
let history_visibility = self

View File

@@ -30,7 +30,6 @@ struct Services {
config: Dep<config::Service>,
globals: Dep<globals::Service>,
metadata: Dep<rooms::metadata::Service>,
state: Dep<rooms::state::Service>,
state_accessor: Dep<rooms::state_accessor::Service>,
users: Dep<users::Service>,
}
@@ -65,7 +64,6 @@ fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
config: args.depend::<config::Service>("config"),
globals: args.depend::<globals::Service>("globals"),
metadata: args.depend::<rooms::metadata::Service>("rooms::metadata"),
state: args.depend::<rooms::state::Service>("rooms::state"),
state_accessor: args
.depend::<rooms::state_accessor::Service>("rooms::state_accessor"),
users: args.depend::<users::Service>("users"),

View File

@@ -118,8 +118,10 @@ pub async fn update_membership(
self.mark_as_joined(user_id, room_id);
},
| MembershipState::Invite => {
let last_state = self.services.state.summary_stripped(pdu, room_id).await;
self.mark_as_invited(user_id, room_id, pdu.sender(), Some(last_state), None)
// 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?;
},
| MembershipState::Leave | MembershipState::Ban => {

View File

@@ -9,7 +9,7 @@
use crate::{
account_data, admin, announcements, antispam, appservice, client, config, emergency,
federation, firstrun, globals, key_backups,
federation, globals, key_backups,
manager::Manager,
media, moderation, presence, pusher, registration_tokens, resolver, rooms, sending,
server_keys,
@@ -33,7 +33,6 @@ pub struct Services {
pub resolver: Arc<resolver::Service>,
pub rooms: rooms::Service,
pub federation: Arc<federation::Service>,
pub firstrun: Arc<firstrun::Service>,
pub sending: Arc<sending::Service>,
pub server_keys: Arc<server_keys::Service>,
pub sync: Arc<sync::Service>,
@@ -68,9 +67,6 @@ macro_rules! build {
}
Ok(Arc::new(Self {
// firstrun service should be built first so other services
// can check first-run state
firstrun: build!(firstrun::Service),
account_data: build!(account_data::Service),
admin: build!(admin::Service),
appservice: build!(appservice::Service),
@@ -148,7 +144,6 @@ pub async fn start(self: &Arc<Self>) -> Result<Arc<Self>> {
}
debug_info!("Services startup complete.");
Ok(Arc::clone(self))
}

View File

@@ -1,29 +0,0 @@
## Thank you for trying out Continuwuity!
Your new homeserver is ready to use! {%- if config.allow_federation %} To make sure you can federate with the rest of the Matrix network, consider checking your domain (`{{ domain }}`) with a federation tester like [this one](https://connectivity-tester.mtrnord.blog/). {%- endif %}
{% if config.get_config_file_token().is_some() -%}
Users may now create accounts normally using the configured registration token.
{%- else if config.recaptcha_site_key.is_some() -%}
Users may now create accounts normally after solving a CAPTCHA.
{%- else if config.yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse -%}
**This server has open, unrestricted registration enabled!** Anyone, including spammers, may now create an account with no further steps. If this is not desired behavior, set `yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse` to `false` in your configuration and restart the server.
{%- else if config.allow_registration -%}
To allow more users to register, use the `!admin token` admin commands to issue registration tokens, or set a registration token in the configuration.
{%- else -%}
You've disabled registration. To create more accounts, use the `!admin users create-user` admin command.
{%- endif %}
This room is your server's admin room. You can send messages starting with `!admin` in this room to perform a range of administrative actions.
To view a list of available commands, send the following message: `!admin --help`
Project chatrooms:
> Support chatroom: https://matrix.to/#/#continuwuity:continuwuity.org
> Update announcements: https://matrix.to/#/#announcements:continuwuity.org
> Other chatrooms: https://matrix.to/#/#space:continuwuity.org
>
Helpful links:
> Source code: https://forgejo.ellis.link/continuwuation/continuwuity
> Documentation: https://continuwuity.org/
> Report issues: https://forgejo.ellis.link/continuwuation/continuwuity/issues

View File

@@ -233,16 +233,6 @@ pub async fn try_auth(
| AuthData::Dummy(_) => {
uiaainfo.completed.push(AuthType::Dummy);
},
| AuthData::FallbackAcknowledgement(_) => {
// The client is checking if authentication has succeeded out-of-band. This is
// possible if the client is using "fallback auth" (see spec section
// 4.9.1.4), which we don't support (and probably never will, because it's a
// disgusting hack).
// Return early to tell the client that no, authentication did not succeed while
// it wasn't looking.
return Ok((false, uiaainfo));
},
| k => error!("type not supported: {:?}", k),
}

View File

@@ -187,9 +187,7 @@ pub async fn create(
self.db
.userid_origin
.insert(user_id, origin.unwrap_or("password"));
self.set_password(user_id, password).await?;
Ok(())
self.set_password(user_id, password).await
}
/// Deactivate account
@@ -306,11 +304,7 @@ pub fn disable_login(&self, user_id: &UserId) {
pub fn enable_login(&self, user_id: &UserId) { self.db.userid_logindisabled.remove(user_id); }
pub async fn is_login_disabled(&self, user_id: &UserId) -> bool {
self.db
.userid_logindisabled
.exists(user_id.as_str())
.await
.is_ok()
self.db.userid_logindisabled.contains(user_id).await
}
/// Check if account is active, infallible
@@ -1271,12 +1265,12 @@ pub fn set_profile_key(
}
#[cfg(not(feature = "ldap"))]
pub async fn search_ldap(&self, _user_id: &UserId) -> Result<Vec<(String, Option<bool>)>> {
pub async fn search_ldap(&self, _user_id: &UserId) -> Result<Vec<(String, bool)>> {
Err!(FeatureDisabled("ldap"))
}
#[cfg(feature = "ldap")]
pub async fn search_ldap(&self, user_id: &UserId) -> Result<Vec<(String, Option<bool>)>> {
pub async fn search_ldap(&self, user_id: &UserId) -> Result<Vec<(String, bool)>> {
let localpart = user_id.localpart().to_owned();
let lowercased_localpart = localpart.to_lowercase();
@@ -1320,7 +1314,7 @@ pub async fn search_ldap(&self, user_id: &UserId) -> Result<Vec<(String, Option<
.inspect(|(entries, result)| trace!(?entries, ?result, "LDAP Search"))
.map_err(|e| err!(Ldap(error!(?attr, ?user_filter, "LDAP search error: {e}"))))?;
let mut dns: HashMap<String, Option<bool>> = entries
let mut dns: HashMap<String, bool> = entries
.into_iter()
.filter_map(|entry| {
let search_entry = SearchEntry::construct(entry);
@@ -1331,16 +1325,11 @@ pub async fn search_ldap(&self, user_id: &UserId) -> Result<Vec<(String, Option<
.into_iter()
.chain(search_entry.attrs.get(&config.name_attribute))
.any(|ids| ids.contains(&localpart) || ids.contains(&lowercased_localpart))
.then_some((search_entry.dn, None))
.then_some((search_entry.dn, false))
})
.collect();
if !config.admin_filter.is_empty() {
// Update all existing entries to Some(false) since we can now determine admin
// status
for admin_status in dns.values_mut() {
*admin_status = Some(false);
}
let admin_base_dn = if config.admin_base_dn.is_empty() {
&config.base_dn
} else {
@@ -1369,7 +1358,7 @@ pub async fn search_ldap(&self, user_id: &UserId) -> Result<Vec<(String, Option<
.into_iter()
.chain(search_entry.attrs.get(&config.name_attribute))
.any(|ids| ids.contains(&localpart) || ids.contains(&lowercased_localpart))
.then_some((search_entry.dn, Some(true)))
.then_some((search_entry.dn, true))
}));
}

19
src/stitcher/Cargo.toml Normal file
View File

@@ -0,0 +1,19 @@
[package]
name = "stitcher"
description = "An implementation of stitched ordering (https://codeberg.org/andybalaam/stitched-order)"
edition.workspace = true
license.workspace = true
readme.workspace = true
repository.workspace = true
version.workspace = true
[lib]
path = "mod.rs"
[dependencies]
indexmap.workspace = true
itertools.workspace = true
[dev-dependencies]
peg = "0.8.5"
rustyline = { version = "17.0.2", default-features = false }

141
src/stitcher/algorithm.rs Normal file
View File

@@ -0,0 +1,141 @@
use std::collections::HashSet;
use indexmap::IndexSet;
use itertools::Itertools;
use super::{Batch, Gap, OrderKey, StitchedItem, StitcherBackend};
/// Updates to a gap in the stitched order.
#[derive(Debug)]
pub struct GapUpdate<'id, K: OrderKey> {
/// The opaque key of the gap to update.
pub key: K,
/// The new contents of the gap. If this is empty, the gap should be
/// deleted.
pub gap: Gap,
/// New items to insert after the gap. These items _should not_ be
/// synchronized to clients.
pub inserted_items: Vec<StitchedItem<'id>>,
}
/// Updates to the stitched order.
#[derive(Debug)]
pub struct OrderUpdates<'id, K: OrderKey> {
/// Updates to individual gaps. The items inserted by these updates _should
/// not_ be synchronized to clients.
pub gap_updates: Vec<GapUpdate<'id, K>>,
/// New items to append to the end of the order. These items _should_ be
/// synchronized to clients.
pub new_items: Vec<StitchedItem<'id>>,
// The subset of events in the batch which got slotted into an existing gap. This is tracked
// for unit testing and may eventually be sent to clients.
pub events_added_to_gaps: HashSet<&'id str>,
}
/// The stitcher, which implements the stitched ordering algorithm.
/// Its primary method is [`Stitcher::stitch`].
pub struct Stitcher<'backend, B: StitcherBackend> {
backend: &'backend B,
}
impl<B: StitcherBackend> Stitcher<'_, B> {
/// Create a new [`Stitcher`] given a [`StitcherBackend`].
pub fn new(backend: &B) -> Stitcher<'_, B> { Stitcher { backend } }
/// Given a [`Batch`], compute the [`OrderUpdates`] which should be made to
/// the stitched order to incorporate that batch. It is the responsibility
/// of the caller to apply the updates.
pub fn stitch<'id>(&self, batch: &Batch<'id>) -> OrderUpdates<'id, B::Key> {
let mut gap_updates = Vec::new();
let mut events_added_to_gaps: HashSet<&'id str> = HashSet::new();
// Events in the batch which haven't been fitted into a gap or appended to the
// end yet.
let mut remaining_events: IndexSet<_> = batch.events().collect();
// 1: Find existing gaps which include IDs of events in `batch`
let matching_gaps = self.backend.find_matching_gaps(batch.events());
// Repeat steps 2-9 for each matching gap
for (key, mut gap) in matching_gaps {
// 2. Find events in `batch` which are mentioned in `gap`
let matching_events = remaining_events.iter().filter(|id| gap.contains(**id));
// Extend `events_added_to_gaps` with the matching events, which are destined to
// be slotted into gaps.
events_added_to_gaps.extend(matching_events.clone());
// 3. Create the to-insert list from the predecessor sets of each matching event
let events_to_insert: Vec<_> = matching_events
.filter_map(|event| batch.predecessors(event))
.flat_map(|predecessors| predecessors.predecessor_set.iter())
.filter(|event| remaining_events.contains(*event))
.copied()
.collect();
// 4. Remove the events in the to-insert list from `remaining_events` so they
// aren't processed again
remaining_events.retain(|event| !events_to_insert.contains(event));
// 5 and 6
let inserted_items = self.sort_events_and_create_gaps(batch, events_to_insert);
// 8. Update gap
gap.retain(|id| !batch.contains(id));
// 7 and 9. Append to-insert list and delete gap if empty
// The actual work of mutating the order is handled by the callee,
// we just record an update to make.
gap_updates.push(GapUpdate { key: key.clone(), gap, inserted_items });
}
// 10. Append remaining events and gaps
let new_items = self.sort_events_and_create_gaps(batch, remaining_events);
OrderUpdates {
gap_updates,
new_items,
events_added_to_gaps,
}
}
fn sort_events_and_create_gaps<'id>(
&self,
batch: &Batch<'id>,
events_to_insert: impl IntoIterator<Item = &'id str>,
) -> Vec<StitchedItem<'id>> {
// 5. Sort the to-insert list with DAG;received order
let events_to_insert = events_to_insert
.into_iter()
.sorted_by(batch.compare_by_dag_received())
.collect_vec();
// allocate 1.5x the size of the to-insert list
let items_capacity = events_to_insert
.capacity()
.saturating_add(events_to_insert.capacity().div_euclid(2));
let mut items = Vec::with_capacity(items_capacity);
for event in events_to_insert {
let missing_prev_events: HashSet<String> = batch
.predecessors(event)
.expect("events in to_insert should be in batch")
.prev_events
.iter()
.filter(|prev_event| {
!(batch.contains(prev_event) || self.backend.event_exists(prev_event))
})
.map(|id| String::from(*id))
.collect();
if !missing_prev_events.is_empty() {
items.push(StitchedItem::Gap(missing_prev_events));
}
items.push(StitchedItem::Event(event));
}
items
}
}

View File

@@ -0,0 +1,88 @@
use std::collections::HashSet;
use rustyline::{DefaultEditor, Result, error::ReadlineError};
use stitcher::{Batch, EventEdges, Stitcher, memory_backend::MemoryStitcherBackend};
const BANNER: &str = "
stitched ordering test repl
- append an event by typing its name: `A`
- to add prev events, type an arrow and then space-separated event names: `A --> B C D`
- to add multiple events at once, separate them with commas
- use `/reset` to clear the ordering
Ctrl-D to exit, Ctrl-C to clear input
"
.trim_ascii();
enum Command<'line> {
AppendEvents(EventEdges<'line>),
ResetOrder,
}
peg::parser! {
// partially copied from the test case parser
grammar command_parser() for str {
/// Parse whitespace.
rule _ -> () = quiet! { $([' '])* {} }
/// Parse an event ID.
rule event_id() -> &'input str
= quiet! { id:$([char if char.is_ascii_alphanumeric() || ['_', '-'].contains(&char)]+) { id } }
/ expected!("an event ID containing only [a-zA-Z0-9_-]")
/// Parse an event and its prev events.
rule event() -> (&'input str, HashSet<&'input str>)
= id:event_id() prev_events:(_ "-->" _ id:(event_id() ++ _) { id })? {
(id, prev_events.into_iter().flatten().collect())
}
pub rule command() -> Command<'input> =
"/reset" { Command::ResetOrder }
/ events:event() ++ (_ "," _) { Command::AppendEvents(events.into_iter().collect()) }
}
}
fn main() -> Result<()> {
let mut backend = MemoryStitcherBackend::default();
let mut reader = DefaultEditor::new()?;
println!("{BANNER}");
loop {
match reader.readline("> ") {
| Ok(line) => match command_parser::command(&line) {
| Ok(Command::AppendEvents(events)) => {
let batch = Batch::from_edges(&events);
let stitcher = Stitcher::new(&backend);
let updates = stitcher.stitch(&batch);
for update in &updates.gap_updates {
println!("update to gap {}:", update.key);
println!(" new gap contents: {:?}", update.gap);
println!(" inserted items: {:?}", update.inserted_items);
}
println!("events added to gaps: {:?}", &updates.events_added_to_gaps);
println!();
println!("items to sync: {:?}", &updates.new_items);
backend.extend(updates);
println!("order: {backend:?}");
},
| Ok(Command::ResetOrder) => {
backend.clear();
println!("order cleared.");
},
| Err(parse_error) => {
println!("parse error!! {parse_error}");
},
},
| Err(ReadlineError::Interrupted) => {
println!("interrupt");
},
| Err(ReadlineError::Eof) => {
println!("goodbye :3");
break Ok(());
},
| Err(err) => break Err(err),
}
}
}

View File

@@ -0,0 +1,130 @@
use std::{
fmt::Debug,
sync::atomic::{AtomicU64, Ordering},
};
use crate::{Gap, OrderUpdates, StitchedItem, StitcherBackend};
/// A version of [`StitchedItem`] which owns event IDs.
#[derive(Debug)]
enum MemoryStitcherItem {
Event(String),
Gap(Gap),
}
impl From<StitchedItem<'_>> for MemoryStitcherItem {
fn from(value: StitchedItem) -> Self {
match value {
| StitchedItem::Event(id) => MemoryStitcherItem::Event(id.to_string()),
| StitchedItem::Gap(gap) => MemoryStitcherItem::Gap(gap),
}
}
}
impl<'id> From<&'id MemoryStitcherItem> for StitchedItem<'id> {
fn from(value: &'id MemoryStitcherItem) -> Self {
match value {
| MemoryStitcherItem::Event(id) => StitchedItem::Event(id),
| MemoryStitcherItem::Gap(gap) => StitchedItem::Gap(gap.clone()),
}
}
}
/// A stitcher backend which holds a stitched ordering in RAM.
#[derive(Default)]
pub struct MemoryStitcherBackend {
items: Vec<(u64, MemoryStitcherItem)>,
counter: AtomicU64,
}
impl MemoryStitcherBackend {
fn next_id(&self) -> u64 { self.counter.fetch_add(1, Ordering::Relaxed) }
/// Extend this ordering with new updates.
pub fn extend(&mut self, results: OrderUpdates<'_, <Self as StitcherBackend>::Key>) {
for update in results.gap_updates {
let Some(gap_index) = self.items.iter().position(|(key, _)| *key == update.key)
else {
panic!("bad update key {}", update.key);
};
let insertion_index = if update.gap.is_empty() {
self.items.remove(gap_index);
gap_index
} else {
match self.items.get_mut(gap_index) {
| Some((_, MemoryStitcherItem::Gap(gap))) => {
*gap = update.gap;
},
| Some((key, other)) => {
panic!("expected item with key {key} to be a gap, it was {other:?}");
},
| None => unreachable!("we just checked that this index is valid"),
}
gap_index.checked_add(1).expect(
"should never allocate usize::MAX ids. what kind of test are you running",
)
};
let to_insert: Vec<_> = update
.inserted_items
.into_iter()
.map(|item| (self.next_id(), item.into()))
.collect();
self.items
.splice(insertion_index..insertion_index, to_insert.into_iter())
.for_each(drop);
}
let new_items: Vec<_> = results
.new_items
.into_iter()
.map(|item| (self.next_id(), item.into()))
.collect();
self.items.extend(new_items);
}
/// Iterate over the items in this ordering.
pub fn iter(&self) -> impl Iterator<Item = StitchedItem<'_>> {
self.items.iter().map(|(_, item)| item.into())
}
/// Clear this ordering.
pub fn clear(&mut self) { self.items.clear(); }
}
impl StitcherBackend for MemoryStitcherBackend {
type Key = u64;
fn find_matching_gaps<'a>(
&'a self,
events: impl Iterator<Item = &'a str>,
) -> impl Iterator<Item = (Self::Key, Gap)> {
// nobody cares about test suite performance right
let mut gaps = vec![];
for event in events {
for (key, item) in &self.items {
if let MemoryStitcherItem::Gap(gap) = item
&& gap.contains(event)
{
gaps.push((*key, gap.clone()));
}
}
}
gaps.into_iter()
}
fn event_exists<'a>(&'a self, event: &'a str) -> bool {
self.items
.iter()
.any(|item| matches!(&item.1, MemoryStitcherItem::Event(id) if event == id))
}
}
impl Debug for MemoryStitcherBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_list().entries(self.iter()).finish()
}
}

160
src/stitcher/mod.rs Normal file
View File

@@ -0,0 +1,160 @@
use std::{cmp::Ordering, collections::HashSet};
use indexmap::IndexMap;
pub mod algorithm;
pub mod memory_backend;
#[cfg(test)]
mod test;
pub use algorithm::*;
/// A gap in the stitched order.
pub type Gap = HashSet<String>;
/// An item in the stitched order.
#[derive(Debug)]
pub enum StitchedItem<'id> {
/// A single event.
Event(&'id str),
/// A gap representing one or more missing events.
Gap(Gap),
}
/// An opaque key returned by a [`StitcherBackend`] to identify an item in its
/// order.
pub trait OrderKey: Eq + Clone {}
impl<T: Eq + Clone> OrderKey for T {}
/// A trait providing read-only access to an existing stitched order.
pub trait StitcherBackend {
type Key: OrderKey;
/// Return all gaps containing one or more events listed in `events`.
fn find_matching_gaps<'a>(
&'a self,
events: impl Iterator<Item = &'a str>,
) -> impl Iterator<Item = (Self::Key, Gap)>;
/// Return whether an event exists in the stitched order.
fn event_exists<'a>(&'a self, event: &'a str) -> bool;
}
/// An ordered map from an event ID to its `prev_events`.
pub type EventEdges<'id> = IndexMap<&'id str, HashSet<&'id str>>;
/// Information about the `prev_events` of an event.
/// This struct does not store the ID of the event itself.
#[derive(Debug)]
struct EventPredecessors<'id> {
/// The `prev_events` of the event.
pub prev_events: HashSet<&'id str>,
/// The predecessor set of the event. This is derived from, and a superset
/// of, [`EventPredecessors::prev_events`]. See
/// [`Batch::find_predecessor_set`] for details. It is cached in this
/// struct for performance.
pub predecessor_set: HashSet<&'id str>,
}
/// A batch of events to be inserted into the stitched order.
#[derive(Debug)]
pub struct Batch<'id> {
events: IndexMap<&'id str, EventPredecessors<'id>>,
}
impl<'id> Batch<'id> {
/// Create a new [`Batch`] from an [`EventEdges`].
pub fn from_edges<'edges>(edges: &EventEdges<'edges>) -> Batch<'edges> {
let mut events = IndexMap::new();
for (event, prev_events) in edges {
let predecessor_set = Self::find_predecessor_set(event, edges);
events.insert(*event, EventPredecessors {
prev_events: prev_events.clone(),
predecessor_set,
});
}
Batch { events }
}
/// Build the predecessor set of `event` using `edges`. The predecessor set
/// is a subgraph of the room's DAG which may be thought of as a tree
/// rooted at `event` containing _only_ events which are included in
/// `edges`. It is represented as a set and not a proper tree structure for
/// efficiency.
fn find_predecessor_set<'a>(event: &'a str, edges: &EventEdges<'a>) -> HashSet<&'a str> {
// The predecessor set which we are building.
let mut predecessor_set = HashSet::new();
// The queue of events to check for membership in `remaining_events`.
let mut events_to_check = vec![event];
// Events which we have already checked and do not need to revisit.
let mut events_already_checked = HashSet::new();
while let Some(event) = events_to_check.pop() {
// Don't add this event to the queue again.
events_already_checked.insert(event);
// If this event is in `edges`, add it to the predecessor set.
if let Some(children) = edges.get(event) {
predecessor_set.insert(event);
// Also add all its `prev_events` to the queue. It's fine if some of them don't
// exist in `edges` because they'll just be discarded when they're popped
// off the queue.
events_to_check.extend(
children
.iter()
.filter(|event| !events_already_checked.contains(*event)),
);
}
}
predecessor_set
}
/// Iterate over all the events contained in this batch.
fn events(&self) -> impl Iterator<Item = &'id str> { self.events.keys().copied() }
/// Check whether an event exists in this batch.
fn contains(&self, event: &'id str) -> bool { self.events.contains_key(event) }
/// Return the predecessors of an event, if it exists in this batch.
fn predecessors(&self, event: &str) -> Option<&EventPredecessors<'id>> {
self.events.get(event)
}
/// Compare two events by DAG;received order.
///
/// If either event is in the other's predecessor set it comes first,
/// otherwise they are sorted by which comes first in the batch.
fn compare_by_dag_received(&self) -> impl FnMut(&&'id str, &&'id str) -> Ordering {
|a, b| {
if self
.predecessors(a)
.is_some_and(|it| it.predecessor_set.contains(b))
{
Ordering::Greater
} else if self
.predecessors(b)
.is_some_and(|it| it.predecessor_set.contains(a))
{
Ordering::Less
} else {
let a_index = self
.events
.get_index_of(a)
.expect("a should be in this batch");
let b_index = self
.events
.get_index_of(b)
.expect("b should be in this batch");
a_index.cmp(&b_index)
}
}
}
}

102
src/stitcher/test/mod.rs Normal file
View File

@@ -0,0 +1,102 @@
use itertools::Itertools;
use super::{algorithm::*, *};
use crate::memory_backend::MemoryStitcherBackend;
mod parser;
fn run_testcase(testcase: parser::TestCase<'_>) {
let mut backend = MemoryStitcherBackend::default();
for (index, phase) in testcase.into_iter().enumerate() {
let stitcher = Stitcher::new(&backend);
let batch = Batch::from_edges(&phase.batch);
let updates = stitcher.stitch(&batch);
println!();
println!("===== phase {index}");
for update in &updates.gap_updates {
println!("update to gap {}:", update.key);
println!(" new gap contents: {:?}", update.gap);
println!(" inserted items: {:?}", update.inserted_items);
}
println!("expected new items: {:?}", &phase.order.new_items);
println!(" actual new items: {:?}", &updates.new_items);
for (expected, actual) in phase
.order
.new_items
.iter()
.zip_eq(updates.new_items.iter())
{
assert_eq!(
expected, actual,
"bad new item, expected {expected:?} but got {actual:?}"
);
}
if let Some(updated_gaps) = phase.updated_gaps {
println!("expected events added to gaps: {updated_gaps:?}");
println!(" actual events added to gaps: {:?}", updates.events_added_to_gaps);
assert_eq!(
updated_gaps, updates.events_added_to_gaps,
"incorrect events added to gaps"
);
}
backend.extend(updates);
println!("extended ordering: {:?}", backend);
for (expected, ref actual) in phase.order.iter().zip_eq(backend.iter()) {
assert_eq!(
expected, actual,
"bad item in order, expected {expected:?} but got {actual:?}",
);
}
}
}
macro_rules! testcase {
($index:literal : $id:ident) => {
#[test]
fn $id() {
let testcase = parser::parse(include_str!(concat!(
"./testcases/",
$index,
"-",
stringify!($id),
".stitched"
)));
run_testcase(testcase);
}
};
}
testcase!("001": receiving_new_events);
testcase!("002": recovering_after_netsplit);
testcase!("zzz": being_before_a_gap_item_beats_being_after_an_existing_item_multiple);
testcase!("zzz": being_before_a_gap_item_beats_being_after_an_existing_item);
testcase!("zzz": chains_are_reordered_using_prev_events);
testcase!("zzz": empty_then_simple_chain);
testcase!("zzz": empty_then_two_chains_interleaved);
testcase!("zzz": empty_then_two_chains);
testcase!("zzz": filling_in_a_gap_with_a_batch_containing_gaps);
testcase!("zzz": gaps_appear_before_events_referring_to_them_received_order);
testcase!("zzz": gaps_appear_before_events_referring_to_them);
testcase!("zzz": if_prev_events_determine_order_they_override_received);
testcase!("zzz": insert_into_first_of_several_gaps);
testcase!("zzz": insert_into_last_of_several_gaps);
testcase!("zzz": insert_into_middle_of_several_gaps);
testcase!("zzz": linked_events_are_split_across_gaps);
testcase!("zzz": linked_events_in_a_diamond_are_split_across_gaps);
testcase!("zzz": middle_of_batch_matches_gap_and_end_of_batch_matches_end);
testcase!("zzz": middle_of_batch_matches_gap);
testcase!("zzz": multiple_events_referring_to_the_same_missing_event_first_has_more);
testcase!("zzz": multiple_events_referring_to_the_same_missing_event);
testcase!("zzz": multiple_events_referring_to_the_same_missing_event_with_more);
testcase!("zzz": multiple_missing_prev_events_turn_into_a_single_gap);
testcase!("zzz": partially_filling_a_gap_leaves_it_before_new_nodes);
testcase!("zzz": partially_filling_a_gap_with_two_events);
testcase!("zzz": received_order_wins_within_a_subgroup_if_no_prev_event_chain);
testcase!("zzz": subgroups_are_processed_in_first_received_order);

140
src/stitcher/test/parser.rs Normal file
View File

@@ -0,0 +1,140 @@
use std::collections::HashSet;
use indexmap::IndexMap;
use super::StitchedItem;
pub(super) type TestEventId<'id> = &'id str;
pub(super) type TestGap<'id> = HashSet<TestEventId<'id>>;
#[derive(Debug)]
pub(super) enum TestStitchedItem<'id> {
Event(TestEventId<'id>),
Gap(TestGap<'id>),
}
impl PartialEq<StitchedItem<'_>> for TestStitchedItem<'_> {
fn eq(&self, other: &StitchedItem<'_>) -> bool {
match (self, other) {
| (TestStitchedItem::Event(lhs), StitchedItem::Event(rhs)) => lhs == rhs,
| (TestStitchedItem::Gap(lhs), StitchedItem::Gap(rhs)) =>
lhs.iter().all(|id| rhs.contains(*id)),
| _ => false,
}
}
}
pub(super) type TestCase<'id> = Vec<Phase<'id>>;
pub(super) struct Phase<'id> {
pub batch: Batch<'id>,
pub order: Order<'id>,
pub updated_gaps: Option<HashSet<TestEventId<'id>>>,
}
pub(super) type Batch<'id> = IndexMap<TestEventId<'id>, HashSet<TestEventId<'id>>>;
pub(super) struct Order<'id> {
pub inserted_items: Vec<TestStitchedItem<'id>>,
pub new_items: Vec<TestStitchedItem<'id>>,
}
impl<'id> Order<'id> {
pub(super) fn iter(&self) -> impl Iterator<Item = &TestStitchedItem<'id>> {
self.inserted_items.iter().chain(self.new_items.iter())
}
}
peg::parser! {
grammar testcase() for str {
/// Parse whitespace.
rule _ -> () = quiet! { $([' '])* {} }
/// Parse empty lines and comments.
rule newline() -> () = quiet! { (("#" [^'\n']*)? "\n")+ {} }
/// Parse an "event ID" in a test case, which may only consist of ASCII letters and numbers.
rule event_id() -> TestEventId<'input>
= quiet! { id:$([char if char.is_ascii_alphanumeric()]+) { id } }
/ expected!("event id")
/// Parse a gap in the order section.
rule gap() -> TestGap<'input>
= "-" events:event_id() ++ "," { events.into_iter().collect() }
/// Parse either an event id or a gap.
rule stitched_item() -> TestStitchedItem<'input> =
id:event_id() { TestStitchedItem::Event(id) }
/ gap:gap() { TestStitchedItem::Gap(gap) }
/// Parse an event line in the batch section, mapping an event name to zero or one prev events.
/// The prev events are merged together by [`batch()`].
rule batch_event() -> (TestEventId<'input>, Option<TestEventId<'input>>)
= id:event_id() prev:(_ "-->" _ prev:event_id() { prev })? { (id, prev) }
/// Parse the batch section of a phase.
rule batch() -> Batch<'input>
= events:batch_event() ++ newline() {
/*
Repeated event lines need to be merged together. For example,
A --> B
A --> C
represents a _single_ event `A` with two prev events, `B` and `C`.
*/
events.into_iter()
.fold(IndexMap::new(), |mut batch: Batch<'_>, (id, prev_event)| {
// Find the prev events set of this event in the batch.
// If it doesn't exist, make a new empty one.
let mut prev_events = batch.entry(id).or_default();
// If this event line defines a prev event to add, insert it into the set.
if let Some(prev_event) = prev_event {
prev_events.insert(prev_event);
}
batch
})
}
rule order() -> Order<'input> =
items:(item:stitched_item() new:"*"? { (item, new.is_some()) }) ** newline()
{
let (mut inserted_items, mut new_items) = (vec![], vec![]);
for (item, new) in items {
if new {
new_items.push(item);
} else {
inserted_items.push(item);
}
}
Order {
inserted_items,
new_items,
}
}
rule updated_gaps() -> HashSet<TestEventId<'input>> =
events:event_id() ++ newline() { events.into_iter().collect() }
rule phase() -> Phase<'input> =
"=== when we receive these events ==="
newline() batch:batch()
newline() "=== then we arrange into this order ==="
newline() order:order()
updated_gaps:(
newline() "=== and we notify about these gaps ==="
newline() updated_gaps:updated_gaps() { updated_gaps }
)?
{ Phase { batch, order, updated_gaps } }
pub rule testcase() -> TestCase<'input> = phase() ++ newline()
}
}
pub(super) fn parse<'input>(input: &'input str) -> TestCase<'input> {
testcase::testcase(input.trim_ascii_end()).expect("parse error")
}

View File

@@ -0,0 +1,22 @@
=== when we receive these events ===
A
B --> A
C --> B
=== then we arrange into this order ===
# Given the server has some existing events in this order:
A*
B*
C*
=== when we receive these events ===
# When it receives new ones:
D --> C
E --> D
=== then we arrange into this order ===
# Then it simply appends them at the end of the order:
A
B
C
D*
E*

View File

@@ -0,0 +1,46 @@
=== when we receive these events ===
A1
A2 --> A1
A3 --> A2
=== then we arrange into this order ===
# Given the server has some existing events in this order:
A1*
A2*
A3*
=== when we receive these events ===
# And after a netsplit the server receives some unrelated events, which refer to
# some unknown event, because the server didn't receive all of them:
B7 --> B6
B8 --> B7
B9 --> B8
=== then we arrange into this order ===
# Then these events are new, and we add a gap to show something is missing:
A1
A2
A3
-B6*
B7*
B8*
B9*
=== when we receive these events ===
# Then if we backfill and receive more of those events later:
B4 --> B3
B5 --> B4
B6 --> B5
=== then we arrange into this order ===
# They are slotted into the gap, and a new gap is created to represent the
# still-missing events:
A1
A2
A3
-B3
B4
B5
B6
B7
B8
B9
=== and we notify about these gaps ===
B6

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