Compare commits

...
Author SHA1 Message Date
timedout 59c2649cde chore: Bump version to 26.7.2 2026-07-30 19:04:38 +01:00
timedoutandEllis Git b0c1381f03 fix: Re-introduce residency check when handling incoming transactions 2026-07-30 18:02:34 +00:00
timedout 14ff018b9f fix: Don't soft-fail valid redactions 2026-07-30 18:33:47 +01:00
timedout 1ca30e4268 chore: Fix missing whitespace in previous commit's changelog
My IDE has beef with pre-commit for some reason
2026-07-29 16:45:50 +01:00
timedoutandErwan Leboucher 71016a0d7f fix: SEC20
Reviewed-By: Ginger <ginger@gingershaped.computer>
Co-Authored-By: Erwan Leboucher <erwanleboucher@gmail.com>
2026-07-29 16:41:59 +01:00
Renovate Bot c7602a570c chore(deps): update github-actions-digest 2026-07-28 05:01:29 +00:00
Ginger 83cf15bccb chore: Release 2026-07-27 16:37:23 -04:00
Ginger e8a6a9ac9d fix: Restore missing arm64 build logic 2026-07-27 16:36:50 -04:00
Ginger 4e4c6028ed chore: Release 2026-07-27 15:59:39 -04:00
Ginger 9bfd41000e chore: Update changelog 2026-07-27 15:56:32 -04:00
Ginger 8d10613607 chore: Update generated documentation 2026-07-27 15:53:14 -04:00
Murph MurphyandGinger ca100e093b Fix for jammy previous image w/ different sources format 2026-07-27 15:48:48 -04:00
Murph MurphyandGinger 71bd471bb7 Update after trying to run as much as I could locally via docker 2026-07-27 15:48:48 -04:00
skeet70andGinger ec8815aa23 feat(workflows): Add arm64 .deb 2026-07-27 15:48:48 -04:00
GingerandEllis Git 44d7e6cf4f chore: News fragment 2026-07-27 19:28:15 +00:00
GingerandEllis Git 4e001abe92 feat: Add stable mutual rooms endpoint 2026-07-27 19:28:15 +00:00
Ginger 4a40290514 chore: News fragment 2026-07-27 14:36:42 -04:00
Ginger 226a3917af feat: Show dehydrated devices in account panel 2026-07-27 14:36:01 -04:00
timedoutandEllis Git 7920127391 fix: Explicit type error 2026-07-27 16:01:33 +00:00
timedoutandEllis Git 65ff23bd48 fix: Don't treat policy server signing keys as required for signature verification 2026-07-27 16:01:33 +00:00
Renovate BotandEllis Git 3e8bf4a3c7 chore(deps): update rust crate syn to v3 2026-07-27 16:01:11 +00:00
timedout 399005abc6 chore: Move registration notice logic 2026-07-27 16:46:36 +01:00
timedoutandEllis Git 7fdc7f9216 fix: Ensure client IP is logged in all registration alert paths 2026-07-27 15:20:07 +00:00
timedoutandEllis Git c677847e29 chore: Add newsfrag 2026-07-27 15:20:07 +00:00
timedoutandEllis Git 795cdd3740 fix: Re-introduce registration alerts 2026-07-27 15:20:07 +00:00
timedoutandEllis Git 9894e2a6d1 fix: Rephrase newsfrag 2026-07-27 14:21:38 +00:00
GingerandEllis Git c8a9eb41c5 refactor: Remove redundant bail_restricted calls 2026-07-27 14:21:38 +00:00
GingerandEllis Git e431a13a1a chore: News fragment 2026-07-27 14:21:38 +00:00
GingerandEllis Git 9a3496ae70 feat: Add admin command to issue access tokens 2026-07-27 14:21:38 +00:00
Renovate BotandEllis Git 4cf743883a chore(deps): update rust-zerover-patch-updates 2026-07-27 01:50:47 +00:00
Renovate Bot 2d719e45fb chore(deps): update ghcr.io/renovatebot/renovate docker tag to v43.281.1 2026-07-27 00:29:00 +00:00
Renovate BotandEllis Git 0b456e3492 chore(deps): update rust-non-major 2026-07-27 00:24:50 +00:00
Renovate Bot eee7a22e26 chore(deps): update ruma digest to 3ad0471 2026-07-26 22:57:51 +00:00
63 changed files with 681 additions and 367 deletions
@@ -44,7 +44,7 @@ runs:
- name: Login to builtin registry
if: ${{ env.BUILTIN_REGISTRY_ENABLED == 'true' }}
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
with:
registry: ${{ env.BUILTIN_REGISTRY }}
username: ${{ inputs.registry_user }}
@@ -79,7 +79,7 @@ runs:
- name: Login to builtin registry
if: ${{ env.BUILTIN_REGISTRY_ENABLED == 'true' }}
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
with:
registry: ${{ env.BUILTIN_REGISTRY }}
username: ${{ inputs.registry_user }}
+1 -1
View File
@@ -71,7 +71,7 @@ runs:
- name: Install timelord-cli and git-warp-time
if: steps.check-binaries.outputs.need-install == 'true'
uses: https://github.com/taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 # v2
uses: https://github.com/taiki-e/install-action@41049aa56687c35e0afa74eed4f09cec4f9afabf # v2
with:
tool: git-warp-time,timelord-cli@3.0.1
+57 -7
View File
@@ -18,6 +18,7 @@ jobs:
strategy:
matrix:
container: [ "ubuntu-latest", "ubuntu-previous", "debian-latest", "debian-oldstable" ]
arch: [ "amd64", "arm64" ]
container:
image: "ghcr.io/tcpipuk/act-runner:${{ matrix.container }}"
@@ -54,9 +55,9 @@ jobs:
path: |
~/.cargo/registry
~/.cargo/git
key: cargo-debian-${{ steps.debian-version.outputs.distribution }}-${{ hashFiles('**/Cargo.lock') }}
key: cargo-debian-${{ steps.debian-version.outputs.distribution }}-${{ matrix.arch }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
cargo-debian-${{ steps.debian-version.outputs.distribution }}-
cargo-debian-${{ steps.debian-version.outputs.distribution }}-${{ matrix.arch }}-
- name: Setup sccache
uses: https://git.tomfos.tr/tom/sccache-action@v1
@@ -70,11 +71,55 @@ jobs:
# Aggressive GC since cache restores don't increment counter
echo "CARGO_INCREMENTAL_GC_TRIGGER=5" >> $GITHUB_ENV
- name: Install cross-compilation tools for arm64
if: matrix.arch == 'arm64'
run: |
dpkg --add-architecture arm64
if ! apt-get update; then
# Older Ubuntu releases (e.g. noble) advertise arm64 in their
# Release files but only serve amd64/i386 from archive.ubuntu.com,
# so apt update 404s. Restrict the main sources to amd64 and
# fetch arm64 from ports.ubuntu.com instead.
CODENAME=$(lsb_release -sc)
# deb822 sources (noble and newer)
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
sed -i '/^Components:/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
fi
# one-line sources (jammy and older)
if [ -f /etc/apt/sources.list ]; then
sed -i 's/^deb /deb [arch=amd64] /' /etc/apt/sources.list
fi
printf 'deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports %s main restricted universe multiverse\n' \
"$CODENAME" "$CODENAME-updates" "$CODENAME-security" \
> /etc/apt/sources.list.d/arm64-ports.list
apt-get update
fi
apt-get install -y \
gcc-aarch64-linux-gnu \
g++-aarch64-linux-gnu \
libc6-dev-arm64-cross
- name: Setup Rust
uses: ./.forgejo/actions/setup-rust
with:
github-token: ${{ secrets.GH_PUBLIC_RO }}
- name: Add Rust target
run: |
TARGET=${{ matrix.arch == 'arm64' && 'aarch64-unknown-linux-gnu' || 'x86_64-unknown-linux-gnu' }}
rustup target add $TARGET
- name: Configure cross-compilation for arm64
if: matrix.arch == 'arm64'
run: |
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
echo "CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++" >> $GITHUB_ENV
# rust-rocksdb's build script probes liburing via pkg-config, which
# refuses to run when host != target unless explicitly allowed
echo "PKG_CONFIG_ALLOW_CROSS=1" >> $GITHUB_ENV
echo "PKG_CONFIG_PATH_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu/pkgconfig" >> $GITHUB_ENV
- name: Get package version and component
id: package-meta
run: |
@@ -120,21 +165,26 @@ jobs:
apt-get update -y
# Build dependencies for rocksdb
apt-get install -y liburing-dev clang
# For arm64 builds, install cross-compiled dependencies.
# libstdc++6:arm64 is needed by dpkg-shlibdeps to resolve the
# dynamically-linked libstdc++ when cargo-deb computes $auto depends.
if [ "${{ matrix.arch }}" = "arm64" ]; then
apt-get install -y liburing-dev:arm64 libstdc++6:arm64
fi
- name: Run cargo-deb
id: cargo-deb
run: |
DEB_PATH=$(cargo deb --deb-version ${{ steps.package-meta.outputs.version }})
TARGET=${{ matrix.arch == 'arm64' && 'aarch64-unknown-linux-gnu' || 'x86_64-unknown-linux-gnu' }}
DEB_PATH=$(cargo deb --target $TARGET --deb-version ${{ steps.package-meta.outputs.version }})
echo "path=$DEB_PATH" >> $GITHUB_OUTPUT
- name: Test deb installation
if: matrix.arch == 'amd64'
run: |
echo "Installing: ${{ steps.cargo-deb.outputs.path }}"
apt-get install -y ${{ steps.cargo-deb.outputs.path }}
dpkg -s continuwuity
[ -f /usr/bin/conduwuit ] && echo "✅ Binary installed successfully"
[ -f /usr/lib/systemd/system/conduwuit.service ] && echo "✅ Systemd service installed"
[ -f /etc/conduwuit/conduwuit.toml ] && echo "✅ Config file installed"
@@ -142,7 +192,7 @@ jobs:
- name: Upload deb artifact
uses: forgejo/upload-artifact@v4
with:
name: continuwuity-${{ steps.debian-version.outputs.distribution }}
name: continuwuity-${{ steps.debian-version.outputs.distribution }}-${{ matrix.arch }}
path: ${{ steps.cargo-deb.outputs.path }}
- name: Publish to Forgejo package registry
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
name: Renovate
runs-on: ubuntu-latest
container:
image: ghcr.io/renovatebot/renovate:43.272.6@sha256:e9dee374e7a32827af434362c6e503aa179168a96a8212cc4a5c64bb5c550142
image: ghcr.io/renovatebot/renovate:43.281.1@sha256:34c2dd58f58e8976be2024a24fec23bbee805f0bf887837d9aaee7daeb09ccfc
options: --tmpfs /tmp:exec
steps:
- name: Checkout
+40
View File
@@ -1,3 +1,43 @@
# Continuwuity 26.7.0 (2026-07-27)
## Features
- Build and publish arm64 .deb packages alongside amd64 for all supported Debian and Ubuntu releases. (#1235)
- Dehydrated devices are now visible in the account panel. Contributed by @ginger. (#1970)
- Introduce `accepted_ip_sources` as a multiple options variant of `request_ip_source`, allowing for more advanced deployments and making fallbacks an explicit choice. Contributed by @Omar007 (#1985)
- Added an admin command to issue an access token for a bot account, to allow legacy bots to function while legacy authentication is disabled. Contributed by @ginger (#2044)
- Added support for the OAuth2 device authorization flow. Contributed by @ginger
- Added support for the stable mutual rooms query endpoint. Contributed by @ginger
- Fetch the joined member count once per event instead of once per notified user.
## Bugfixes
- Fix joining restricted rooms over federation failing with signature verification error. (fix-federation-signature)
- Fixed the client space hierarchy endpoint returning a 500 "Space hierarchy is unreasonably large" error for cyclic space graphs (e.g. a space containing itself). Rooms are now deduplicated during traversal as required by the spec, and the traversal depth is bounded even when the client does not specify `max_depth`. (space-hierarchy-cycle)
- Fixed simplified sliding sync holding account data for up to 30 seconds, which made encryption setup and cross-signing resets appear to hang. (sss-account-data-longpoll)
- Fixed local invites and invite acceptances not being reflected in sync promptly. Contributed by @eleboucher (wake-local-member-sync)
- Fixed the deeplink redirect for deleting devices. Contributed by @koen (#1965)
- Fix status code for oauth registration. Contributed by @n00byking (#1984)
- Exempt m.room.create from auth_events check. Contributed by @eleboucher (#1987)
- Fixed `create` being returned as a supported prompt value regardless of if registration is enabled or not. Contributed by @ginger (#1994)
- Fixed high CPU usage when multiple clients from the same account were connected at once. Each sync woke the account's other sync loops, causing them to wake each other in a loop. (#2006)
- Fixed MSC4190 appservice device creation registering a random device ID instead of the requested one (and dropping the requested display name), which prevented encrypted mautrix bridges from starting on OIDC-enabled servers and leaked an orphan device on the bridge bot per startup attempt. (#2015)
- Deactivated users and appservice puppets are no longer counted by `/_continuwuity/local_user_count`. Contributed by @ginger. (#2040)
- Re-introduced admin room registration alerts that were accidentally removed in the OAuth2 update. (#2057)
- Appservices are now properly able to create devices for E2EE.
- Appservices may now specify both the unstable and stable `device_id` query parameters in a request. The stable parameter will take priority. Contributed by @ginger.
- Fixed `roomuserid_lastnotificationread` being aliased to the highlight count table, which clobbered highlight counts when setting a read marker. Contributed by @eleboucher
- Fixed freshly left room failing to sync.
- Fixed newly created rooms failing to sync properly in clients using legacy sync.
- Fixed newly joined rooms failing to sync their full state (including the room name) to clients using legacy sync.
- Fixed requests returning `500 Internal Server Error` when the header selected by `request_ip_source` is absent, duplicated, or malformed (for example Envoy omitting `X-Envoy-External-Address` on internal requests). The client IP now falls back to the connection peer address instead of failing the request. Contributed by @eleboucher
- Resolve alias service by correct name for auto-join. Contributed by @eleboucher
## Improved Documentation
- Updated an out-of-date statement about Oracle Linux release cadences. (#1999)
# Continuwuity 26.6.2 (2026-07-12)
## Bugfixes
Generated
+185 -174
View File
File diff suppressed because it is too large Load Diff
+3 -5
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 = "26.6.2"
version = "26.7.2"
[workspace.metadata.crane]
name = "conduwuit"
@@ -342,9 +342,8 @@ version = "1.1.1"
# Used for matrix spec type definitions and helpers
[workspace.dependencies.ruma]
# version = "0.14.1"
git = "https://github.com/ruma/ruma.git"
rev = "04d5d68841c3c8d71e5fe6f7899ccccad94274c3"
rev = "e7284c31da289f0a3b885191f5b2b6b307fa059f"
features = [
"appservice-api-c",
"client-api",
@@ -357,7 +356,6 @@ features = [
"compat-upload-signatures",
"compat-optional-txn-pdus",
"compat-get-3pids",
"unstable-msc2666",
"unstable-msc2867",
"unstable-msc2870",
"unstable-msc3061",
@@ -503,7 +501,7 @@ default-features = false
version = "0.1"
[workspace.dependencies.syn]
version = "2.0"
version = "3.0"
default-features = false
features = ["full", "extra-traits"]
-1
View File
@@ -1 +0,0 @@
Added support for the OAuth2 device authorization flow. Contributed by @ginger
-1
View File
@@ -1 +0,0 @@
Fixed requests returning `500 Internal Server Error` when the header selected by `request_ip_source` is absent, duplicated, or malformed (for example Envoy omitting `X-Envoy-External-Address` on internal requests). The client IP now falls back to the connection peer address instead of failing the request. Contributed by @eleboucher
-1
View File
@@ -1 +0,0 @@
Appservices are now properly able to create devices for E2EE.
-1
View File
@@ -1 +0,0 @@
Resolve alias service by correct name for auto-join. Contributed by @eleboucher
-1
View File
@@ -1 +0,0 @@
Fixed newly created rooms failing to sync properly in clients using legacy sync.
-1
View File
@@ -1 +0,0 @@
Fixed newly joined rooms failing to sync their full state (including the room name) to clients using legacy sync.
-1
View File
@@ -1 +0,0 @@
Fixed `roomuserid_lastnotificationread` being aliased to the highlight count table, which clobbered highlight counts when setting a read marker. Contributed by @eleboucher
-1
View File
@@ -1 +0,0 @@
Fixed freshly left room failing to sync.
-1
View File
@@ -1 +0,0 @@
Appservices may now specify both the unstable and stable `device_id` query parameters in a request. The stable parameter will take priority. Contributed by @ginger.
-1
View File
@@ -1 +0,0 @@
Fetch the joined member count once per event instead of once per notified user.
-1
View File
@@ -1 +0,0 @@
Fixed the deeplink redirect for deleting devices. Contributed by @koen
-1
View File
@@ -1 +0,0 @@
Fix status code for oauth registration. Contributed by @n00byking
-1
View File
@@ -1 +0,0 @@
Introduce `accepted_ip_sources` as a multiple options variant of `request_ip_source`, allowing for more advanced deployments and making fallbacks an explicit choice. Contributed by @Omar007
-1
View File
@@ -1 +0,0 @@
Exempt m.room.create from auth_events check. Contributed by @eleboucher
-1
View File
@@ -1 +0,0 @@
Fixed `create` being returned as a supported prompt value regardless of if registration is enabled or not. Contributed by @ginger
-1
View File
@@ -1 +0,0 @@
Updated an out-of-date statement about Oracle Linux release cadences.
-1
View File
@@ -1 +0,0 @@
Fixed high CPU usage when multiple clients from the same account were connected at once. Each sync woke the account's other sync loops, causing them to wake each other in a loop.
-1
View File
@@ -1 +0,0 @@
Fixed MSC4190 appservice device creation registering a random device ID instead of the requested one (and dropping the requested display name), which prevented encrypted mautrix bridges from starting on OIDC-enabled servers and leaked an orphan device on the bridge bot per startup attempt.
-1
View File
@@ -1 +0,0 @@
Deactivated users and appservice puppets are no longer counted by `/_continuwuity/local_user_count`. Contributed by @ginger.
@@ -1 +0,0 @@
Fix joining restricted rooms over federation failing with signature verification error.
+1
View File
@@ -0,0 +1 @@
**TODO - embargoed until next release** (maintainers see security issue 10 when writing rls notes). Contributed by @eleboucher.
@@ -1 +0,0 @@
Fixed the client space hierarchy endpoint returning a 500 "Space hierarchy is unreasonably large" error for cyclic space graphs (e.g. a space containing itself). Rooms are now deduplicated during traversal as required by the spec, and the traversal depth is bounded even when the client does not specify `max_depth`.
@@ -1 +0,0 @@
Fixed simplified sliding sync holding account data for up to 30 seconds, which made encryption setup and cross-signing resets appear to hang.
@@ -1 +0,0 @@
Fixed local invites and invite acceptances not being reflected in sync promptly. Contributed by @eleboucher
+4
View File
@@ -8,6 +8,10 @@ ## `!admin users create-user`
Create a new user
## `!admin users issue-token`
Issue an access token for a user. This command will not work on shadow users, such as appservice puppets or accounts imported from an identity provider
## `!admin users reset-password`
Reset user password
+1 -1
View File
@@ -3,7 +3,7 @@
use service::registration_tokens::TokenExpires;
impl crate::Context<'_> {
pub(super) async fn issue_token(&self, expires: super::TokenExpires) -> Result {
pub(super) async fn issue_registration_token(&self, expires: super::TokenExpires) -> Result {
let expires = {
if expires.immortal {
None
+1 -1
View File
@@ -10,7 +10,7 @@
pub enum TokenCommand {
/// Issue a new registration token
#[clap(name = "issue")]
IssueToken {
IssueRegistrationToken {
/// When this token will expire.
#[command(flatten)]
expires: TokenExpires,
+31 -14
View File
@@ -20,7 +20,7 @@
tag::{TagEvent, TagEventContent, TagInfo},
},
};
use service::users::{AccountStatus, HashedPassword};
use service::users::{AccountStatus, DeviceToken, HashedPassword};
use crate::{
get_room_info,
@@ -59,13 +59,42 @@ pub(super) async fn create_user(&self, username: String, password: Option<String
self.services
.users
.create_local_account(&user_id, Some(HashedPassword::new(password)?), None)
.create_local_account(
&user_id,
Some(HashedPassword::new(password)?),
None,
None,
None,
)
.await?;
self.write_str(&format!("Created user {user_id} with password `{password}`"))
.await
}
pub(super) async fn issue_access_token(&self, username: String, password: String) -> Result {
let user_id = parse_active_local_user_id(self.services, &username).await?;
let user_id = self
.services
.users
.check_password(&user_id, &password)
.await?;
let token = DeviceToken::new_random();
let device_id = self
.services
.users
.create_device(&user_id, None, Some(token.clone()), None, None)
.await?;
self.write_str(&format!(
"Created device `{device_id}` with access token `{}` for {user_id}",
token.into_token()
))
.await
}
pub(super) async fn deactivate(&self, no_leave_rooms: bool, user_id: String) -> Result {
// Validate user id
let user_id = parse_local_user_id(self.services, &user_id)?;
@@ -102,7 +131,6 @@ pub(super) async fn deactivate(&self, no_leave_rooms: bool, user_id: String) ->
}
pub(super) async fn suspend(&self, user_id: String) -> Result {
self.bail_restricted()?;
let user_id = parse_active_local_user_id(self.services, &user_id).await?;
if user_id == self.services.globals.server_user {
@@ -123,7 +151,6 @@ pub(super) async fn suspend(&self, user_id: String) -> Result {
}
pub(super) async fn unsuspend(&self, user_id: String) -> Result {
self.bail_restricted()?;
let user_id = parse_active_local_user_id(self.services, &user_id).await?;
if user_id == self.services.globals.server_user {
@@ -935,7 +962,6 @@ pub(super) async fn force_leave_remote_room(
}
pub(super) async fn lock(&self, user_id: String) -> Result {
self.bail_restricted()?;
let user_id = parse_active_local_user_id(self.services, &user_id).await?;
if user_id == self.services.globals.server_user {
@@ -956,7 +982,6 @@ pub(super) async fn lock(&self, user_id: String) -> Result {
}
pub(super) async fn unlock(&self, user_id: String) -> Result {
self.bail_restricted()?;
let user_id = parse_active_local_user_id(self.services, &user_id).await?;
self.services.users.unlock_account(&user_id).await;
@@ -966,7 +991,6 @@ pub(super) async fn unlock(&self, user_id: String) -> Result {
}
pub(super) async fn logout(&self, user_id: String) -> Result {
self.bail_restricted()?;
let user_id = parse_active_local_user_id(self.services, &user_id).await?;
if user_id == self.services.globals.server_user {
@@ -992,7 +1016,6 @@ pub(super) async fn logout(&self, user_id: String) -> Result {
}
pub(super) async fn disable_login(&self, user_id: String) -> Result {
self.bail_restricted()?;
let user_id = parse_active_local_user_id(self.services, &user_id).await?;
if user_id == self.services.globals.server_user {
@@ -1011,7 +1034,6 @@ pub(super) async fn disable_login(&self, user_id: String) -> Result {
}
pub(super) async fn enable_login(&self, user_id: String) -> Result {
self.bail_restricted()?;
let user_id = parse_active_local_user_id(self.services, &user_id).await?;
self.services.users.enable_login(&user_id);
@@ -1020,7 +1042,6 @@ pub(super) async fn enable_login(&self, user_id: String) -> Result {
}
pub(super) async fn get_email(&self, user_id: String) -> Result {
self.bail_restricted()?;
let user_id = parse_local_user_id(self.services, &user_id)?;
match self
@@ -1039,8 +1060,6 @@ pub(super) async fn get_email(&self, user_id: String) -> Result {
}
pub(super) async fn get_user_by_email(&self, email: String) -> Result {
self.bail_restricted()?;
let Ok(email) = Address::try_from(email) else {
return Err!("Invalid email address.");
};
@@ -1063,8 +1082,6 @@ pub(super) async fn get_user_by_email(&self, email: String) -> Result {
}
pub(super) async fn change_email(&self, user_id: String, email: Option<String>) -> Result {
self.bail_restricted()?;
let user_id = parse_local_user_id(self.services, &user_id)?;
let Ok(new_email) = email.map(Address::try_from).transpose() else {
return Err!("Invalid email address.");
+9
View File
@@ -18,6 +18,15 @@ pub enum UserCommand {
password: Option<String>,
},
/// Issue an access token for a user. This command will not work on
/// shadow users, such as appservice puppets or accounts imported from
/// an identity provider.
#[clap(name = "issue-token")]
IssueAccessToken {
username: String,
password: String,
},
/// Reset user password
ResetPassword {
/// Log out existing sessions
-1
View File
@@ -25,7 +25,6 @@
};
use service::{mailer::messages, uiaa::UiaaInitiator, users::HashedPassword};
use super::DEVICE_ID_LENGTH;
use crate::{Ruma, router::ClientIdentity};
pub(crate) mod register;
+11 -14
View File
@@ -1,10 +1,7 @@
use std::collections::HashMap;
use axum::extract::State;
use conduwuit::{
Err, Result, debug_info, info,
utils::{self},
};
use conduwuit::{Err, Result, debug_info, info};
use conduwuit_service::Services;
use futures::StreamExt;
use lettre::{Address, message::Mailbox};
@@ -24,7 +21,6 @@
users::{DeviceToken, HashedPassword},
};
use super::DEVICE_ID_LENGTH;
use crate::{Ruma, client_ip::ClientIp};
/// # `POST /_matrix/client/v3/register`
@@ -99,7 +95,13 @@ pub(crate) async fn register_route(
services
.users
.create_local_account(&user_id, Some(password), identity.email)
.create_local_account(
&user_id,
Some(password),
identity.email,
Some(&client),
body.initial_device_display_name.as_deref(),
)
.await?;
user_id
@@ -114,26 +116,21 @@ pub(crate) async fn register_route(
)));
}
// Generate new device id if the user didn't specify one
let device_id = body
.device_id
.clone()
.unwrap_or_else(|| utils::random_string(DEVICE_ID_LENGTH).into());
// Generate new token for the device
let new_token = DeviceToken::new_random();
// Create device for this account
services
let device_id = services
.users
.create_device(
&user_id,
&device_id,
body.device_id.clone(),
Some(new_token.clone()),
body.initial_device_display_name.clone(),
Some(client.to_string()),
)
.await?;
(Some(new_token), Some(device_id))
} else {
// Don't create a device for inhibited logins
+1 -1
View File
@@ -88,7 +88,7 @@ pub(crate) async fn update_device_route(
.users
.create_device(
sender_user,
&body.device_id,
Some(body.device_id.clone()),
None,
body.display_name.clone(),
Some(client.to_string()),
-3
View File
@@ -90,8 +90,5 @@
pub(super) use voip::*;
pub(super) use well_known::*;
/// generated device ID length
const DEVICE_ID_LENGTH: usize = 10;
/// generated user access token length
const TOKEN_LENGTH: usize = 32;
+33 -3
View File
@@ -1,7 +1,7 @@
use axum::extract::State;
use conduwuit::{Err, Result};
use futures::StreamExt;
use ruma::api::client::membership::mutual_rooms;
use ruma::{OwnedRoomId, api::client::membership::mutual_rooms};
use crate::Ruma;
@@ -11,14 +11,14 @@
///
/// An implementation of [MSC2666](https://github.com/matrix-org/matrix-spec-proposals/pull/2666)
#[tracing::instrument(skip_all, name = "mutual_rooms", level = "info")]
pub(crate) async fn get_mutual_rooms_route(
pub(crate) async fn get_mutual_rooms_unstable_route(
State(services): State<crate::State>,
body: Ruma<mutual_rooms::unstable::Request>,
) -> Result<mutual_rooms::unstable::Response> {
let sender_user = body.identity.expect_sender_user()?;
if sender_user == body.user_id {
return Err!(Request(Unknown("You cannot request rooms in common with yourself.")));
return Err!(Request(InvalidParam("You cannot request rooms in common with yourself.")));
}
let mutual_rooms = services
@@ -30,3 +30,33 @@ pub(crate) async fn get_mutual_rooms_route(
Ok(mutual_rooms::unstable::Response::new(mutual_rooms))
}
/// # `GET /_matrix/client/v1/mutual_rooms`
///
/// Gets all the rooms the sender shares with the specified user.
#[tracing::instrument(skip_all, name = "mutual_rooms", level = "info")]
pub(crate) async fn get_mutual_rooms_route(
State(services): State<crate::State>,
body: Ruma<mutual_rooms::v1::Request>,
) -> Result<mutual_rooms::v1::Response> {
let sender_user = body.identity.expect_sender_user()?;
if sender_user == body.user_id {
return Err!(Request(InvalidParam("You cannot request rooms in common with yourself.")));
}
let mutual_rooms: Vec<OwnedRoomId> = services
.rooms
.state_cache
.get_shared_rooms(sender_user, &body.user_id)
.collect()
.await;
Ok(mutual_rooms::v1::Response::new(
mutual_rooms
.len()
.try_into()
.expect("user should be in fewer than 9.1 quadrillion rooms"),
mutual_rooms,
))
}
+11 -16
View File
@@ -3,7 +3,7 @@
use axum::extract::State;
use conduwuit::{
Err, Result, debug, err, info,
utils::{self, ReadyExt, stream::BroadbandExt},
utils::{ReadyExt, stream::BroadbandExt},
warn,
};
use conduwuit_service::Services;
@@ -30,7 +30,6 @@
};
use service::users::DeviceToken;
use super::DEVICE_ID_LENGTH;
use crate::{Ruma, client_ip::ClientIp};
/// # `GET /_matrix/client/v3/login`
@@ -189,43 +188,39 @@ pub(crate) async fn login_route(
},
};
// Generate new device id if the user didn't specify one
let device_id = body
.device_id
.clone()
.unwrap_or_else(|| utils::random_string(DEVICE_ID_LENGTH).into());
// Generate a new token for the device
let token = DeviceToken::new_random();
// Determine if device_id was provided and exists in the db for this user
let device_exists = if body.device_id.is_some() {
let existing_device_id = if let Some(device_id) = &body.device_id {
services
.users
.all_device_ids(&user_id)
.ready_any(|v| v == device_id)
.ready_find(|v| v == device_id)
.await
} else {
false
None
};
if device_exists {
let device_id = if let Some(existing_device_id) = existing_device_id {
services
.users
.set_token(&user_id, &device_id, token.clone())
.set_token(&user_id, &existing_device_id, token.clone())
.await?;
existing_device_id
} else {
services
.users
.create_device(
&user_id,
&device_id,
body.device_id.clone(),
Some(token.clone()),
body.initial_device_display_name.clone(),
Some(client.to_string()),
)
.await?;
}
.await?
};
// send client well-known if specified so the client knows to reconfigure itself
let client_discovery_info: Option<DiscoveryInfo> = services
+112 -23
View File
@@ -1,6 +1,6 @@
use std::{
cmp::{self, Ordering},
collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
collections::{BTreeMap, BTreeSet, HashMap, HashSet},
ops::Deref,
time::Duration,
};
@@ -138,6 +138,13 @@ pub(crate) async fn sync_events_v5_route(
let (all_joined_rooms, all_invited_rooms, all_knocked_rooms) =
join3(all_joined_rooms, all_invited_rooms, all_knocked_rooms).await;
let allowed_rooms: BTreeSet<OwnedRoomId> = all_joined_rooms
.iter()
.chain(all_invited_rooms.iter())
.chain(all_knocked_rooms.iter())
.cloned()
.collect();
let all_joined_rooms = all_joined_rooms.iter().map(AsRef::as_ref);
let all_invited_rooms = all_invited_rooms.iter().map(AsRef::as_ref);
let all_knocked_rooms = all_knocked_rooms.iter().map(AsRef::as_ref);
@@ -190,13 +197,14 @@ pub(crate) async fn sync_events_v5_route(
)
.await;
fetch_subscriptions(services, sync_info, &known_rooms, &mut todo_rooms).await;
fetch_subscriptions(services, sync_info, &known_rooms, &allowed_rooms, &mut todo_rooms).await;
response.rooms = process_rooms(
services,
sender_user,
next_batch,
all_invited_rooms.clone(),
all_knocked_rooms.clone(),
&todo_rooms,
&mut response,
&body,
@@ -214,6 +222,7 @@ pub(crate) async fn sync_events_v5_route(
let no_room_data = response.rooms.iter().all(|(id, r)| {
r.timeline.is_empty()
&& r.required_state.is_empty()
&& r.invite_state.is_none()
&& !response.extensions.receipts.rooms.contains_key(id)
});
@@ -247,10 +256,17 @@ async fn fetch_subscriptions(
services: &Services,
(sender_user, sender_device, globalsince, body): SyncInfo<'_>,
known_rooms: &KnownRooms,
allowed_rooms: &BTreeSet<OwnedRoomId>,
todo_rooms: &mut TodoRooms,
) {
let mut known_subscription_rooms = BTreeSet::new();
for (room_id, room) in &body.room_subscriptions {
// Silently ignore subscriptions to rooms the user is not a member of
// (joined or invited).
if !allowed_rooms.contains(room_id) {
continue;
}
let not_exists = services.rooms.metadata.exists(room_id).eq(&false);
let is_disabled = services.rooms.metadata.is_disabled(room_id);
@@ -409,11 +425,13 @@ async fn handle_lists<'a, Rooms, AllRooms>(
BTreeMap::default()
}
#[allow(clippy::too_many_arguments)]
async fn process_rooms<'a, Rooms>(
services: &Services,
sender_user: &UserId,
next_batch: u64,
all_invited_rooms: Rooms,
all_knocked_rooms: Rooms,
todo_rooms: &TodoRooms,
response: &mut sync_events::v5::Response,
body: &sync_events::v5::Request,
@@ -426,38 +444,101 @@ async fn process_rooms<'a, Rooms>(
let roomsincecount = PduCount::Normal(*roomsince);
let mut timestamp: Option<_> = None;
let mut invite_state = None;
let (timeline_pdus, limited);
let new_room_id: &RoomId = (*room_id).as_ref();
if all_invited_rooms.clone().any(is_equal_to!(new_room_id)) {
let Ok(invite_count) = services
.rooms
.state_cache
.get_invite_count(room_id, sender_user)
.await
else {
continue;
};
if *roomsince >= invite_count {
continue;
}
// TODO: figure out a timestamp we can use for remote invites
invite_state = services
let invite_state = services
.rooms
.state_cache
.invite_state(sender_user, room_id)
.await
.ok();
(timeline_pdus, limited) = (VecDeque::new(), true);
} else {
TimelinePdus { pdus: timeline_pdus, limited } = match load_timeline(
services,
sender_user,
room_id,
Some(roomsincecount),
Some(PduCount::from(next_batch)),
*timeline_limit,
)
.await
{
| Ok(value) => value,
| Err(err) => {
warn!("Encountered missing timeline in {}, error {}", room_id, err);
continue;
},
};
rooms.insert(
room_id.clone(),
assign!(sync_events::v5::response::Room::new(), {
initial: Some(roomsince == &0),
invite_state,
limited: true,
}),
);
continue;
}
if all_knocked_rooms.clone().any(is_equal_to!(new_room_id)) {
let Ok(knock_count) = services
.rooms
.state_cache
.get_knock_count(room_id, sender_user)
.await
else {
continue;
};
if *roomsince >= knock_count {
continue;
}
let Ok(knock_state) = services
.rooms
.state_cache
.knock_state(sender_user, room_id)
.await
else {
continue;
};
rooms.insert(
room_id.clone(),
assign!(sync_events::v5::response::Room::new(), {
initial: Some(roomsince == &0),
invite_state: Some(knock_state),
limited: true,
}),
);
continue;
}
if !services
.rooms
.state_cache
.is_joined(sender_user, room_id)
.await
{
continue;
}
TimelinePdus { pdus: timeline_pdus, limited } = match load_timeline(
services,
sender_user,
room_id,
Some(roomsincecount),
Some(PduCount::from(next_batch)),
*timeline_limit,
)
.await
{
| Ok(value) => value,
| Err(err) => {
warn!("Encountered missing timeline in {}, error {}", room_id, err);
continue;
},
};
if body.extensions.account_data.enabled == Some(true) {
response.extensions.account_data.rooms.insert(
room_id.to_owned(),
@@ -641,7 +722,6 @@ async fn process_rooms<'a, Rooms>(
},
initial: Some(roomsince == &0),
is_dm: None,
invite_state,
unread_notifications: assign!(UnreadNotificationsCount::new(), {
highlight_count: Some(
services
@@ -769,6 +849,15 @@ async fn collect_typing_events(
let mut typing_response = sync_events::v5::response::Typing::default();
for (room_id, (_, _, roomsince)) in todo_rooms {
if !services
.rooms
.state_cache
.is_joined(sender_user, room_id)
.await
{
continue;
}
if services.rooms.typing.last_typing_update(room_id).await? <= *roomsince {
continue;
}
+1
View File
@@ -177,6 +177,7 @@ pub fn build(router: Router<State>, state: State) -> Router<State> {
.ruma_route(&client::get_relating_events_with_rel_type_route)
.ruma_route(&client::get_relating_events_route)
.ruma_route(&client::get_hierarchy_route)
.ruma_route(&client::get_mutual_rooms_unstable_route)
.ruma_route(&client::get_mutual_rooms_route)
.ruma_route(&client::get_room_summary)
.ruma_route(&client::get_suspended_status)
+1
View File
@@ -34,6 +34,7 @@ pub fn unstable_features() -> BTreeMap<String, bool> {
// query mutual rooms (https://github.com/matrix-org/matrix-spec-proposals/pull/2666)
// Expected for spec v1.19
("uk.half-shot.msc2666.query_mutual_rooms".to_owned(), true),
("uk.half-shot.msc2666.query_mutual_rooms.stable".to_owned(), true),
// Simplified Sliding sync (https://github.com/matrix-org/matrix-spec-proposals/pull/4186)
// Expected for spec v1.19
("org.matrix.simplified_msc3575".to_owned(), true),
+4 -1
View File
@@ -35,7 +35,10 @@ systemd-units = { unit-name = "conduwuit", start = false, unit-scripts = "../../
assets = [
["../../pkg/debian/README.md", "usr/share/doc/conduwuit/README.Debian", "644"],
["../../README.md", "usr/share/doc/conduwuit/", "644"],
["../../target/release/conduwuit", "usr/bin/conduwuit", "755"],
# cargo-deb only treats the exact prefix "target/release/" as the magic
# path that resolves to the real build dir (e.g. target/<triple>/release
# when cross-compiling); a ../../ prefix would be read as a literal path
["target/release/conduwuit", "usr/bin/conduwuit", "755"],
["../../conduwuit-example.toml", "etc/conduwuit/conduwuit.toml", "640"],
]
+14 -14
View File
@@ -547,7 +547,7 @@ async fn create_session(
.iter()
.find_map(|scope| {
if let Scope::Device(device_id) = scope {
Some(device_id)
Some(device_id.to_owned())
} else {
None
}
@@ -557,7 +557,7 @@ async fn create_session(
if self
.services
.users
.get_device_metadata(&authorizing_user, device_id)
.get_device_metadata(&authorizing_user, &device_id)
.await
.is_ok()
{
@@ -567,11 +567,11 @@ async fn create_session(
));
}
self.services
let device_id = self.services
.users
.create_device(
&authorizing_user,
device_id,
Some(device_id),
Some(access_token.clone()),
client_name,
None,
@@ -581,8 +581,16 @@ async fn create_session(
// failure during authentication, which should(?) be impossible(?)
.expect("failed to create device");
info!(
?client_id,
?authorizing_user,
?device_id,
?requested_scopes,
"Created new oauth session"
);
self.db.userdeviceid_oauthsessioninfo.put(
(&authorizing_user, device_id),
(&authorizing_user, &device_id),
Json(SessionInfo {
client_id: client_id.clone(),
current_refresh_token: refresh_token.clone(),
@@ -595,18 +603,10 @@ async fn create_session(
Json(RefreshTokenInfo {
client_id: client_id.clone(),
user_id: authorizing_user.clone(),
device_id: device_id.to_owned(),
device_id,
}),
);
info!(
?client_id,
?authorizing_user,
?device_id,
?requested_scopes,
"Created new oauth session"
);
Ok(TokenResponse {
access_token: access_token.into_token(),
token_type: TokenType::Bearer,
+1 -1
View File
@@ -367,7 +367,7 @@ pub async fn complete_session(
// Create a new shadow user
self.services
.users
.create_local_account(&user_id, None, None)
.create_local_account(&user_id, None, None, None, None)
.await
.map_err(|err| {
error!("Failed to create a shadow user for {user_id}: {err}");
@@ -7,7 +7,7 @@
Err, Event, Result, debug, debug_error, debug_warn, defer, matrix::PartialPdu, trace,
utils::time::jitter,
};
use futures::{FutureExt, StreamExt, future::try_join3};
use futures::{FutureExt, StreamExt, future::try_join4};
use ruma::{CanonicalJsonValue, EventId, RoomId, ServerName, UserId};
use tokio::sync::mpsc;
@@ -69,9 +69,13 @@ pub async fn handle_incoming_pdu<'a>(
.and_then(|s| UserId::parse(s).ok())
.is_some_and(|u| self.services.globals.user_is_local(&u));
let (room_exists, is_disabled, ()) = try_join3(
let (room_exists, is_disabled, is_resident, ()) = try_join4(
self.services.metadata.exists(room_id).map(Ok),
self.services.metadata.is_disabled(room_id).map(Ok),
self.services
.state_cache
.server_in_room(self.services.globals.server_name(), room_id)
.map(Ok),
self.acl_check(origin, room_id),
)
.await
@@ -85,12 +89,20 @@ pub async fn handle_incoming_pdu<'a>(
)));
}
// If the room doesn't exist (we don't have the create event), there's nothing
// we can do.
if !room_exists {
return Err!(Request(NotFound("Room is unknown to this server")));
}
// If the room does exist, but we aren't a resident of it, we might be
// interested in an out-of-band membership (for example, an inviter rescinding
// their invite).
if !is_resident {
if is_interesting_member_event {
// TODO: handle interesting membership events where we aren't in
// the room
}
return Err!(Request(NotFound("Room is unknown to this server")));
return Err!(Request(NotFound("This server does not have any members this room")));
}
// Fetch create event
@@ -104,24 +104,24 @@ pub(super) async fn upgrade_outlier_to_timeline_pdu(
// Determine whether this PDU should be soft-failed.
// If the auth check failed, invariably yes. Otherwise, only if the user isn't
// allowed to redact the target event (if any).
let mut should_soft_fail =
match (passes_current_state, incoming_pdu.redacts_id(&room_version_rules)) {
| (false, _) => true,
| (true, None) => false,
| (true, Some(redact_id)) => self
.services
let redaction_permitted =
if let Some(redacted_id) = incoming_pdu.redacts_id(&room_version_rules) {
self.services
.state_accessor
.user_can_redact(&redact_id, incoming_pdu.sender(), room_id, true)
.user_can_redact(&redacted_id, incoming_pdu.sender(), room_id, true)
.await
.is_ok_and(is_true!()),
.is_ok_and(is_true!())
} else {
true
};
let mut should_soft_fail = !redaction_permitted || !passes_current_state;
if !should_soft_fail {
// Now we can perform check 7, which is ensuring the event passes policy server
// checks.
// We explicitly only do this if we aren't already going to soft-fail the event,
// since the policy server refusing this event also soft-fails it.
debug!(event_id = %incoming_pdu.event_id, "Checking policy server for event");
debug!("Checking policy server for event");
should_soft_fail = !self
.policy_server_check_7(&incoming_pdu, &mut val, &room_version_rules)
.await
@@ -153,6 +153,12 @@ pub(super) async fn upgrade_outlier_to_timeline_pdu(
should_soft_fail = true;
}
}
} else {
debug!(
%redaction_permitted,
%passes_current_state,
"Intending to soft-fail event (skipping further PDU checks)"
);
}
// The PDU has now passed all checks! We can now promote it (or soft-fail it if
+4 -1
View File
@@ -1,4 +1,4 @@
use conduwuit::{Err, Result, matrix::Event, pdu::PartialPdu};
use conduwuit::{Err, Result, matrix::Event, pdu::PartialPdu, trace};
use ruma::{
EventId, RoomId, UserId,
events::{
@@ -46,6 +46,7 @@ pub async fn user_can_redact(
let power_levels = self.get_room_power_levels(room_id).await;
if power_levels.user_can_redact_event_of_other(sender) {
trace!(%sender, "Sender is allowed to redact other users' events");
return Ok(true);
}
@@ -59,10 +60,12 @@ pub async fn user_can_redact(
},
| _ => false,
};
trace!(%is_own_event, "User can redact own event");
return Ok(is_own_event);
}
trace!("User is not permitted to redact their own event nor others' events");
Ok(false)
}
+4
View File
@@ -2,6 +2,7 @@
use ruma::{
CanonicalJsonObject, CanonicalJsonValue, OwnedServerName, OwnedServerSigningKeyId,
events::room::policy::POLICY_SERVER_ED25519_SIGNING_KEY_ID,
room_version_rules::SignaturesRules,
signatures::{VerificationError, required_server_signatures_to_verify_event},
};
@@ -27,6 +28,9 @@ pub(super) fn required_keys(
.cloned()
.map(TryInto::try_into)
.filter_map(Result::ok)
.filter(|key_id: &OwnedServerSigningKeyId| {
key_id.as_str() != POLICY_SERVER_ED25519_SIGNING_KEY_ID
})
.for_each(|key_id| entry.push(key_id));
}
+22 -1
View File
@@ -1,4 +1,7 @@
use std::time::{Duration, SystemTime};
use std::{
net::IpAddr,
time::{Duration, SystemTime},
};
use conduwuit::{
Err, Result, debug_error, debug_warn, err, error, info, trace,
@@ -136,6 +139,8 @@ pub async fn create_local_account(
user_id: &UserId,
password: Option<HashedPassword>,
email: Option<Address>,
client: Option<&IpAddr>,
device_name: Option<&str>,
) -> Result<()> {
self.create_shadow_account(user_id).await?;
@@ -143,6 +148,22 @@ pub async fn create_local_account(
self.convert_to_local_account(user_id, password).await?;
}
if let Some(client) = client {
let notice = if let Some(device_name) = device_name {
format!(
"New user \"{user_id}\" registered on this server from IP {client} and \
device display name \"{device_name}\".",
)
} else {
format!("New user \"{user_id}\" registered on this server from IP {client}.")
};
info!("{notice}");
if self.services.config.admin_room_notices {
self.services.admin.notice(&notice).await;
}
}
// Set an initial display name
{
let mut displayname = user_id.localpart().to_owned();
+1 -1
View File
@@ -40,7 +40,7 @@ pub async fn set_dehydrated_device(&self, user_id: &UserId, request: Request) ->
self.create_device(
user_id,
&request.device_id,
Some(request.device_id.clone()),
None,
request.initial_device_display_name.clone(),
None,
+13 -6
View File
@@ -42,32 +42,39 @@ pub fn into_token(self) -> String { self.token }
impl super::Service {
/// Adds a new device to a user.
///
/// If no `device_id` is provided, a random one will be generated.
///
/// If no `token` is provided, the device will not be able to be logged
/// into.
pub async fn create_device(
&self,
user_id: &UserId,
device_id: &DeviceId,
device_id: Option<OwnedDeviceId>,
token: Option<DeviceToken>,
initial_device_display_name: Option<String>,
client_ip: Option<String>,
) -> Result<()> {
) -> Result<OwnedDeviceId> {
const DEVICE_ID_LENGTH: usize = 10;
self.status(user_id).await.ensure_active()?;
let key = (user_id, device_id);
let mut device = Device::new(device_id.into());
let device_id =
device_id.unwrap_or_else(|| utils::random_string(DEVICE_ID_LENGTH).into());
let mut device = Device::new(device_id.clone());
device.display_name = initial_device_display_name;
device.last_seen_ip = client_ip;
device.last_seen_ts = Some(MilliSecondsSinceUnixEpoch::now());
let key = (user_id, &device_id);
increment(&self.db.userid_devicelistversion, user_id.as_bytes());
self.db.userdeviceid_metadata.put(key, Json(device));
if let Some(token) = token {
self.set_token(user_id, device_id, token).await?;
self.set_token(user_id, &device_id, token).await?;
}
Ok(())
Ok(device_id)
}
/// Removes a device from a user.
+3 -1
View File
@@ -70,6 +70,7 @@ enum AccountBody {
email_requirement: EmailRequirement,
email: Option<String>,
devices: Vec<DeviceCard>,
dehydrated_device_id: Option<OwnedDeviceId>,
},
Locked,
}
@@ -132,7 +133,8 @@ async fn get_account(
oidc_enabled: services.oidc.enabled(),
email_requirement,
email,
devices: device_cards
devices: device_cards,
dehydrated_device_id,
}))
}
+18 -5
View File
@@ -1,4 +1,4 @@
use std::{collections::BTreeMap, time::SystemTime};
use std::{collections::BTreeMap, net::IpAddr, time::SystemTime};
use axum::{
Extension, Router,
@@ -6,6 +6,7 @@
response::{Redirect, Response},
routing::{get, on},
};
use conduwuit_api::client_ip::ClientIp;
use conduwuit_core::{config::TermsDocument, warn};
use conduwuit_service::{
mailer::messages,
@@ -116,6 +117,7 @@ struct CompletedRegistration {
async fn route_register(
State(services): State<crate::State>,
ClientIp(client): ClientIp, // NOTE: Required for metadata.
Extension(context): Extension<TemplateContext>,
session_store: Session,
Expect(Query(query)): Expect<Query<RegisterQuery>>,
@@ -144,6 +146,7 @@ async fn route_register(
session_store,
form,
query.next.clone(),
&client,
)
.boxed()
.await?
@@ -276,6 +279,7 @@ struct RegisterEmailValidateQuery {
async fn get_register_email_validate(
State(services): State<crate::State>,
ClientIp(client): ClientIp, // NOTE: Required for metadata.
Extension(context): Extension<TemplateContext>,
session_store: Session,
Expect(Query(RegisterEmailValidateQuery {
@@ -303,8 +307,14 @@ async fn get_register_email_validate(
let email = session.consume();
response!(
complete_registration(&services, session_store, completed_registration, Some(email))
.await?
complete_registration(
&services,
session_store,
completed_registration,
Some(email),
&client
)
.await?
)
}
@@ -314,6 +324,7 @@ async fn begin_registration(
session_store: Session,
form: RegistrationForm,
next: Option<LoginTarget>,
client: &IpAddr,
) -> Result<Result<Response, ValidationErrors>> {
let open_registration = services
.config
@@ -496,7 +507,8 @@ async fn begin_registration(
} else {
// If email isn't required we can immediately complete registration
Ok(response!(
complete_registration(services, session_store, completed_registration, None).await?
complete_registration(services, session_store, completed_registration, None, client)
.await?
))
}
}
@@ -511,10 +523,11 @@ async fn complete_registration(
next,
}: CompletedRegistration,
email: Option<Address>,
client: &IpAddr,
) -> Result<Redirect> {
services
.users
.create_local_account(&user_id, Some(password_hash), email)
.create_local_account(&user_id, Some(password_hash), email, Some(client), None)
.await?;
if let Some(registration_token) = registration_token {
+16 -2
View File
@@ -67,12 +67,15 @@ pub(super) async fn for_local_user(services: &Services, user_id: &UserId) -> Sel
pub(super) fn for_device(
client_metadata: Option<&ClientMetadata>,
display_name: Option<&str>,
dehydrated: bool,
) -> Self {
let avatar_src = client_metadata
.and_then(|metadata| metadata.logo_uri.as_ref())
.map(|uri| uri.as_str().to_owned());
let avatar_type = if let Some(avatar_src) = avatar_src {
let avatar_type = if dehydrated {
AvatarType::Initial('⊡')
} else if let Some(avatar_src) = avatar_src {
AvatarType::Image(avatar_src)
} else if let Some(initial) = display_name.and_then(|name| name.chars().next()) {
if client_metadata.is_some() {
@@ -130,6 +133,7 @@ pub(super) struct DeviceCard {
pub last_active: String,
pub oauth_metadata: Option<ClientMetadata>,
pub style: DeviceCardStyle,
pub dehydrated: bool,
}
impl HtmlSafe for DeviceCard {}
@@ -163,12 +167,21 @@ pub(super) async fn for_device(
}
.await;
let dehydrated_device_id = services
.users
.get_dehydrated_device(user_id)
.await
.ok()
.map(|device| device.device_id);
let dehydrated = dehydrated_device_id.as_ref() == Some(&device.device_id);
let display_name = oauth_metadata
.as_ref()
.and_then(|metadata| metadata.client_name.clone())
.or_else(|| device.display_name.clone());
let avatar = Avatar::for_device(oauth_metadata.as_ref(), display_name.as_deref());
let avatar =
Avatar::for_device(oauth_metadata.as_ref(), display_name.as_deref(), dehydrated);
let last_active = device.last_seen_ts.map_or_else(
|| "unknown".to_owned(),
@@ -190,6 +203,7 @@ pub(super) async fn for_device(
last_active,
oauth_metadata,
style,
dehydrated,
}
}
}
@@ -1,37 +1,41 @@
<div class="card">
{{ avatar }}
<div class="info">
<div class="name">
<span>
{% if let Some(display_name) = display_name %}
{{ display_name }}
{% else %}
Unknown device
{% endif %}
</span>
{% if style == DeviceCardStyle::Detailed %}
<span class="id">
<span class="mobile-hidden">•</span>
<ul class="bullet-separated">
<li>{{ device_id }}</li>
<li>
{% if let Some(metadata) = oauth_metadata %}
<a href="{{ metadata.client_uri }}">Client website</a>
{% else %}
legacy
{% endif %}
</li>
</span>
{% if dehydrated %}
<div class="name">Dehydrated device</div>
{% else %}
<div class="name">
<span>
{% if let Some(display_name) = display_name %}
{{ display_name }}
{% else %}
Unknown device
{% endif %}
</span>
{% if style == DeviceCardStyle::Detailed %}
<span class="id">
<span class="mobile-hidden">•</span>
<ul class="bullet-separated">
<li>{{ device_id }}</li>
<li>
{% if let Some(metadata) = oauth_metadata %}
<a href="{{ metadata.client_uri }}">Client website</a>
{% else %}
legacy
{% endif %}
</li>
</span>
</span>
{% endif %}
</div>
<div>
Last active: {{ last_active }}
</div>
<div>
{% if style != DeviceCardStyle::Detailed %}
<a href="{{ crate::ROUTE_PREFIX }}/account/device/{{ device_id }}/">Details</a>
{% endif %}
</div>
<div>
Last active: {{ last_active }}
</div>
<div>
{% if style != DeviceCardStyle::Detailed %}
<a href="{{ crate::ROUTE_PREFIX }}/account/device/{{ device_id }}/">Details</a>
</div>
{% endif %}
</div>
</div>
</div>
+4 -1
View File
@@ -9,7 +9,7 @@ Your account
<h1>Manage your account</h1>
{{ user_card }}
{% match body %}
{% when AccountBody::Unlocked { suspended, email_requirement, email, devices, oidc_enabled } %}
{% when AccountBody::Unlocked { suspended, email_requirement, email, devices, oidc_enabled, dehydrated_device_id } %}
{% if suspended %}
<p class="card danger">
⚠️ Your account has been suspended by your homeserver's administrator.
@@ -52,6 +52,9 @@ Your account
and sign in to start chatting on Matrix.
</span>
{% endfor %}
{% if let Some(dehydrated_device_id) = dehydrated_device_id %}
<small>⊡ Your account has a dehydrated device. <a href="device/{{ dehydrated_device_id | urlencode_strict }}/">View details</a></small>
{% endif %}
</div>
</details>
</section>
@@ -27,6 +27,8 @@ Device information
{% if let Some((_, session_info)) = client_metadata %}
This device has permission to:
{{ ClientScopes { scopes: session_info.scopes.clone() } }}
{% else if device_card.dehydrated %}
This is your dehydrated device. It saves encryption keys for you while you're offline.
{% else %}
This device can access and control all features of your Matrix account.
<br>
+6 -2
View File
@@ -4,6 +4,7 @@
time::{Duration, SystemTime},
};
use askama::filters::urlencode_strict;
use axum::{
extract::FromRequestParts,
http::request::Parts,
@@ -63,8 +64,11 @@ pub(crate) fn target_path(&self) -> String {
| Self::DeviceCode(code) =>
format!("oauth2/grant/device_code?{}", serde_urlencoded::to_string(code).unwrap())
.into(),
| Self::DeviceInfo(path) => format!("account/device/{}/", path.device).into(),
| Self::RemoveDevice(path) => format!("account/device/{}/remove", path.device).into(),
| Self::DeviceInfo(path) =>
format!("account/device/{}/", urlencode_strict(&path.device).unwrap()).into(),
| Self::RemoveDevice(path) =>
format!("account/device/{}/remove", urlencode_strict(&path.device).unwrap())
.into(),
};
format!("{ROUTE_PREFIX}/{path}")