mirror of
https://forgejo.ellis.link/continuwuation/continuwuity.git
synced 2026-05-26 20:49:55 +00:00
Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0177810e2d | |||
| a7f51d460e | |||
| f6ce156acc | |||
| 8260856638 | |||
| a7bdcc9ab9 | |||
| 854901d79a | |||
| d899c6e17a | |||
| 9a0dd36b8d | |||
| 5bdaf478c4 | |||
| 666adb705c | |||
| 203d55d2f7 | |||
| 7b70cdba75 | |||
| 7ff448b325 | |||
| e8a06f51b7 | |||
| 900d41bcef | |||
| f4cbc3270d | |||
| 9a623738cd | |||
| 3b730233bc | |||
| f9497606f8 | |||
| ec52428e06 | |||
| 4426437130 | |||
| 585f0e1104 | |||
| 23ecec65a9 | |||
| 38eb184b4c | |||
| 346e58fd62 | |||
| cf10a1edaa | |||
| 677c407755 | |||
| e3ae714248 | |||
| fb9a2aa4d6 | |||
| 5164822090 | |||
| 6b013bcf60 | |||
| 05a49ceb60 | |||
| 728c5828ba | |||
| 50c94d85a1 | |||
| 0cc188f62c | |||
| 6451671f66 | |||
| ca21a885d5 | |||
| 4af4110f6d | |||
| 51b450c05c | |||
| f9d1f71343 | |||
| 7901e4b996 | |||
| 7b6bf4b78e | |||
| 67d5619ccb | |||
| bf001f96d6 | |||
| ae2b87f03f | |||
| 957cd3502f | |||
| a109542eb8 | |||
| 8c4844b00b | |||
| eec7103910 | |||
| 43aa172829 | |||
| 9b4c483b6d | |||
| b885e206ce | |||
| 07a935f625 | |||
| d13801e976 | |||
| 5716c36b47 | |||
| f11943b956 | |||
| 8b726a9c94 | |||
| ffa3c53847 | |||
| da8833fca4 | |||
| 267feb3c09 | |||
| 3d50af0943 | |||
| 9515019641 | |||
| f0f53dfada | |||
| acef746d26 | |||
| 3356b60e97 | |||
| c988c2b387 | |||
| 3121229707 | |||
| ff85145ee8 | |||
| f61d1a11e0 | |||
| 11ba8979ff | |||
| f6956ccf12 | |||
| 977a5ac8c1 | |||
| 906c3df953 | |||
| 33e5fdc16f | |||
| 77ac17855a | |||
| 65ffcd2884 | |||
| 7ec88bdbfe | |||
| da3fac8cb4 | |||
| 3366113939 | |||
| 9039784f41 | |||
| 7f165e5bbe | |||
| c97111e3ca | |||
| e8746760fa | |||
| 9dbd75e740 | |||
| 85b2fd91b9 | |||
| 6420c218a9 | |||
| ec9402a328 | |||
| d01f06a5c2 | |||
| aee51b3b0d | |||
| afcbccd9dd | |||
| 02448000f9 | |||
| 6af8918aa8 | |||
| 08f83cc438 | |||
| a0468db121 | |||
| 4f23d566ed | |||
| dac619b5f8 | |||
| fdc9cc8074 | |||
| 40b1dabcca | |||
| 94c5af40cf | |||
| 36a3144757 | |||
| 220b61c589 |
@@ -44,7 +44,7 @@ runs:
|
|||||||
|
|
||||||
- name: Login to builtin registry
|
- name: Login to builtin registry
|
||||||
if: ${{ env.BUILTIN_REGISTRY_ENABLED == 'true' }}
|
if: ${{ env.BUILTIN_REGISTRY_ENABLED == 'true' }}
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.BUILTIN_REGISTRY }}
|
registry: ${{ env.BUILTIN_REGISTRY }}
|
||||||
username: ${{ inputs.registry_user }}
|
username: ${{ inputs.registry_user }}
|
||||||
@@ -52,7 +52,7 @@ runs:
|
|||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
if: ${{ env.BUILTIN_REGISTRY_ENABLED == 'true' }}
|
if: ${{ env.BUILTIN_REGISTRY_ENABLED == 'true' }}
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
with:
|
with:
|
||||||
# Use persistent BuildKit if BUILDKIT_ENDPOINT is set (e.g. tcp://buildkit:8125)
|
# Use persistent BuildKit if BUILDKIT_ENDPOINT is set (e.g. tcp://buildkit:8125)
|
||||||
driver: ${{ env.BUILDKIT_ENDPOINT != '' && 'remote' || 'docker-container' }}
|
driver: ${{ env.BUILDKIT_ENDPOINT != '' && 'remote' || 'docker-container' }}
|
||||||
@@ -61,7 +61,7 @@ runs:
|
|||||||
- name: Extract metadata (tags) for Docker
|
- name: Extract metadata (tags) for Docker
|
||||||
if: ${{ env.BUILTIN_REGISTRY_ENABLED == 'true' }}
|
if: ${{ env.BUILTIN_REGISTRY_ENABLED == 'true' }}
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
flavor: |
|
flavor: |
|
||||||
latest=auto
|
latest=auto
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ runs:
|
|||||||
uses: ./.forgejo/actions/rust-toolchain
|
uses: ./.forgejo/actions/rust-toolchain
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
with:
|
with:
|
||||||
# Use persistent BuildKit if BUILDKIT_ENDPOINT is set (e.g. tcp://buildkit:8125)
|
# Use persistent BuildKit if BUILDKIT_ENDPOINT is set (e.g. tcp://buildkit:8125)
|
||||||
driver: ${{ env.BUILDKIT_ENDPOINT != '' && 'remote' || 'docker-container' }}
|
driver: ${{ env.BUILDKIT_ENDPOINT != '' && 'remote' || 'docker-container' }}
|
||||||
@@ -79,7 +79,7 @@ runs:
|
|||||||
|
|
||||||
- name: Login to builtin registry
|
- name: Login to builtin registry
|
||||||
if: ${{ env.BUILTIN_REGISTRY_ENABLED == 'true' }}
|
if: ${{ env.BUILTIN_REGISTRY_ENABLED == 'true' }}
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.BUILTIN_REGISTRY }}
|
registry: ${{ env.BUILTIN_REGISTRY }}
|
||||||
username: ${{ inputs.registry_user }}
|
username: ${{ inputs.registry_user }}
|
||||||
@@ -87,7 +87,7 @@ runs:
|
|||||||
|
|
||||||
- name: Extract metadata (labels, annotations) for Docker
|
- name: Extract metadata (labels, annotations) for Docker
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ${{ inputs.images }}
|
images: ${{ inputs.images }}
|
||||||
# default labels & annotations: https://github.com/docker/metadata-action/blob/master/src/meta.ts#L509
|
# default labels & annotations: https://github.com/docker/metadata-action/blob/master/src/meta.ts#L509
|
||||||
@@ -152,7 +152,7 @@ runs:
|
|||||||
|
|
||||||
- name: inject cache into docker
|
- name: inject cache into docker
|
||||||
if: ${{ env.BUILDKIT_ENDPOINT == '' }}
|
if: ${{ env.BUILDKIT_ENDPOINT == '' }}
|
||||||
uses: https://github.com/reproducible-containers/buildkit-cache-dance@v3.3.0
|
uses: https://github.com/reproducible-containers/buildkit-cache-dance@v3.3.2
|
||||||
with:
|
with:
|
||||||
cache-map: |
|
cache-map: |
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -62,10 +62,6 @@ sync:
|
|||||||
target: registry.gitlab.com/continuwuity/continuwuity
|
target: registry.gitlab.com/continuwuity/continuwuity
|
||||||
type: repository
|
type: repository
|
||||||
<<: *tags-main
|
<<: *tags-main
|
||||||
- source: *source
|
|
||||||
target: git.nexy7574.co.uk/mirrored/continuwuity
|
|
||||||
type: repository
|
|
||||||
<<: *tags-releases
|
|
||||||
- source: *source
|
- source: *source
|
||||||
target: ghcr.io/continuwuity/continuwuity
|
target: ghcr.io/continuwuity/continuwuity
|
||||||
type: repository
|
type: repository
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ jobs:
|
|||||||
registry_password: ${{ secrets.BUILTIN_REGISTRY_PASSWORD || secrets.GITHUB_TOKEN }}
|
registry_password: ${{ secrets.BUILTIN_REGISTRY_PASSWORD || secrets.GITHUB_TOKEN }}
|
||||||
- name: Build and push Docker image by digest
|
- name: Build and push Docker image by digest
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: "docker/Dockerfile"
|
file: "docker/Dockerfile"
|
||||||
@@ -146,7 +146,7 @@ jobs:
|
|||||||
registry_password: ${{ secrets.BUILTIN_REGISTRY_PASSWORD || secrets.GITHUB_TOKEN }}
|
registry_password: ${{ secrets.BUILTIN_REGISTRY_PASSWORD || secrets.GITHUB_TOKEN }}
|
||||||
- name: Build and push max-perf Docker image by digest
|
- name: Build and push max-perf Docker image by digest
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: "docker/Dockerfile"
|
file: "docker/Dockerfile"
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
name: Renovate
|
name: Renovate
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: ghcr.io/renovatebot/renovate:42.70.2@sha256:3c2ac1b94fa92ef2fa4d1a0493f2c3ba564454720a32fdbcac2db2846ff1ee47
|
image: ghcr.io/renovatebot/renovate:43.59.4@sha256:f951508dea1e7d71cbe6deca298ab0a05488e7631229304813f630cc06010892
|
||||||
options: --tmpfs /tmp:exec
|
options: --tmpfs /tmp:exec
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
persist-credentials: true
|
persist-credentials: true
|
||||||
token: ${{ secrets.FORGEJO_TOKEN }}
|
token: ${{ secrets.FORGEJO_TOKEN }}
|
||||||
|
|
||||||
- uses: https://github.com/cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
- uses: https://github.com/cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0
|
||||||
with:
|
with:
|
||||||
nix_path: nixpkgs=channel:nixos-unstable
|
nix_path: nixpkgs=channel:nixos-unstable
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
github: [JadedBlueEyes, nexy7574, gingershaped]
|
github: [JadedBlueEyes, nexy7574, gingershaped]
|
||||||
custom:
|
custom:
|
||||||
- https://ko-fi.com/nexy7574
|
- https://timedout.uk/donate.html
|
||||||
- https://ko-fi.com/JadedBlueEyes
|
- https://jade.ellis.link/sponsors
|
||||||
|
|||||||
+6
-10
@@ -22,22 +22,18 @@ Continuwuity uses pre-commit hooks to enforce various coding standards and catch
|
|||||||
- Validating YAML, JSON, and TOML files
|
- Validating YAML, JSON, and TOML files
|
||||||
- Checking for merge conflicts
|
- Checking for merge conflicts
|
||||||
|
|
||||||
You can run these checks locally by installing [prefligit](https://github.com/j178/prefligit):
|
You can run these checks locally by installing [prek](https://github.com/j178/prek):
|
||||||
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Requires UV: https://docs.astral.sh/uv/getting-started/installation/
|
# Install prek using cargo-binstall
|
||||||
# Mac/linux: curl -LsSf https://astral.sh/uv/install.sh | sh
|
cargo binstall prek
|
||||||
# Windows: powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
|
||||||
|
|
||||||
# Install prefligit using cargo-binstall
|
|
||||||
cargo binstall prefligit
|
|
||||||
|
|
||||||
# Install git hooks to run checks automatically
|
# Install git hooks to run checks automatically
|
||||||
prefligit install
|
prek install
|
||||||
|
|
||||||
# Run all checks
|
# Run all checks
|
||||||
prefligit --all-files
|
prek --all-files
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternatively, you can use [pre-commit](https://pre-commit.com/):
|
Alternatively, you can use [pre-commit](https://pre-commit.com/):
|
||||||
@@ -54,7 +50,7 @@ pre-commit install
|
|||||||
pre-commit run --all-files
|
pre-commit run --all-files
|
||||||
```
|
```
|
||||||
|
|
||||||
These same checks are run in CI via the prefligit-checks workflow to ensure consistency. These must pass before the PR is merged.
|
These same checks are run in CI via the prek-checks workflow to ensure consistency. These must pass before the PR is merged.
|
||||||
|
|
||||||
### Running tests locally
|
### Running tests locally
|
||||||
|
|
||||||
|
|||||||
Generated
+796
-227
File diff suppressed because it is too large
Load Diff
+20
-5
@@ -12,7 +12,7 @@ license = "Apache-2.0"
|
|||||||
# See also `rust-toolchain.toml`
|
# See also `rust-toolchain.toml`
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
repository = "https://forgejo.ellis.link/continuwuation/continuwuity"
|
repository = "https://forgejo.ellis.link/continuwuation/continuwuity"
|
||||||
version = "0.5.6"
|
version = "0.5.7-alpha.1"
|
||||||
|
|
||||||
[workspace.metadata.crane]
|
[workspace.metadata.crane]
|
||||||
name = "conduwuit"
|
name = "conduwuit"
|
||||||
@@ -99,7 +99,7 @@ features = [
|
|||||||
[workspace.dependencies.axum-extra]
|
[workspace.dependencies.axum-extra]
|
||||||
version = "0.12.0"
|
version = "0.12.0"
|
||||||
default-features = false
|
default-features = false
|
||||||
features = ["typed-header", "tracing"]
|
features = ["typed-header", "tracing", "cookie"]
|
||||||
|
|
||||||
[workspace.dependencies.axum-server]
|
[workspace.dependencies.axum-server]
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -159,7 +159,7 @@ features = ["raw_value"]
|
|||||||
|
|
||||||
# Used for appservice registration files
|
# Used for appservice registration files
|
||||||
[workspace.dependencies.serde-saphyr]
|
[workspace.dependencies.serde-saphyr]
|
||||||
version = "0.0.19"
|
version = "0.0.21"
|
||||||
|
|
||||||
# Used to load forbidden room/user regex from config
|
# Used to load forbidden room/user regex from config
|
||||||
[workspace.dependencies.serde_regex]
|
[workspace.dependencies.serde_regex]
|
||||||
@@ -344,7 +344,7 @@ version = "0.1.2"
|
|||||||
[workspace.dependencies.ruma]
|
[workspace.dependencies.ruma]
|
||||||
git = "https://forgejo.ellis.link/continuwuation/ruwuma"
|
git = "https://forgejo.ellis.link/continuwuation/ruwuma"
|
||||||
#branch = "conduwuit-changes"
|
#branch = "conduwuit-changes"
|
||||||
rev = "bb12ed288a31a23aa11b10ba0fad22b7f985eb88"
|
rev = "a97b91adcc012ef04991d823b8b5a79c6686ae48"
|
||||||
features = [
|
features = [
|
||||||
"compat",
|
"compat",
|
||||||
"rand",
|
"rand",
|
||||||
@@ -559,6 +559,19 @@ version = "1.0.1"
|
|||||||
[workspace.dependencies.askama]
|
[workspace.dependencies.askama]
|
||||||
version = "0.15.0"
|
version = "0.15.0"
|
||||||
|
|
||||||
|
[workspace.dependencies.lettre]
|
||||||
|
version = "0.11.19"
|
||||||
|
default-features = false
|
||||||
|
features = ["smtp-transport", "pool", "hostname", "builder", "rustls", "aws-lc-rs", "rustls-native-certs", "tokio1", "tokio1-rustls", "tracing", "serde"]
|
||||||
|
|
||||||
|
[workspace.dependencies.governor]
|
||||||
|
version = "0.10.4"
|
||||||
|
default-features = false
|
||||||
|
features = ["std"]
|
||||||
|
|
||||||
|
[workspace.dependencies.nonzero_ext]
|
||||||
|
version = "0.3.0"
|
||||||
|
|
||||||
#
|
#
|
||||||
# Patches
|
# Patches
|
||||||
#
|
#
|
||||||
@@ -919,7 +932,6 @@ fn_to_numeric_cast_any = "warn"
|
|||||||
format_push_string = "warn"
|
format_push_string = "warn"
|
||||||
get_unwrap = "warn"
|
get_unwrap = "warn"
|
||||||
impl_trait_in_params = "warn"
|
impl_trait_in_params = "warn"
|
||||||
let_underscore_untyped = "warn"
|
|
||||||
lossy_float_literal = "warn"
|
lossy_float_literal = "warn"
|
||||||
mem_forget = "warn"
|
mem_forget = "warn"
|
||||||
missing_assert_message = "warn"
|
missing_assert_message = "warn"
|
||||||
@@ -969,3 +981,6 @@ needless_raw_string_hashes = "allow"
|
|||||||
|
|
||||||
# TODO: Enable this lint & fix all instances
|
# TODO: Enable this lint & fix all instances
|
||||||
collapsible_if = "allow"
|
collapsible_if = "allow"
|
||||||
|
|
||||||
|
# TODO: break these apart
|
||||||
|
cognitive_complexity = "allow"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Added support for associating email addresses with accounts, requiring email addresses for registration, and resetting passwords via email. Contributed by @ginger
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Added support for using an admin command to issue self-service password reset links.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Fixed corrupted appservice registrations causing the server to enter a crash loop. Contributed by @nex.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Add new config option to allow or disallow search engine indexing through a `<meta ../>` tag. Defaults to blocking indexing (`content="noindex"`). Contributed by @s1lv3r and @ginger.
|
||||||
@@ -25,6 +25,10 @@
|
|||||||
#
|
#
|
||||||
# Also see the `[global.well_known]` config section at the very bottom.
|
# Also see the `[global.well_known]` config section at the very bottom.
|
||||||
#
|
#
|
||||||
|
# If `client` is not set under `[global.well_known]`, the server name will
|
||||||
|
# be used as the base domain for user-facing links (such as password
|
||||||
|
# reset links) created by Continuwuity.
|
||||||
|
#
|
||||||
# Examples of delegation:
|
# Examples of delegation:
|
||||||
# - https://continuwuity.org/.well-known/matrix/server
|
# - https://continuwuity.org/.well-known/matrix/server
|
||||||
# - https://continuwuity.org/.well-known/matrix/client
|
# - https://continuwuity.org/.well-known/matrix/client
|
||||||
@@ -1505,6 +1509,11 @@
|
|||||||
#
|
#
|
||||||
#url_preview_user_agent = "continuwuity/<version> (bot; +https://continuwuity.org)"
|
#url_preview_user_agent = "continuwuity/<version> (bot; +https://continuwuity.org)"
|
||||||
|
|
||||||
|
# Determines whether audio and video files will be downloaded for URL
|
||||||
|
# previews.
|
||||||
|
#
|
||||||
|
#url_preview_allow_audio_video = false
|
||||||
|
|
||||||
# List of forbidden room aliases and room IDs as strings of regex
|
# List of forbidden room aliases and room IDs as strings of regex
|
||||||
# patterns.
|
# patterns.
|
||||||
#
|
#
|
||||||
@@ -1790,6 +1799,11 @@
|
|||||||
#
|
#
|
||||||
#config_reload_signal = true
|
#config_reload_signal = true
|
||||||
|
|
||||||
|
# Allow search engines and crawlers to index Continuwuity's built-in
|
||||||
|
# webpages served under the `/_continuwuity/` prefix.
|
||||||
|
#
|
||||||
|
#allow_web_indexing = false
|
||||||
|
|
||||||
[global.tls]
|
[global.tls]
|
||||||
|
|
||||||
# Path to a valid TLS certificate file.
|
# Path to a valid TLS certificate file.
|
||||||
@@ -2023,3 +2037,41 @@
|
|||||||
# web->synapseHTTPAntispam->authorization
|
# web->synapseHTTPAntispam->authorization
|
||||||
#
|
#
|
||||||
#secret =
|
#secret =
|
||||||
|
|
||||||
|
#[global.smtp]
|
||||||
|
|
||||||
|
# A `smtp://`` URI which will be used to connect to a mail server.
|
||||||
|
# Uncommenting the [global.smtp] group and setting this option enables
|
||||||
|
# features which depend on the ability to send email,
|
||||||
|
# such as self-service password resets.
|
||||||
|
#
|
||||||
|
# For most modern mail servers, format the URI like this:
|
||||||
|
# `smtps://username:password@hostname:port`
|
||||||
|
# Note that you will need to URL-encode the username and password. If your
|
||||||
|
# username _is_ your email address, you will need to replace the `@` with
|
||||||
|
# `%40`.
|
||||||
|
#
|
||||||
|
# For a guide on the accepted URI syntax, consult Lettre's documentation:
|
||||||
|
# https://docs.rs/lettre/latest/lettre/transport/smtp/struct.AsyncSmtpTransport.html#method.from_url
|
||||||
|
#
|
||||||
|
#connection_uri =
|
||||||
|
|
||||||
|
# The outgoing address which will be used for sending emails.
|
||||||
|
#
|
||||||
|
# For a syntax guide, see https://datatracker.ietf.org/doc/html/rfc2822#section-3.4
|
||||||
|
#
|
||||||
|
# ...or if you don't want to read the RFC, for some reason:
|
||||||
|
# - `Name <address@domain.org>` to specify a sender name
|
||||||
|
# - `address@domain.org` to not use a name
|
||||||
|
#
|
||||||
|
#sender =
|
||||||
|
|
||||||
|
# Whether to require that users provide an email address when they
|
||||||
|
# register.
|
||||||
|
#
|
||||||
|
#require_email_for_registration = false
|
||||||
|
|
||||||
|
# Whether to require that users who register with a registration token
|
||||||
|
# provide an email address.
|
||||||
|
#
|
||||||
|
#require_email_for_token_registration = false
|
||||||
|
|||||||
+2
-2
@@ -10,7 +10,7 @@ RUN rm -f /etc/apt/apt.conf.d/docker-clean
|
|||||||
|
|
||||||
# Match Rustc version as close as possible
|
# Match Rustc version as close as possible
|
||||||
# rustc -vV
|
# rustc -vV
|
||||||
ARG LLVM_VERSION=20
|
ARG LLVM_VERSION=21
|
||||||
# ENV RUSTUP_TOOLCHAIN=${RUST_VERSION}
|
# ENV RUSTUP_TOOLCHAIN=${RUST_VERSION}
|
||||||
|
|
||||||
# Install repo tools
|
# Install repo tools
|
||||||
@@ -48,7 +48,7 @@ EOF
|
|||||||
|
|
||||||
# Developer tool versions
|
# Developer tool versions
|
||||||
# renovate: datasource=github-releases depName=cargo-bins/cargo-binstall
|
# renovate: datasource=github-releases depName=cargo-bins/cargo-binstall
|
||||||
ENV BINSTALL_VERSION=1.17.6
|
ENV BINSTALL_VERSION=1.17.7
|
||||||
# renovate: datasource=github-releases depName=psastras/sbom-rs
|
# renovate: datasource=github-releases depName=psastras/sbom-rs
|
||||||
ENV CARGO_SBOM_VERSION=0.9.1
|
ENV CARGO_SBOM_VERSION=0.9.1
|
||||||
# renovate: datasource=crate depName=lddtree
|
# renovate: datasource=crate depName=lddtree
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ RUN --mount=type=cache,target=/etc/apk/cache apk add \
|
|||||||
|
|
||||||
# Developer tool versions
|
# Developer tool versions
|
||||||
# renovate: datasource=github-releases depName=cargo-bins/cargo-binstall
|
# renovate: datasource=github-releases depName=cargo-bins/cargo-binstall
|
||||||
ENV BINSTALL_VERSION=1.17.6
|
ENV BINSTALL_VERSION=1.17.7
|
||||||
# renovate: datasource=github-releases depName=psastras/sbom-rs
|
# renovate: datasource=github-releases depName=psastras/sbom-rs
|
||||||
ENV CARGO_SBOM_VERSION=0.9.1
|
ENV CARGO_SBOM_VERSION=0.9.1
|
||||||
# renovate: datasource=crate depName=lddtree
|
# renovate: datasource=crate depName=lddtree
|
||||||
|
|||||||
@@ -69,6 +69,11 @@
|
|||||||
"label": "Configuration Reference",
|
"label": "Configuration Reference",
|
||||||
"name": "/reference/config"
|
"name": "/reference/config"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"label": "Environment Variables",
|
||||||
|
"name": "/reference/environment-variables"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "dir",
|
"type": "dir",
|
||||||
"label": "Admin Command Reference",
|
"label": "Admin Command Reference",
|
||||||
|
|||||||
@@ -109,9 +109,6 @@ Restart Continuwuity and your reverse proxy. Once that's done, visit these route
|
|||||||
{
|
{
|
||||||
"m.homeserver": {
|
"m.homeserver": {
|
||||||
"base_url": "https://matrix.example.com/"
|
"base_url": "https://matrix.example.com/"
|
||||||
},
|
|
||||||
"org.matrix.msc3575.proxy": {
|
|
||||||
"url": "https://matrix.example.com/"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ services:
|
|||||||
### then you are ready to go.
|
### then you are ready to go.
|
||||||
image: forgejo.ellis.link/continuwuation/continuwuity:latest
|
image: forgejo.ellis.link/continuwuation/continuwuity:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
command: /sbin/conduwuit
|
||||||
volumes:
|
volumes:
|
||||||
- db:/var/lib/continuwuity
|
- db:/var/lib/continuwuity
|
||||||
- /etc/resolv.conf:/etc/resolv.conf:ro # Use the host's DNS resolver rather than Docker's.
|
|
||||||
#- ./continuwuity.toml:/etc/continuwuity.toml
|
#- ./continuwuity.toml:/etc/continuwuity.toml
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ services:
|
|||||||
### then you are ready to go.
|
### then you are ready to go.
|
||||||
image: forgejo.ellis.link/continuwuation/continuwuity:latest
|
image: forgejo.ellis.link/continuwuation/continuwuity:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
command: /sbin/conduwuit
|
||||||
volumes:
|
volumes:
|
||||||
- db:/var/lib/continuwuity
|
- db:/var/lib/continuwuity
|
||||||
- /etc/resolv.conf:/etc/resolv.conf:ro # Use the host's DNS resolver rather than Docker's.
|
- /etc/resolv.conf:/etc/resolv.conf:ro # Use the host's DNS resolver rather than Docker's.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ services:
|
|||||||
### then you are ready to go.
|
### then you are ready to go.
|
||||||
image: forgejo.ellis.link/continuwuation/continuwuity:latest
|
image: forgejo.ellis.link/continuwuation/continuwuity:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
command: /sbin/conduwuit
|
||||||
volumes:
|
volumes:
|
||||||
- db:/var/lib/continuwuity
|
- db:/var/lib/continuwuity
|
||||||
- /etc/resolv.conf:/etc/resolv.conf:ro # Use the host's DNS resolver rather than Docker's.
|
- /etc/resolv.conf:/etc/resolv.conf:ro # Use the host's DNS resolver rather than Docker's.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ services:
|
|||||||
### then you are ready to go.
|
### then you are ready to go.
|
||||||
image: forgejo.ellis.link/continuwuation/continuwuity:latest
|
image: forgejo.ellis.link/continuwuation/continuwuity:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
command: /sbin/conduwuit
|
||||||
ports:
|
ports:
|
||||||
- 8448:6167
|
- 8448:6167
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+159
-122
@@ -2,28 +2,26 @@
|
|||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
To run Continuwuity with Docker, you can either build the image yourself or pull it
|
To run Continuwuity with Docker, you can either build the image yourself or pull
|
||||||
from a registry.
|
it from a registry.
|
||||||
|
|
||||||
### Use a registry
|
### Use a registry
|
||||||
|
|
||||||
OCI images for Continuwuity are available in the registries listed below.
|
Available OCI images:
|
||||||
|
|
||||||
| Registry | Image | Notes |
|
| Registry | Image | Notes |
|
||||||
| --------------- | --------------------------------------------------------------- | -----------------------|
|
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||||
| Forgejo Registry| [forgejo.ellis.link/continuwuation/continuwuity:latest](https://forgejo.ellis.link/continuwuation/-/packages/container/continuwuity/latest) | Latest tagged image. |
|
| Forgejo Registry | [forgejo.ellis.link/continuwuation/continuwuity:latest](https://forgejo.ellis.link/continuwuation/-/packages/container/continuwuity/latest) | Latest tagged image. |
|
||||||
| Forgejo Registry| [forgejo.ellis.link/continuwuation/continuwuity:main](https://forgejo.ellis.link/continuwuation/-/packages/container/continuwuity/main) | Main branch image. |
|
| Forgejo Registry | [forgejo.ellis.link/continuwuation/continuwuity:main](https://forgejo.ellis.link/continuwuation/-/packages/container/continuwuity/main) | Main branch image. |
|
||||||
| Forgejo Registry| [forgejo.ellis.link/continuwuation/continuwuity:latest-maxperf](https://forgejo.ellis.link/continuwuation/-/packages/container/continuwuity/latest-maxperf) | [Performance optimised version.](./generic.mdx#performance-optimised-builds) |
|
| Forgejo Registry | [forgejo.ellis.link/continuwuation/continuwuity:latest-maxperf](https://forgejo.ellis.link/continuwuation/-/packages/container/continuwuity/latest-maxperf) | [Performance optimised version.](./generic.mdx#performance-optimised-builds) |
|
||||||
| Forgejo Registry| [forgejo.ellis.link/continuwuation/continuwuity:main-maxperf](https://forgejo.ellis.link/continuwuation/-/packages/container/continuwuity/main-maxperf) | [Performance optimised version.](./generic.mdx#performance-optimised-builds) |
|
| Forgejo Registry | [forgejo.ellis.link/continuwuation/continuwuity:main-maxperf](https://forgejo.ellis.link/continuwuation/-/packages/container/continuwuity/main-maxperf) | [Performance optimised version.](./generic.mdx#performance-optimised-builds) |
|
||||||
|
|
||||||
Use
|
**Example:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker image pull $LINK
|
docker image pull forgejo.ellis.link/continuwuation/continuwuity:main-maxperf
|
||||||
```
|
```
|
||||||
|
|
||||||
to pull it to your machine.
|
|
||||||
|
|
||||||
#### Mirrors
|
#### Mirrors
|
||||||
|
|
||||||
Images are mirrored to multiple locations automatically, on a schedule:
|
Images are mirrored to multiple locations automatically, on a schedule:
|
||||||
@@ -33,39 +31,146 @@ Images are mirrored to multiple locations automatically, on a schedule:
|
|||||||
- `registry.gitlab.com/continuwuity/continuwuity`
|
- `registry.gitlab.com/continuwuity/continuwuity`
|
||||||
- `git.nexy7574.co.uk/mirrored/continuwuity` (releases only, no `main`)
|
- `git.nexy7574.co.uk/mirrored/continuwuity` (releases only, no `main`)
|
||||||
|
|
||||||
### Run
|
### Quick Run
|
||||||
|
|
||||||
When you have the image, you can simply run it with
|
Get a working Continuwuity server with an admin user in four steps:
|
||||||
|
|
||||||
|
#### Prerequisites
|
||||||
|
|
||||||
|
Continuwuity requires HTTPS for Matrix federation. You'll need:
|
||||||
|
|
||||||
|
- A domain name pointing to your server
|
||||||
|
- A reverse proxy with SSL/TLS certificates (Traefik, Caddy, nginx, etc.)
|
||||||
|
|
||||||
|
See [Docker Compose](#docker-compose) for complete examples.
|
||||||
|
|
||||||
|
#### Environment Variables
|
||||||
|
|
||||||
|
- `CONTINUWUITY_SERVER_NAME` - Your Matrix server's domain name
|
||||||
|
- `CONTINUWUITY_DATABASE_PATH` - Where to store your database (must match the
|
||||||
|
volume mount)
|
||||||
|
- `CONTINUWUITY_ADDRESS` - Bind address (use `0.0.0.0` to listen on all
|
||||||
|
interfaces)
|
||||||
|
- `CONTINUWUITY_ALLOW_REGISTRATION` - Set to `false` to disable registration, or
|
||||||
|
use with `CONTINUWUITY_REGISTRATION_TOKEN` to require a token (see
|
||||||
|
[reference](../reference/environment-variables.mdx#registration--user-configuration)
|
||||||
|
for details)
|
||||||
|
|
||||||
|
See the
|
||||||
|
[Environment Variables Reference](../reference/environment-variables.mdx) for
|
||||||
|
more configuration options.
|
||||||
|
|
||||||
|
#### 1. Pull the image
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d -p 8448:6167 \
|
docker pull forgejo.ellis.link/continuwuation/continuwuity:latest
|
||||||
-v db:/var/lib/continuwuity/ \
|
|
||||||
-e CONTINUWUITY_SERVER_NAME="your.server.name" \
|
|
||||||
-e CONTINUWUITY_ALLOW_REGISTRATION=false \
|
|
||||||
--name continuwuity $LINK
|
|
||||||
```
|
```
|
||||||
|
|
||||||
or you can use [Docker Compose](#docker-compose).
|
#### 2. Start the server with initial admin user
|
||||||
|
|
||||||
The `-d` flag lets the container run in detached mode. You may supply an
|
```bash
|
||||||
optional `continuwuity.toml` config file, the example config can be found
|
docker run -d \
|
||||||
[here](../reference/config.mdx). You can pass in different env vars to
|
-p 6167:6167 \
|
||||||
change config values on the fly. You can even configure Continuwuity completely by
|
-v continuwuity_db:/var/lib/continuwuity \
|
||||||
using env vars. For an overview of possible values, please take a look at the
|
-e CONTINUWUITY_SERVER_NAME="matrix.example.com" \
|
||||||
<a href="/examples/docker-compose.yml" target="_blank">`docker-compose.yml`</a> file.
|
-e CONTINUWUITY_DATABASE_PATH="/var/lib/continuwuity" \
|
||||||
|
-e CONTINUWUITY_ADDRESS="0.0.0.0" \
|
||||||
|
-e CONTINUWUITY_ALLOW_REGISTRATION="false" \
|
||||||
|
--name continuwuity \
|
||||||
|
forgejo.ellis.link/continuwuation/continuwuity:latest \
|
||||||
|
/sbin/conduwuit --execute "users create-user admin"
|
||||||
|
```
|
||||||
|
|
||||||
If you just want to test Continuwuity for a short time, you can use the `--rm`
|
Replace `matrix.example.com` with your actual server name and `admin` with
|
||||||
flag, which cleans up everything related to your container after you stop
|
your preferred username.
|
||||||
it.
|
|
||||||
|
#### 3. Get your admin password
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs continuwuity 2>&1 | grep "Created user"
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll see output like:
|
||||||
|
|
||||||
|
```
|
||||||
|
Created user with user_id: @admin:matrix.example.com and password: `[auto-generated-password]`
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Configure your reverse proxy
|
||||||
|
|
||||||
|
Configure your reverse proxy to forward HTTPS traffic to Continuwuity. See
|
||||||
|
[Docker Compose](#docker-compose) for examples.
|
||||||
|
|
||||||
|
Once configured, log in with any Matrix client using `@admin:matrix.example.com`
|
||||||
|
and the generated password. You'll automatically be invited to the admin room
|
||||||
|
where you can manage your server.
|
||||||
|
|
||||||
### Docker Compose
|
### Docker Compose
|
||||||
|
|
||||||
If the `docker run` command is not suitable for you or your setup, you can also use one
|
Docker Compose is the recommended deployment method. These examples include
|
||||||
of the provided `docker-compose` files.
|
reverse proxy configurations for Matrix federation.
|
||||||
|
|
||||||
Depending on your proxy setup, you can use one of the following files:
|
#### Matrix Federation Requirements
|
||||||
|
|
||||||
### For existing Traefik setup
|
For Matrix federation to work, you need to serve `.well-known/matrix/client` and
|
||||||
|
`.well-known/matrix/server` endpoints. You can achieve this either by:
|
||||||
|
|
||||||
|
1. **Using a well-known service** - The compose files below include an nginx
|
||||||
|
container to serve these files
|
||||||
|
2. **Using Continuwuity's built-in delegation** (easier for Traefik) - Configure
|
||||||
|
delegation files in your config, then proxy `/.well-known/matrix/*` to
|
||||||
|
Continuwuity
|
||||||
|
|
||||||
|
**Traefik example using built-in delegation:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
labels:
|
||||||
|
traefik.http.routers.continuwuity.rule: >-
|
||||||
|
(Host(`matrix.example.com`) ||
|
||||||
|
(Host(`example.com`) && PathPrefix(`/.well-known/matrix`)))
|
||||||
|
```
|
||||||
|
|
||||||
|
This routes your Matrix domain and well-known paths to Continuwuity.
|
||||||
|
|
||||||
|
#### Creating Your First Admin User
|
||||||
|
|
||||||
|
Add the `--execute` command to create an admin user on first startup. In your
|
||||||
|
compose file, add under the `continuwuity` service:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
continuwuity:
|
||||||
|
image: forgejo.ellis.link/continuwuation/continuwuity:latest
|
||||||
|
command: /sbin/conduwuit --execute "users create-user admin"
|
||||||
|
# ... rest of configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
Then retrieve the auto-generated password:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs continuwuity | grep "Created user"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Choose Your Reverse Proxy
|
||||||
|
|
||||||
|
Select the compose file that matches your setup:
|
||||||
|
|
||||||
|
:::note DNS Performance
|
||||||
|
Docker's default DNS resolver can cause performance issues with Matrix
|
||||||
|
federation. If you experience slow federation or DNS timeouts, you may need to
|
||||||
|
use your host's DNS resolver instead. Add this volume mount to the
|
||||||
|
`continuwuity` service:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- /etc/resolv.conf:/etc/resolv.conf:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Troubleshooting - DNS Issues](../troubleshooting.mdx#potential-dns-issues-when-using-docker)
|
||||||
|
for more details and alternative solutions.
|
||||||
|
:::
|
||||||
|
|
||||||
|
##### For existing Traefik setup
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>docker-compose.for-traefik.yml</summary>
|
<summary>docker-compose.for-traefik.yml</summary>
|
||||||
@@ -76,7 +181,7 @@ Depending on your proxy setup, you can use one of the following files:
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
### With Traefik included
|
##### With Traefik included
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>docker-compose.with-traefik.yml</summary>
|
<summary>docker-compose.with-traefik.yml</summary>
|
||||||
@@ -87,7 +192,7 @@ Depending on your proxy setup, you can use one of the following files:
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
### With Caddy Docker Proxy
|
##### With Caddy Docker Proxy
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>docker-compose.with-caddy.yml</summary>
|
<summary>docker-compose.with-caddy.yml</summary>
|
||||||
@@ -98,9 +203,15 @@ Replace all `example.com` placeholders with your own domain.
|
|||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you don't already have a network for Caddy to monitor, create one first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network create caddy
|
||||||
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
### For other reverse proxies
|
##### For other reverse proxies
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>docker-compose.yml</summary>
|
<summary>docker-compose.yml</summary>
|
||||||
@@ -111,7 +222,7 @@ Replace all `example.com` placeholders with your own domain.
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
### Override file
|
##### Override file for customisation
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>docker-compose.override.yml</summary>
|
<summary>docker-compose.override.yml</summary>
|
||||||
@@ -122,98 +233,24 @@ Replace all `example.com` placeholders with your own domain.
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
When picking the Traefik-related compose file, rename it to
|
#### Starting Your Server
|
||||||
`docker-compose.yml`, and rename the override file to
|
|
||||||
`docker-compose.override.yml`. Edit the latter with the values you want for your
|
|
||||||
server.
|
|
||||||
|
|
||||||
When picking the `caddy-docker-proxy` compose file, it's important to first
|
1. Choose your compose file and rename it to `docker-compose.yml`
|
||||||
create the `caddy` network before spinning up the containers:
|
2. If using the override file, rename it to `docker-compose.override.yml` and
|
||||||
|
edit your values
|
||||||
```bash
|
3. Start the server:
|
||||||
docker network create caddy
|
|
||||||
```
|
|
||||||
|
|
||||||
After that, you can rename it to `docker-compose.yml` and spin up the
|
|
||||||
containers!
|
|
||||||
|
|
||||||
Additional info about deploying Continuwuity can be found [here](generic.mdx).
|
|
||||||
|
|
||||||
### Build
|
|
||||||
|
|
||||||
Official Continuwuity images are built using **Docker Buildx** and the Dockerfile found at [`docker/Dockerfile`][dockerfile-path]. This approach uses common Docker tooling and enables efficient multi-platform builds.
|
|
||||||
|
|
||||||
The resulting images are widely compatible with Docker and other container runtimes like Podman or containerd.
|
|
||||||
|
|
||||||
The images *do not contain a shell*. They contain only the Continuwuity binary, required libraries, TLS certificates, and metadata.
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Click to view the Dockerfile</summary>
|
|
||||||
|
|
||||||
You can also <a href="https://forgejo.ellis.link/continuwuation/continuwuation/src/branch/main/docker/Dockerfile" target="_blank">view the Dockerfile on Forgejo</a>.
|
|
||||||
|
|
||||||
```dockerfile file="../../docker/Dockerfile"
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
To build an image locally using Docker Buildx, you can typically run a command like:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build for the current platform and load into the local Docker daemon
|
|
||||||
docker buildx build --load --tag continuwuity:latest -f docker/Dockerfile .
|
|
||||||
|
|
||||||
# Example: Build for specific platforms and push to a registry.
|
|
||||||
# docker buildx build --platform linux/amd64,linux/arm64 --tag registry.io/org/continuwuity:latest -f docker/Dockerfile . --push
|
|
||||||
|
|
||||||
# Example: Build binary optimised for the current CPU (standard release profile)
|
|
||||||
# docker buildx build --load \
|
|
||||||
# --tag continuwuity:latest \
|
|
||||||
# --build-arg TARGET_CPU=native \
|
|
||||||
# -f docker/Dockerfile .
|
|
||||||
|
|
||||||
# Example: Build maxperf variant (release-max-perf profile with LTO)
|
|
||||||
# Optimised for runtime performance and smaller binary size, but requires longer build time
|
|
||||||
# docker buildx build --load \
|
|
||||||
# --tag continuwuity:latest-maxperf \
|
|
||||||
# --build-arg TARGET_CPU=native \
|
|
||||||
# --build-arg RUST_PROFILE=release-max-perf \
|
|
||||||
# -f docker/Dockerfile .
|
|
||||||
```
|
|
||||||
|
|
||||||
Refer to the Docker Buildx documentation for more advanced build options.
|
|
||||||
|
|
||||||
[dockerfile-path]: https://forgejo.ellis.link/continuwuation/continuwuation/src/branch/main/docker/Dockerfile
|
|
||||||
|
|
||||||
### Run
|
|
||||||
|
|
||||||
If you have already built the image or want to use one from the registries, you
|
|
||||||
can start the container and everything else in the compose file in detached
|
|
||||||
mode with:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** Don't forget to modify and adjust the compose file to your needs.
|
See the [generic deployment guide](generic.mdx) for more deployment options.
|
||||||
|
|
||||||
### Use Traefik as Proxy
|
### Building Custom Images
|
||||||
|
|
||||||
As a container user, you probably know about Traefik. It is an easy-to-use
|
For information on building your own Continuwuity Docker images, see the
|
||||||
reverse proxy for making containerized apps and services available through the
|
[Building Docker Images](../development/index.mdx#building-docker-images)
|
||||||
web. With the Traefik-related docker-compose files provided above, it is equally easy
|
section in the development documentation.
|
||||||
to deploy and use Continuwuity, with a small caveat. If you have already looked at
|
|
||||||
the files, you should have seen the `well-known` service, which is the
|
|
||||||
small caveat. Traefik is simply a proxy and load balancer and cannot
|
|
||||||
serve any kind of content. For Continuwuity to federate, we need to either
|
|
||||||
expose ports `443` and `8448` or serve two endpoints: `.well-known/matrix/client`
|
|
||||||
and `.well-known/matrix/server`.
|
|
||||||
|
|
||||||
With the service `well-known`, we use a single `nginx` container that serves
|
|
||||||
those two files.
|
|
||||||
|
|
||||||
Alternatively, you can use Continuwuity's built-in delegation file capability. Set up the delegation files in the configuration file, and then proxy paths under `/.well-known/matrix` to continuwuity. For example, the label ``traefik.http.routers.continuwuity.rule=(Host(`matrix.ellis.link`) || (Host(`ellis.link`) && PathPrefix(`/.well-known/matrix`)))`` does this for the domain `ellis.link`.
|
|
||||||
|
|
||||||
## Voice communication
|
## Voice communication
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Continuwuity for FreeBSD
|
# Continuwuity for FreeBSD
|
||||||
|
|
||||||
Continuwuity currently does not provide FreeBSD builds or FreeBSD packaging. However, Continuwuity does build and work on FreeBSD using the system-provided RocksDB.
|
Continuwuity doesn't provide official FreeBSD packages; however, a community-maintained set of packages is available on [Forgejo](https://forgejo.ellis.link/katie/continuwuity-bsd). Note that these are provided as standalone packages and are not part of a FreeBSD package repository (yet), so updates need to be downloaded and installed manually.
|
||||||
|
|
||||||
Contributions to get Continuwuity packaged for FreeBSD are welcome.
|
Please see the installation instructions in that repository. Direct any questions to its issue tracker or to [@katie:kat5.dev](https://matrix.to/#/@katie:kat5.dev).
|
||||||
|
|
||||||
Please join our [Continuwuity BSD](https://matrix.to/#/%23bsd:continuwuity.org) community room.
|
For general BSD support, please join our [Continuwuity BSD](https://matrix.to/#/%23bsd:continuwuity.org) community room.
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ spec:
|
|||||||
- name: continuwuity
|
- name: continuwuity
|
||||||
# use a sha hash <3
|
# use a sha hash <3
|
||||||
image: forgejo.ellis.link/continuwuation/continuwuity:latest
|
image: forgejo.ellis.link/continuwuation/continuwuity:latest
|
||||||
|
command: ["/sbin/conduwuit"]
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
|
|||||||
+133
-70
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Information about developing the project. If you are only interested in using
|
Information about developing the project. If you are only interested in using
|
||||||
it, you can safely ignore this page. If you plan on contributing, see the
|
it, you can safely ignore this page. If you plan on contributing, see the
|
||||||
[contributor's guide](./contributing.mdx) and [code style guide](./code_style.mdx).
|
[contributor's guide](./contributing.mdx) and
|
||||||
|
[code style guide](./code_style.mdx).
|
||||||
|
|
||||||
## Continuwuity project layout
|
## Continuwuity project layout
|
||||||
|
|
||||||
@@ -12,86 +13,98 @@ members are under `src/`. The workspace definition is at the top level / root
|
|||||||
`Cargo.toml`.
|
`Cargo.toml`.
|
||||||
|
|
||||||
The crate names are generally self-explanatory:
|
The crate names are generally self-explanatory:
|
||||||
|
|
||||||
- `admin` is the admin room
|
- `admin` is the admin room
|
||||||
- `api` is the HTTP API, Matrix C-S and S-S endpoints, etc
|
- `api` is the HTTP API, Matrix C-S and S-S endpoints, etc
|
||||||
- `core` is core Continuwuity functionality like config loading, error definitions,
|
- `core` is core Continuwuity functionality like config loading, error
|
||||||
global utilities, logging infrastructure, etc
|
definitions, global utilities, logging infrastructure, etc
|
||||||
- `database` is RocksDB methods, helpers, RocksDB config, and general database definitions,
|
- `database` is RocksDB methods, helpers, RocksDB config, and general database
|
||||||
utilities, or functions
|
definitions, utilities, or functions
|
||||||
- `macros` are Continuwuity Rust [macros][macros] like general helper macros, logging
|
- `macros` are Continuwuity Rust [macros][macros] like general helper macros,
|
||||||
and error handling macros, and [syn][syn] and [procedural macros][proc-macro]
|
logging and error handling macros, and [syn][syn] and [procedural
|
||||||
used for admin room commands and others
|
macros][proc-macro] used for admin room commands and others
|
||||||
- `main` is the "primary" sub-crate. This is where the `main()` function lives,
|
- `main` is the "primary" sub-crate. This is where the `main()` function lives,
|
||||||
tokio worker and async initialisation, Sentry initialisation, [clap][clap] init,
|
tokio worker and async initialisation, Sentry initialisation, [clap][clap]
|
||||||
and signal handling. If you are adding new [Rust features][features], they *must*
|
init, and signal handling. If you are adding new [Rust features][features],
|
||||||
go here.
|
they _must_ go here.
|
||||||
- `router` is the webserver and request handling bits, using axum, tower, tower-http,
|
- `router` is the webserver and request handling bits, using axum, tower,
|
||||||
hyper, etc, and the [global server state][state] to access `services`.
|
tower-http, hyper, etc, and the [global server state][state] to access
|
||||||
|
`services`.
|
||||||
- `service` is the high-level database definitions and functions for data,
|
- `service` is the high-level database definitions and functions for data,
|
||||||
outbound/sending code, and other business logic such as media fetching.
|
outbound/sending code, and other business logic such as media fetching.
|
||||||
|
|
||||||
It is highly unlikely you will ever need to add a new workspace member, but
|
It is highly unlikely you will ever need to add a new workspace member, but if
|
||||||
if you truly find yourself needing to, we recommend reaching out to us in
|
you truly find yourself needing to, we recommend reaching out to us in the
|
||||||
the Matrix room for discussions about it beforehand.
|
Matrix room for discussions about it beforehand.
|
||||||
|
|
||||||
The primary inspiration for this design was apart of hot reloadable development,
|
The primary inspiration for this design was apart of hot reloadable development,
|
||||||
to support "Continuwuity as a library" where specific parts can simply be swapped out.
|
to support "Continuwuity as a library" where specific parts can simply be
|
||||||
There is evidence Conduit wanted to go this route too as `axum` is technically an
|
swapped out. There is evidence Conduit wanted to go this route too as `axum` is
|
||||||
optional feature in Conduit, and can be compiled without the binary or axum library
|
technically an optional feature in Conduit, and can be compiled without the
|
||||||
for handling inbound web requests; but it was never completed or worked.
|
binary or axum library for handling inbound web requests; but it was never
|
||||||
|
completed or worked.
|
||||||
|
|
||||||
See the Rust documentation on [Workspaces][workspaces] for general questions
|
See the Rust documentation on [Workspaces][workspaces] for general questions and
|
||||||
and information on Cargo workspaces.
|
information on Cargo workspaces.
|
||||||
|
|
||||||
## Adding compile-time [features][features]
|
## Adding compile-time [features][features]
|
||||||
|
|
||||||
If you'd like to add a compile-time feature, you must first define it in
|
If you'd like to add a compile-time feature, you must first define it in the
|
||||||
the `main` workspace crate located in `src/main/Cargo.toml`. The feature must
|
`main` workspace crate located in `src/main/Cargo.toml`. The feature must enable
|
||||||
enable a feature in the other workspace crate(s) you intend to use it in. Then
|
a feature in the other workspace crate(s) you intend to use it in. Then the said
|
||||||
the said workspace crate(s) must define the feature there in its `Cargo.toml`.
|
workspace crate(s) must define the feature there in its `Cargo.toml`.
|
||||||
|
|
||||||
So, if this is adding a feature to the API such as `woof`, you define the feature
|
So, if this is adding a feature to the API such as `woof`, you define the
|
||||||
in the `api` crate's `Cargo.toml` as `woof = []`. The feature definition in `main`'s
|
feature in the `api` crate's `Cargo.toml` as `woof = []`. The feature definition
|
||||||
`Cargo.toml` will be `woof = ["conduwuit-api/woof"]`.
|
in `main`'s `Cargo.toml` will be `woof = ["conduwuit-api/woof"]`.
|
||||||
|
|
||||||
The rationale for this is due to Rust / Cargo not supporting
|
The rationale for this is due to Rust / Cargo not supporting ["workspace level
|
||||||
["workspace level features"][9], we must make a choice of; either scattering
|
features"][9], we must make a choice of; either scattering features all over the
|
||||||
features all over the workspace crates, making it difficult for anyone to add
|
workspace crates, making it difficult for anyone to add or remove default
|
||||||
or remove default features; or define all the features in one central workspace
|
features; or define all the features in one central workspace crate that
|
||||||
crate that propagate down/up to the other workspace crates. It is a Cargo pitfall,
|
propagate down/up to the other workspace crates. It is a Cargo pitfall, and we'd
|
||||||
and we'd like to see better developer UX in Rust's Workspaces.
|
like to see better developer UX in Rust's Workspaces.
|
||||||
|
|
||||||
Additionally, the definition of one single place makes "feature collection" in our
|
Additionally, the definition of one single place makes "feature collection" in
|
||||||
Nix flake a million times easier instead of collecting and deduping them all from
|
our Nix flake a million times easier instead of collecting and deduping them all
|
||||||
searching in all the workspace crates' `Cargo.toml`s. Though we wouldn't need to
|
from searching in all the workspace crates' `Cargo.toml`s. Though we wouldn't
|
||||||
do this if Rust supported workspace-level features to begin with.
|
need to do this if Rust supported workspace-level features to begin with.
|
||||||
|
|
||||||
## List of forked dependencies
|
## List of forked dependencies
|
||||||
|
|
||||||
During Continuwuity (and prior projects) development, we have had to fork some dependencies to support our use-cases.
|
During Continuwuity (and prior projects) development, we have had to fork some
|
||||||
These forks exist for various reasons including features that upstream projects won't accept,
|
dependencies to support our use-cases. These forks exist for various reasons
|
||||||
faster-paced development, Continuwuity-specific usecases, or lack of time to upstream changes.
|
including features that upstream projects won't accept, faster-paced
|
||||||
|
development, Continuwuity-specific usecases, or lack of time to upstream
|
||||||
|
changes.
|
||||||
|
|
||||||
All forked dependencies are maintained under the [continuwuation organization on Forgejo](https://forgejo.ellis.link/continuwuation):
|
All forked dependencies are maintained under the
|
||||||
|
[continuwuation organization on Forgejo](https://forgejo.ellis.link/continuwuation):
|
||||||
|
|
||||||
- [ruwuma][continuwuation-ruwuma] - Fork of [ruma/ruma][ruma] with various performance improvements, more features and better client/server interop
|
- [ruwuma][continuwuation-ruwuma] - Fork of [ruma/ruma][ruma] with various
|
||||||
- [rocksdb][continuwuation-rocksdb] - Fork of [facebook/rocksdb][rocksdb] via [`@zaidoon1`][8] with liburing build fixes and GCC debug build fixes
|
performance improvements, more features and better client/server interop
|
||||||
- [jemallocator][continuwuation-jemallocator] - Fork of [tikv/jemallocator][jemallocator] fixing musl builds, suspicious code,
|
- [rocksdb][continuwuation-rocksdb] - Fork of [facebook/rocksdb][rocksdb] via
|
||||||
and adding support for redzones in Valgrind
|
[`@zaidoon1`][8] with liburing build fixes and GCC debug build fixes
|
||||||
- [rustyline-async][continuwuation-rustyline-async] - Fork of [zyansheep/rustyline-async][rustyline-async] with tab completion callback
|
- [jemallocator][continuwuation-jemallocator] - Fork of
|
||||||
and `CTRL+\` signal quit event for Continuwuity console CLI
|
[tikv/jemallocator][jemallocator] fixing musl builds, suspicious code, and
|
||||||
- [rust-rocksdb][continuwuation-rust-rocksdb] - Fork of [rust-rocksdb/rust-rocksdb][rust-rocksdb] fixing musl build issues,
|
adding support for redzones in Valgrind
|
||||||
removing unnecessary `gtest` include, and using our RocksDB and jemallocator forks
|
- [rustyline-async][continuwuation-rustyline-async] - Fork of
|
||||||
- [tracing][continuwuation-tracing] - Fork of [tokio-rs/tracing][tracing] implementing `Clone` for `EnvFilter` to
|
[zyansheep/rustyline-async][rustyline-async] with tab completion callback and
|
||||||
support dynamically changing tracing environments
|
`CTRL+\` signal quit event for Continuwuity console CLI
|
||||||
|
- [rust-rocksdb][continuwuation-rust-rocksdb] - Fork of
|
||||||
|
[rust-rocksdb/rust-rocksdb][rust-rocksdb] fixing musl build issues, removing
|
||||||
|
unnecessary `gtest` include, and using our RocksDB and jemallocator forks
|
||||||
|
- [tracing][continuwuation-tracing] - Fork of [tokio-rs/tracing][tracing]
|
||||||
|
implementing `Clone` for `EnvFilter` to support dynamically changing tracing
|
||||||
|
environments
|
||||||
|
|
||||||
## Debugging with `tokio-console`
|
## Debugging with `tokio-console`
|
||||||
|
|
||||||
[`tokio-console`][7] can be a useful tool for debugging and profiling. To make a
|
[`tokio-console`][7] can be a useful tool for debugging and profiling. To make a
|
||||||
`tokio-console`-enabled build of Continuwuity, enable the `tokio_console` feature,
|
`tokio-console`-enabled build of Continuwuity, enable the `tokio_console`
|
||||||
disable the default `release_max_log_level` feature, and set the `--cfg
|
feature, disable the default `release_max_log_level` feature, and set the
|
||||||
tokio_unstable` flag to enable experimental tokio APIs. A build might look like
|
`--cfg tokio_unstable` flag to enable experimental tokio APIs. A build might
|
||||||
this:
|
look like this:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
RUSTFLAGS="--cfg tokio_unstable" cargo +nightly build \
|
RUSTFLAGS="--cfg tokio_unstable" cargo +nightly build \
|
||||||
@@ -100,34 +113,84 @@ RUSTFLAGS="--cfg tokio_unstable" cargo +nightly build \
|
|||||||
--features=systemd,element_hacks,gzip_compression,brotli_compression,zstd_compression,tokio_console
|
--features=systemd,element_hacks,gzip_compression,brotli_compression,zstd_compression,tokio_console
|
||||||
```
|
```
|
||||||
|
|
||||||
You will also need to enable the `tokio_console` config option in Continuwuity when
|
You will also need to enable the `tokio_console` config option in Continuwuity
|
||||||
starting it. This was due to tokio-console causing gradual memory leak/usage
|
when starting it. This was due to tokio-console causing gradual memory
|
||||||
if left enabled.
|
leak/usage if left enabled.
|
||||||
|
|
||||||
## Building Docker Images
|
## Building Docker Images
|
||||||
|
|
||||||
To build a Docker image for Continuwuity, use the standard Docker build command:
|
Official Continuwuity images are built using **Docker Buildx** and the
|
||||||
|
Dockerfile found at [`docker/Dockerfile`][dockerfile-path].
|
||||||
|
|
||||||
|
The images are compatible with Docker and other container runtimes like Podman
|
||||||
|
or containerd.
|
||||||
|
|
||||||
|
The images _do not contain a shell_. They contain only the Continuwuity binary,
|
||||||
|
required libraries, TLS certificates, and metadata.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Click to view the Dockerfile</summary>
|
||||||
|
|
||||||
|
You can also
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="<https://forgejo.ellis.link/continuwuation/continuwuation/src/branch/main/docker/Dockerfile>"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
view the Dockerfile on Forgejo
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
|
||||||
|
```dockerfile file="../../docker/Dockerfile"
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -f docker/Dockerfile .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The image can be cross-compiled for different architectures.
|
</details>
|
||||||
|
|
||||||
|
### Building Locally
|
||||||
|
|
||||||
|
To build an image locally using Docker Buildx:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build for the current platform and load into the local Docker daemon
|
||||||
|
docker buildx build --load --tag continuwuity:latest -f docker/Dockerfile .
|
||||||
|
|
||||||
|
# Example: Build for specific platforms and push to a registry
|
||||||
|
# docker buildx build --platform linux/amd64,linux/arm64 --tag registry.io/org/continuwuity:latest -f docker/Dockerfile . --push
|
||||||
|
|
||||||
|
# Example: Build binary optimised for the current CPU (standard release profile)
|
||||||
|
# docker buildx build --load \
|
||||||
|
# --tag continuwuity:latest \
|
||||||
|
# --build-arg TARGET_CPU=native \
|
||||||
|
# -f docker/Dockerfile .
|
||||||
|
|
||||||
|
# Example: Build maxperf variant (release-max-perf profile with LTO)
|
||||||
|
# docker buildx build --load \
|
||||||
|
# --tag continuwuity:latest-maxperf \
|
||||||
|
# --build-arg TARGET_CPU=native \
|
||||||
|
# --build-arg RUST_PROFILE=release-max-perf \
|
||||||
|
# -f docker/Dockerfile .
|
||||||
|
```
|
||||||
|
|
||||||
|
Refer to the Docker Buildx documentation for more advanced build options.
|
||||||
|
|
||||||
|
[dockerfile-path]:
|
||||||
|
https://forgejo.ellis.link/continuwuation/continuwuation/src/branch/main/docker/Dockerfile
|
||||||
[continuwuation-ruwuma]: https://forgejo.ellis.link/continuwuation/ruwuma
|
[continuwuation-ruwuma]: https://forgejo.ellis.link/continuwuation/ruwuma
|
||||||
[continuwuation-rocksdb]: https://forgejo.ellis.link/continuwuation/rocksdb
|
[continuwuation-rocksdb]: https://forgejo.ellis.link/continuwuation/rocksdb
|
||||||
[continuwuation-jemallocator]: https://forgejo.ellis.link/continuwuation/jemallocator
|
[continuwuation-jemallocator]:
|
||||||
[continuwuation-rustyline-async]: https://forgejo.ellis.link/continuwuation/rustyline-async
|
https://forgejo.ellis.link/continuwuation/jemallocator
|
||||||
[continuwuation-rust-rocksdb]: https://forgejo.ellis.link/continuwuation/rust-rocksdb
|
[continuwuation-rustyline-async]:
|
||||||
|
https://forgejo.ellis.link/continuwuation/rustyline-async
|
||||||
|
[continuwuation-rust-rocksdb]:
|
||||||
|
https://forgejo.ellis.link/continuwuation/rust-rocksdb
|
||||||
[continuwuation-tracing]: https://forgejo.ellis.link/continuwuation/tracing
|
[continuwuation-tracing]: https://forgejo.ellis.link/continuwuation/tracing
|
||||||
|
|
||||||
[ruma]: https://github.com/ruma/ruma/
|
[ruma]: https://github.com/ruma/ruma/
|
||||||
[rocksdb]: https://github.com/facebook/rocksdb/
|
[rocksdb]: https://github.com/facebook/rocksdb/
|
||||||
[jemallocator]: https://github.com/tikv/jemallocator/
|
[jemallocator]: https://github.com/tikv/jemallocator/
|
||||||
[rustyline-async]: https://github.com/zyansheep/rustyline-async/
|
[rustyline-async]: https://github.com/zyansheep/rustyline-async/
|
||||||
[rust-rocksdb]: https://github.com/rust-rocksdb/rust-rocksdb/
|
[rust-rocksdb]: https://github.com/rust-rocksdb/rust-rocksdb/
|
||||||
[tracing]: https://github.com/tokio-rs/tracing/
|
[tracing]: https://github.com/tokio-rs/tracing/
|
||||||
|
|
||||||
[7]: https://docs.rs/tokio-console/latest/tokio_console/
|
[7]: https://docs.rs/tokio-console/latest/tokio_console/
|
||||||
[8]: https://github.com/zaidoon1/
|
[8]: https://github.com/zaidoon1/
|
||||||
[9]: https://github.com/rust-lang/cargo/issues/12162
|
[9]: https://github.com/rust-lang/cargo/issues/12162
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"m.homeserver":{"base_url": "https://matrix.continuwuity.org"},"org.matrix.msc3575.proxy":{"url": "https://matrix.continuwuity.org"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://livekit.ellis.link"}]}
|
{"m.homeserver":{"base_url": "https://matrix.continuwuity.org"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://livekit.ellis.link"}]}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
"name": "config",
|
"name": "config",
|
||||||
"label": "Configuration"
|
"label": "Configuration"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"name": "environment-variables",
|
||||||
|
"label": "Environment Variables"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"name": "admin",
|
"name": "admin",
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ Trim memory usage
|
|||||||
|
|
||||||
List database files
|
List database files
|
||||||
|
|
||||||
|
## `!admin debug send-test-email`
|
||||||
|
|
||||||
|
Send a test email to the invoking admin's email address
|
||||||
|
|
||||||
## `!admin debug tester`
|
## `!admin debug tester`
|
||||||
|
|
||||||
Developer test stubs
|
Developer test stubs
|
||||||
|
|||||||
@@ -12,6 +12,24 @@ Create a new user
|
|||||||
|
|
||||||
Reset user password
|
Reset user password
|
||||||
|
|
||||||
|
## `!admin users issue-password-reset-link`
|
||||||
|
|
||||||
|
Issue a self-service password reset link for a user
|
||||||
|
|
||||||
|
## `!admin users get-email`
|
||||||
|
|
||||||
|
Get a user's associated email address
|
||||||
|
|
||||||
|
## `!admin users get-user-by-email`
|
||||||
|
|
||||||
|
Get the user with the given email address
|
||||||
|
|
||||||
|
## `!admin users change-email`
|
||||||
|
|
||||||
|
Update or remove a user's email address.
|
||||||
|
|
||||||
|
If `email` is not supplied, the user's existing address will be removed.
|
||||||
|
|
||||||
## `!admin users deactivate`
|
## `!admin users deactivate`
|
||||||
|
|
||||||
Deactivate a user
|
Deactivate a user
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
# Environment Variables
|
||||||
|
|
||||||
|
Continuwuity can be configured entirely through environment variables, making it
|
||||||
|
ideal for containerised deployments and infrastructure-as-code scenarios.
|
||||||
|
|
||||||
|
This is a convenience reference and may not be exhaustive. The
|
||||||
|
[Configuration Reference](./config.mdx) is the primary source for all
|
||||||
|
configuration options.
|
||||||
|
|
||||||
|
## Prefix System
|
||||||
|
|
||||||
|
Continuwuity supports three environment variable prefixes for backwards
|
||||||
|
compatibility:
|
||||||
|
|
||||||
|
- `CONTINUWUITY_*` (current, recommended)
|
||||||
|
- `CONDUWUIT_*` (compatibility)
|
||||||
|
- `CONDUIT_*` (legacy)
|
||||||
|
|
||||||
|
All three prefixes work identically. Use double underscores (`__`) to represent
|
||||||
|
nested configuration sections from the TOML config.
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Simple top-level config
|
||||||
|
CONTINUWUITY_SERVER_NAME="matrix.example.com"
|
||||||
|
CONTINUWUITY_PORT="8008"
|
||||||
|
|
||||||
|
# Nested config sections use double underscores
|
||||||
|
# This maps to [database] section in TOML
|
||||||
|
CONTINUWUITY_DATABASE__PATH="/var/lib/continuwuity"
|
||||||
|
|
||||||
|
# This maps to [tls] section in TOML
|
||||||
|
CONTINUWUITY_TLS__CERTS="/path/to/cert.pem"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration File Override
|
||||||
|
|
||||||
|
You can specify a custom configuration file path:
|
||||||
|
|
||||||
|
- `CONTINUWUITY_CONFIG` - Path to continuwuity.toml (current)
|
||||||
|
- `CONDUWUIT_CONFIG` - Path to config file (compatibility)
|
||||||
|
- `CONDUIT_CONFIG` - Path to config file (legacy)
|
||||||
|
|
||||||
|
## Essential Variables
|
||||||
|
|
||||||
|
These are the minimum variables needed for a working deployment:
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| ---------------------------- | ---------------------------------- | ---------------------- |
|
||||||
|
| `CONTINUWUITY_SERVER_NAME` | Your Matrix server's domain name | Required |
|
||||||
|
| `CONTINUWUITY_DATABASE_PATH` | Path to RocksDB database directory | `/var/lib/conduwuit` |
|
||||||
|
| `CONTINUWUITY_ADDRESS` | IP address to bind to | `["127.0.0.1", "::1"]` |
|
||||||
|
| `CONTINUWUITY_PORT` | Port to listen on | `8008` |
|
||||||
|
|
||||||
|
## Network Configuration
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| -------------------------------- | ----------------------------------------------- | ---------------------- |
|
||||||
|
| `CONTINUWUITY_ADDRESS` | Bind address (use `0.0.0.0` for all interfaces) | `["127.0.0.1", "::1"]` |
|
||||||
|
| `CONTINUWUITY_PORT` | HTTP port | `8008` |
|
||||||
|
| `CONTINUWUITY_UNIX_SOCKET_PATH` | UNIX socket path (alternative to TCP) | - |
|
||||||
|
| `CONTINUWUITY_UNIX_SOCKET_PERMS` | Socket permissions (octal) | `660` |
|
||||||
|
|
||||||
|
## Database Configuration
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| ------------------------------------------ | --------------------------- | -------------------- |
|
||||||
|
| `CONTINUWUITY_DATABASE_PATH` | RocksDB data directory | `/var/lib/conduwuit` |
|
||||||
|
| `CONTINUWUITY_DATABASE_BACKUP_PATH` | Backup directory | - |
|
||||||
|
| `CONTINUWUITY_DATABASE_BACKUPS_TO_KEEP` | Number of backups to retain | `1` |
|
||||||
|
| `CONTINUWUITY_DB_CACHE_CAPACITY_MB` | Database read cache (MB) | - |
|
||||||
|
| `CONTINUWUITY_DB_WRITE_BUFFER_CAPACITY_MB` | Write cache (MB) | - |
|
||||||
|
|
||||||
|
## Cache Configuration
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
| ---------------------------------------- | ------------------------ |
|
||||||
|
| `CONTINUWUITY_CACHE_CAPACITY_MODIFIER` | LRU cache multiplier |
|
||||||
|
| `CONTINUWUITY_PDU_CACHE_CAPACITY` | PDU cache entries |
|
||||||
|
| `CONTINUWUITY_AUTH_CHAIN_CACHE_CAPACITY` | Auth chain cache entries |
|
||||||
|
|
||||||
|
## DNS Configuration
|
||||||
|
|
||||||
|
Configure DNS resolution behaviour for federation and external requests.
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| ------------------------------------ | ---------------------------- | -------- |
|
||||||
|
| `CONTINUWUITY_DNS_CACHE_ENTRIES` | Max DNS cache entries | `32768` |
|
||||||
|
| `CONTINUWUITY_DNS_MIN_TTL` | Minimum cache TTL (seconds) | `10800` |
|
||||||
|
| `CONTINUWUITY_DNS_MIN_TTL_NXDOMAIN` | NXDOMAIN cache TTL (seconds) | `259200` |
|
||||||
|
| `CONTINUWUITY_DNS_ATTEMPTS` | Retry attempts | - |
|
||||||
|
| `CONTINUWUITY_DNS_TIMEOUT` | Query timeout (seconds) | - |
|
||||||
|
| `CONTINUWUITY_DNS_TCP_FALLBACK` | Allow TCP fallback | - |
|
||||||
|
| `CONTINUWUITY_QUERY_ALL_NAMESERVERS` | Query all nameservers | - |
|
||||||
|
| `CONTINUWUITY_QUERY_OVER_TCP_ONLY` | TCP-only queries | - |
|
||||||
|
|
||||||
|
## Request Configuration
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
| ------------------------------------ | ----------------------------- |
|
||||||
|
| `CONTINUWUITY_MAX_REQUEST_SIZE` | Max HTTP request size (bytes) |
|
||||||
|
| `CONTINUWUITY_REQUEST_CONN_TIMEOUT` | Connection timeout (seconds) |
|
||||||
|
| `CONTINUWUITY_REQUEST_TIMEOUT` | Overall request timeout |
|
||||||
|
| `CONTINUWUITY_REQUEST_TOTAL_TIMEOUT` | Total timeout |
|
||||||
|
| `CONTINUWUITY_REQUEST_IDLE_TIMEOUT` | Idle timeout |
|
||||||
|
| `CONTINUWUITY_REQUEST_IDLE_PER_HOST` | Idle connections per host |
|
||||||
|
|
||||||
|
## Federation Configuration
|
||||||
|
|
||||||
|
Control how your server federates with other Matrix servers.
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| ---------------------------------------------- | ----------------------------- | ------- |
|
||||||
|
| `CONTINUWUITY_ALLOW_FEDERATION` | Enable federation | `true` |
|
||||||
|
| `CONTINUWUITY_FEDERATION_LOOPBACK` | Allow loopback federation | - |
|
||||||
|
| `CONTINUWUITY_FEDERATION_CONN_TIMEOUT` | Connection timeout | - |
|
||||||
|
| `CONTINUWUITY_FEDERATION_TIMEOUT` | Request timeout | - |
|
||||||
|
| `CONTINUWUITY_FEDERATION_IDLE_TIMEOUT` | Idle timeout | - |
|
||||||
|
| `CONTINUWUITY_FEDERATION_IDLE_PER_HOST` | Idle connections per host | - |
|
||||||
|
| `CONTINUWUITY_TRUSTED_SERVERS` | JSON array of trusted servers | - |
|
||||||
|
| `CONTINUWUITY_QUERY_TRUSTED_KEY_SERVERS_FIRST` | Query trusted first | - |
|
||||||
|
| `CONTINUWUITY_ONLY_QUERY_TRUSTED_KEY_SERVERS` | Only query trusted | - |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Trust matrix.org for key verification
|
||||||
|
CONTINUWUITY_TRUSTED_SERVERS='["matrix.org"]'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Registration & User Configuration
|
||||||
|
|
||||||
|
Control user registration and account creation behaviour.
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| ------------------------------------------ | --------------------- | ------- |
|
||||||
|
| `CONTINUWUITY_ALLOW_REGISTRATION` | Enable registration | `true` |
|
||||||
|
| `CONTINUWUITY_REGISTRATION_TOKEN` | Token requirement | - |
|
||||||
|
| `CONTINUWUITY_SUSPEND_ON_REGISTER` | Suspend new accounts | - |
|
||||||
|
| `CONTINUWUITY_NEW_USER_DISPLAYNAME_SUFFIX` | Display name suffix | 🏳️⚧️ |
|
||||||
|
| `CONTINUWUITY_RECAPTCHA_SITE_KEY` | reCAPTCHA site key | - |
|
||||||
|
| `CONTINUWUITY_RECAPTCHA_PRIVATE_SITE_KEY` | reCAPTCHA private key | - |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Disable open registration
|
||||||
|
CONTINUWUITY_ALLOW_REGISTRATION="false"
|
||||||
|
|
||||||
|
# Require a registration token
|
||||||
|
CONTINUWUITY_REGISTRATION_TOKEN="your_secret_token_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feature Configuration
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| ---------------------------------------------------------- | -------------------------- | ------- |
|
||||||
|
| `CONTINUWUITY_ALLOW_ENCRYPTION` | Enable E2EE | `true` |
|
||||||
|
| `CONTINUWUITY_ALLOW_ROOM_CREATION` | Enable room creation | - |
|
||||||
|
| `CONTINUWUITY_ALLOW_UNSTABLE_ROOM_VERSIONS` | Allow unstable versions | - |
|
||||||
|
| `CONTINUWUITY_DEFAULT_ROOM_VERSION` | Default room version | `v11` |
|
||||||
|
| `CONTINUWUITY_REQUIRE_AUTH_FOR_PROFILE_REQUESTS` | Auth for profiles | - |
|
||||||
|
| `CONTINUWUITY_ALLOW_PUBLIC_ROOM_DIRECTORY_OVER_FEDERATION` | Federate directory | - |
|
||||||
|
| `CONTINUWUITY_ALLOW_PUBLIC_ROOM_DIRECTORY_WITHOUT_AUTH` | Unauth directory | - |
|
||||||
|
| `CONTINUWUITY_ALLOW_DEVICE_NAME_FEDERATION` | Device names in federation | - |
|
||||||
|
|
||||||
|
## TLS Configuration
|
||||||
|
|
||||||
|
Built-in TLS support is primarily for testing. **For production deployments,
|
||||||
|
especially when federating on the internet, use a reverse proxy** (Traefik,
|
||||||
|
Caddy, nginx) to handle TLS termination.
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
| --------------------------------- | ------------------------- |
|
||||||
|
| `CONTINUWUITY_TLS__CERTS` | TLS certificate file path |
|
||||||
|
| `CONTINUWUITY_TLS__KEY` | TLS private key path |
|
||||||
|
| `CONTINUWUITY_TLS__DUAL_PROTOCOL` | Support TLS 1.2 + 1.3 |
|
||||||
|
|
||||||
|
**Example (testing only):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CONTINUWUITY_TLS__CERTS="/etc/letsencrypt/live/matrix.example.com/fullchain.pem"
|
||||||
|
CONTINUWUITY_TLS__KEY="/etc/letsencrypt/live/matrix.example.com/privkey.pem"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logging Configuration
|
||||||
|
|
||||||
|
Control log output format and verbosity.
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| ------------------------------ | ------------------ | ------- |
|
||||||
|
| `CONTINUWUITY_LOG` | Log filter level | - |
|
||||||
|
| `CONTINUWUITY_LOG_COLORS` | ANSI colours | `true` |
|
||||||
|
| `CONTINUWUITY_LOG_SPAN_EVENTS` | Log span events | `none` |
|
||||||
|
| `CONTINUWUITY_LOG_THREAD_IDS` | Include thread IDs | - |
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set log level to info
|
||||||
|
CONTINUWUITY_LOG="info"
|
||||||
|
|
||||||
|
# Enable debug logging for specific modules
|
||||||
|
CONTINUWUITY_LOG="warn,continuwuity::api=debug"
|
||||||
|
|
||||||
|
# Disable colours for log aggregation
|
||||||
|
CONTINUWUITY_LOG_COLORS="false"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Observability Configuration
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
| ---------------------------------------- | --------------------- |
|
||||||
|
| `CONTINUWUITY_ALLOW_OTLP` | Enable OpenTelemetry |
|
||||||
|
| `CONTINUWUITY_OTLP_FILTER` | OTLP filter level |
|
||||||
|
| `CONTINUWUITY_OTLP_PROTOCOL` | Protocol (http/grpc) |
|
||||||
|
| `CONTINUWUITY_TRACING_FLAME` | Enable flame graphs |
|
||||||
|
| `CONTINUWUITY_TRACING_FLAME_FILTER` | Flame graph filter |
|
||||||
|
| `CONTINUWUITY_TRACING_FLAME_OUTPUT_PATH` | Output directory |
|
||||||
|
| `CONTINUWUITY_SENTRY` | Enable Sentry |
|
||||||
|
| `CONTINUWUITY_SENTRY_ENDPOINT` | Sentry DSN |
|
||||||
|
| `CONTINUWUITY_SENTRY_SEND_SERVER_NAME` | Include server name |
|
||||||
|
| `CONTINUWUITY_SENTRY_TRACES_SAMPLE_RATE` | Sample rate (0.0-1.0) |
|
||||||
|
|
||||||
|
## Admin Configuration
|
||||||
|
|
||||||
|
Configure admin users and automated command execution.
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| ------------------------------------------ | -------------------------------- | ----------------- |
|
||||||
|
| `CONTINUWUITY_ADMINS_LIST` | JSON array of admin user IDs | - |
|
||||||
|
| `CONTINUWUITY_ADMINS_FROM_ROOM` | Derive admins from room | - |
|
||||||
|
| `CONTINUWUITY_ADMIN_ESCAPE_COMMANDS` | Allow `\` prefix in public rooms | - |
|
||||||
|
| `CONTINUWUITY_ADMIN_CONSOLE_AUTOMATIC` | Auto-activate console | - |
|
||||||
|
| `CONTINUWUITY_ADMIN_EXECUTE` | JSON array of startup commands | - |
|
||||||
|
| `CONTINUWUITY_ADMIN_EXECUTE_ERRORS_IGNORE` | Ignore command errors | - |
|
||||||
|
| `CONTINUWUITY_ADMIN_SIGNAL_EXECUTE` | Commands on SIGUSR2 | - |
|
||||||
|
| `CONTINUWUITY_ADMIN_ROOM_TAG` | Admin room tag | `m.server_notice` |
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create admin user on startup
|
||||||
|
CONTINUWUITY_ADMIN_EXECUTE='["users create-user admin", "users make-user-admin admin"]'
|
||||||
|
|
||||||
|
# Specify admin users directly
|
||||||
|
CONTINUWUITY_ADMINS_LIST='["@alice:example.com", "@bob:example.com"]'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Media & URL Preview Configuration
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
| ---------------------------------------------------- | ------------------ |
|
||||||
|
| `CONTINUWUITY_URL_PREVIEW_BOUND_INTERFACE` | Bind interface |
|
||||||
|
| `CONTINUWUITY_URL_PREVIEW_DOMAIN_CONTAINS_ALLOWLIST` | Domain allowlist |
|
||||||
|
| `CONTINUWUITY_URL_PREVIEW_DOMAIN_EXPLICIT_ALLOWLIST` | Explicit allowlist |
|
||||||
|
| `CONTINUWUITY_URL_PREVIEW_DOMAIN_EXPLICIT_DENYLIST` | Explicit denylist |
|
||||||
|
| `CONTINUWUITY_URL_PREVIEW_MAX_SPIDER_SIZE` | Max fetch size |
|
||||||
|
| `CONTINUWUITY_URL_PREVIEW_TIMEOUT` | Fetch timeout |
|
||||||
|
| `CONTINUWUITY_IP_RANGE_DENYLIST` | IP range denylist |
|
||||||
|
|
||||||
|
## Tokio Runtime Configuration
|
||||||
|
|
||||||
|
These can be set as environment variables or CLI arguments:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
| ----------------------------------------- | -------------------------- |
|
||||||
|
| `TOKIO_WORKER_THREADS` | Worker thread count |
|
||||||
|
| `TOKIO_GLOBAL_QUEUE_INTERVAL` | Global queue interval |
|
||||||
|
| `TOKIO_EVENT_INTERVAL` | Event interval |
|
||||||
|
| `TOKIO_MAX_IO_EVENTS_PER_TICK` | Max I/O events per tick |
|
||||||
|
| `CONTINUWUITY_RUNTIME_HISTOGRAM_INTERVAL` | Histogram bucket size (μs) |
|
||||||
|
| `CONTINUWUITY_RUNTIME_HISTOGRAM_BUCKETS` | Bucket count |
|
||||||
|
| `CONTINUWUITY_RUNTIME_WORKER_AFFINITY` | Enable worker affinity |
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Configuration Reference](./config.mdx) - Complete TOML configuration
|
||||||
|
documentation
|
||||||
|
- [Admin Commands](./admin/) - Admin command reference
|
||||||
+12
-12
@@ -6,7 +6,7 @@ misconfigurations to cause issues, particularly with networking and permissions.
|
|||||||
Please check that your issues are not due to problems with your Docker setup.
|
Please check that your issues are not due to problems with your Docker setup.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Continuwuity and Matrix issues
|
## Continuwuity issues
|
||||||
|
|
||||||
### Slow joins to rooms
|
### Slow joins to rooms
|
||||||
|
|
||||||
@@ -23,6 +23,16 @@ which is a longstanding bug with synchronizing room joins to clients. In this si
|
|||||||
the bug caused your homeserver to forget to tell your client. **To fix this, clear your client's cache.** Both Element and Cinny
|
the bug caused your homeserver to forget to tell your client. **To fix this, clear your client's cache.** Both Element and Cinny
|
||||||
have a button to clear their cache in the "About" section of their settings.
|
have a button to clear their cache in the "About" section of their settings.
|
||||||
|
|
||||||
|
### 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!
|
||||||
|
|
||||||
### Lost access to admin room
|
### Lost access to admin room
|
||||||
|
|
||||||
You can reinvite yourself to the admin room through the following methods:
|
You can reinvite yourself to the admin room through the following methods:
|
||||||
@@ -33,17 +43,7 @@ argument once to invite yourslf to the admin room on startup
|
|||||||
- Or specify the `emergency_password` config option to allow you to temporarily
|
- Or specify the `emergency_password` config option to allow you to temporarily
|
||||||
log into the server account (`@conduit`) from a web client
|
log into the server account (`@conduit`) from a web client
|
||||||
|
|
||||||
## General potential issues
|
## DNS 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
|
### Potential DNS issues when using Docker
|
||||||
|
|
||||||
|
|||||||
Generated
+27
-27
@@ -3,11 +3,11 @@
|
|||||||
"advisory-db": {
|
"advisory-db": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1766324728,
|
"lastModified": 1773786698,
|
||||||
"narHash": "sha256-9C+WyE5U3y5w4WQXxmb0ylRyMMsPyzxielWXSHrcDpE=",
|
"narHash": "sha256-o/J7ZculgwSs1L4H4UFlFZENOXTJzq1X0n71x6oNNvY=",
|
||||||
"owner": "rustsec",
|
"owner": "rustsec",
|
||||||
"repo": "advisory-db",
|
"repo": "advisory-db",
|
||||||
"rev": "c88b88c62bda077be8aa621d4e89d8701e39cb5d",
|
"rev": "99e9de91bb8b61f06ef234ff84e11f758ecd5384",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -18,11 +18,11 @@
|
|||||||
},
|
},
|
||||||
"crane": {
|
"crane": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1766194365,
|
"lastModified": 1773189535,
|
||||||
"narHash": "sha256-4AFsUZ0kl6MXSm4BaQgItD0VGlEKR3iq7gIaL7TjBvc=",
|
"narHash": "sha256-E1G/Or6MWeP+L6mpQ0iTFLpzSzlpGrITfU2220Gq47g=",
|
||||||
"owner": "ipetkov",
|
"owner": "ipetkov",
|
||||||
"repo": "crane",
|
"repo": "crane",
|
||||||
"rev": "7d8ec2c71771937ab99790b45e6d9b93d15d9379",
|
"rev": "6fa2fb4cf4a89ba49fc9dd5a3eb6cde99d388269",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -39,11 +39,11 @@
|
|||||||
"rust-analyzer-src": "rust-analyzer-src"
|
"rust-analyzer-src": "rust-analyzer-src"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1766299592,
|
"lastModified": 1773732206,
|
||||||
"narHash": "sha256-7u+q5hexu2eAxL2VjhskHvaUKg+GexmelIR2ve9Nbb4=",
|
"narHash": "sha256-HKibxaUXyWd4Hs+ZUnwo6XslvaFqFqJh66uL9tphU4Q=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "fenix",
|
"repo": "fenix",
|
||||||
"rev": "381579dee168d5ced412e2990e9637ecc7cf1c5d",
|
"rev": "0aa13c1b54063a8d8679b28a5cd357ba98f4a56b",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -55,11 +55,11 @@
|
|||||||
"flake-compat": {
|
"flake-compat": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1765121682,
|
"lastModified": 1767039857,
|
||||||
"narHash": "sha256-4VBOP18BFeiPkyhy9o4ssBNQEvfvv1kXkasAYd0+rrA=",
|
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
|
||||||
"owner": "edolstra",
|
"owner": "edolstra",
|
||||||
"repo": "flake-compat",
|
"repo": "flake-compat",
|
||||||
"rev": "65f23138d8d09a92e30f1e5c87611b23ef451bf3",
|
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -74,11 +74,11 @@
|
|||||||
"nixpkgs-lib": "nixpkgs-lib"
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1765835352,
|
"lastModified": 1772408722,
|
||||||
"narHash": "sha256-XswHlK/Qtjasvhd1nOa1e8MgZ8GS//jBoTqWtrS1Giw=",
|
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "a34fae9c08a15ad73f295041fec82323541400a9",
|
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -89,11 +89,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1766070988,
|
"lastModified": 1773734432,
|
||||||
"narHash": "sha256-G/WVghka6c4bAzMhTwT2vjLccg/awmHkdKSd2JrycLc=",
|
"narHash": "sha256-IF5ppUWh6gHGHYDbtVUyhwy/i7D261P7fWD1bPefOsw=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "c6245e83d836d0433170a16eb185cefe0572f8b8",
|
"rev": "cda48547b432e8d3b18b4180ba07473762ec8558",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -105,11 +105,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-lib": {
|
"nixpkgs-lib": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1765674936,
|
"lastModified": 1772328832,
|
||||||
"narHash": "sha256-k00uTP4JNfmejrCLJOwdObYC9jHRrr/5M/a/8L2EIdo=",
|
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixpkgs.lib",
|
"repo": "nixpkgs.lib",
|
||||||
"rev": "2075416fcb47225d9b68ac469a5c4801a9c4dd85",
|
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -132,11 +132,11 @@
|
|||||||
"rust-analyzer-src": {
|
"rust-analyzer-src": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1766253897,
|
"lastModified": 1773697963,
|
||||||
"narHash": "sha256-ChK07B1aOlJ4QzWXpJo+y8IGAxp1V9yQ2YloJ+RgHRw=",
|
"narHash": "sha256-xdKI77It9PM6eNrCcDZsnP4SKulZwk8VkDgBRVMnCb8=",
|
||||||
"owner": "rust-lang",
|
"owner": "rust-lang",
|
||||||
"repo": "rust-analyzer",
|
"repo": "rust-analyzer",
|
||||||
"rev": "765b7bdb432b3740f2d564afccfae831d5a972e4",
|
"rev": "2993637174252ff60a582fd1f55b9ab52c39db6d",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -153,11 +153,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1766000401,
|
"lastModified": 1773297127,
|
||||||
"narHash": "sha256-+cqN4PJz9y0JQXfAK5J1drd0U05D5fcAGhzhfVrDlsI=",
|
"narHash": "sha256-6E/yhXP7Oy/NbXtf1ktzmU8SdVqJQ09HC/48ebEGBpk=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "treefmt-nix",
|
"repo": "treefmt-nix",
|
||||||
"rev": "42d96e75aa56a3f70cab7e7dc4a32868db28e8fd",
|
"rev": "71b125cd05fbfd78cab3e070b73544abe24c5016",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
file = inputs.self + "/rust-toolchain.toml";
|
file = inputs.self + "/rust-toolchain.toml";
|
||||||
|
|
||||||
# See also `rust-toolchain.toml`
|
# See also `rust-toolchain.toml`
|
||||||
sha256 = "sha256-SJwZ8g0zF2WrKDVmHrVG3pD2RGoQeo24MEXnNx5FyuI=";
|
sha256 = "sha256-sqSWJDUxc+zaz1nBWMAJKTAGBuGWP25GCftIOlCEAtA=";
|
||||||
};
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
+266
-139
@@ -16,21 +16,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.8.1",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
|
||||||
"integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==",
|
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/wasi-threads": "1.1.0",
|
"@emnapi/wasi-threads": "1.2.0",
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.8.1",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
|
||||||
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
|
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -39,9 +39,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
|
||||||
"integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
|
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -144,17 +144,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rsbuild/plugin-react": {
|
"node_modules/@rsbuild/plugin-react": {
|
||||||
"version": "1.4.5",
|
"version": "1.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/@rsbuild/plugin-react/-/plugin-react-1.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rsbuild/plugin-react/-/plugin-react-1.4.6.tgz",
|
||||||
"integrity": "sha512-eS2sXCedgGA/7bLu8yVtn48eE/GyPbXx4Q7OcutB01IQ1D2y8WSMBys4nwfrecy19utvw4NPn4gYDy52316+vg==",
|
"integrity": "sha512-LAT6xHlEyZKA0VjF/ph5d50iyG+WSmBx+7g98HNZUwb94VeeTMZFB8qVptTkbIRMss3BNKOXmHOu71Lhsh9oEw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rspack/plugin-react-refresh": "^1.6.0",
|
"@rspack/plugin-react-refresh": "^1.6.1",
|
||||||
"react-refresh": "^0.18.0"
|
"react-refresh": "^0.18.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@rsbuild/core": "^1.0.0 || ^2.0.0-0"
|
"@rsbuild/core": "^1.0.0 || ^2.0.0-0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@rsbuild/core": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rspack/binding": {
|
"node_modules/@rspack/binding": {
|
||||||
@@ -342,9 +347,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rspack/plugin-react-refresh": {
|
"node_modules/@rspack/plugin-react-refresh": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rspack/plugin-react-refresh/-/plugin-react-refresh-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rspack/plugin-react-refresh/-/plugin-react-refresh-1.6.1.tgz",
|
||||||
"integrity": "sha512-OO53gkrte/Ty4iRXxxM6lkwPGxsSsupFKdrPFnjwFIYrPvFLjeolAl5cTx+FzO5hYygJiGnw7iEKTmD+ptxqDA==",
|
"integrity": "sha512-eqqW5645VG3CzGzFgNg5HqNdHVXY+567PGjtDhhrM8t67caxmsSzRmT5qfoEIfBcGgFkH9vEg7kzXwmCYQdQDw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -362,9 +367,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rspress/core": {
|
"node_modules/@rspress/core": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@rspress/core/-/core-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rspress/core/-/core-2.0.5.tgz",
|
||||||
"integrity": "sha512-OdeGMY75OFzyRZvXuBEMre3q8Y4/OjYJa4vVBDp4Z2E65LSt8+hYkzzkarEl6sFWqbp8c1o9qfSUf4xMctmKvw==",
|
"integrity": "sha512-2ezGmANmIrWmhsUrvlRb9Df4xsun1BDgEertDc890aQqtKcNrbu+TBRsOoO+E/N6ioavun7JGGe1wWjvxubCHw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -372,7 +377,7 @@
|
|||||||
"@mdx-js/react": "^3.1.1",
|
"@mdx-js/react": "^3.1.1",
|
||||||
"@rsbuild/core": "2.0.0-beta.6",
|
"@rsbuild/core": "2.0.0-beta.6",
|
||||||
"@rsbuild/plugin-react": "~1.4.5",
|
"@rsbuild/plugin-react": "~1.4.5",
|
||||||
"@rspress/shared": "2.0.4",
|
"@rspress/shared": "2.0.5",
|
||||||
"@shikijs/rehype": "^4.0.1",
|
"@shikijs/rehype": "^4.0.1",
|
||||||
"@types/unist": "^3.0.3",
|
"@types/unist": "^3.0.3",
|
||||||
"@unhead/react": "^2.1.9",
|
"@unhead/react": "^2.1.9",
|
||||||
@@ -399,6 +404,8 @@
|
|||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"rehype-external-links": "^3.0.0",
|
"rehype-external-links": "^3.0.0",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
|
"remark-cjk-friendly": "^2.0.1",
|
||||||
|
"remark-cjk-friendly-gfm-strikethrough": "^2.0.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-mdx": "^3.1.1",
|
"remark-mdx": "^3.1.1",
|
||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
@@ -420,35 +427,35 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rspress/plugin-client-redirects": {
|
"node_modules/@rspress/plugin-client-redirects": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@rspress/plugin-client-redirects/-/plugin-client-redirects-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rspress/plugin-client-redirects/-/plugin-client-redirects-2.0.5.tgz",
|
||||||
"integrity": "sha512-cm7VNfisVCHe+YHNjd9YrWt6/WtJ5I/oNRyjt+tqCeOcC1IJSX2LhNXpNN5h9az3wxYn37kVctBUjzqkj2FQ+A==",
|
"integrity": "sha512-sxwWzwHPefSPWUyV6/AA/hBlQUeNFntL8dBQi/vZCQiZHM6ShvKbqa3s5Xu2yI7DeFKHH3jb0VGbjufu8M3Ypw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@rspress/core": "^2.0.4"
|
"@rspress/core": "^2.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rspress/plugin-sitemap": {
|
"node_modules/@rspress/plugin-sitemap": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@rspress/plugin-sitemap/-/plugin-sitemap-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rspress/plugin-sitemap/-/plugin-sitemap-2.0.5.tgz",
|
||||||
"integrity": "sha512-TKaj3/8+P1fP3sD5NOaWVMXvRvJFQmuJQlUBxhRM0oiUHhzNNkVy/2YXkjYJuXuMhFPLnOWCjrYjTG3xcZE7Wg==",
|
"integrity": "sha512-wBxKL8sNd3bkKFxlFtB1xJ7jCtSRDL6pfVvWsmTIbTNDPCtefd1nmiMBIDMLOR8EflwuStIz3bMQXdWpbC7ahA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@rspress/core": "^2.0.4"
|
"@rspress/core": "^2.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rspress/shared": {
|
"node_modules/@rspress/shared": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@rspress/shared/-/shared-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rspress/shared/-/shared-2.0.5.tgz",
|
||||||
"integrity": "sha512-os2nzsPgHKVFXjDoW7N53rmhLChCw/y2O2TGilT4w2A4HNJa2oJwRk0UryXbxxWD5C85HErTjovs2uBdhdOTtA==",
|
"integrity": "sha512-Wdhh+VjU8zJWoVLhv9KJTRAZQ4X2V/Z81Lo2D0hQsa0Kj5F3EaxlMt5/dhX7DoflqNuZPZk/e7CSUB+gO/Umlg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -460,14 +467,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@shikijs/core": {
|
"node_modules/@shikijs/core": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz",
|
||||||
"integrity": "sha512-vWvqi9JNgz1dRL9Nvog5wtx7RuNkf7MEPl2mU/cyUUxJeH1CAr3t+81h8zO8zs7DK6cKLMoU9TvukWIDjP4Lzg==",
|
"integrity": "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shikijs/primitive": "4.0.1",
|
"@shikijs/primitive": "4.0.2",
|
||||||
"@shikijs/types": "4.0.1",
|
"@shikijs/types": "4.0.2",
|
||||||
"@shikijs/vscode-textmate": "^10.0.2",
|
"@shikijs/vscode-textmate": "^10.0.2",
|
||||||
"@types/hast": "^3.0.4",
|
"@types/hast": "^3.0.4",
|
||||||
"hast-util-to-html": "^9.0.5"
|
"hast-util-to-html": "^9.0.5"
|
||||||
@@ -477,13 +484,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@shikijs/engine-javascript": {
|
"node_modules/@shikijs/engine-javascript": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.2.tgz",
|
||||||
"integrity": "sha512-DJK9NiwtGYqMuKCRO4Ip0FKNDQpmaiS+K5bFjJ7DWFn4zHueDWgaUG8kAofkrnXF6zPPYYQY7J5FYVW9MbZyBg==",
|
"integrity": "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shikijs/types": "4.0.1",
|
"@shikijs/types": "4.0.2",
|
||||||
"@shikijs/vscode-textmate": "^10.0.2",
|
"@shikijs/vscode-textmate": "^10.0.2",
|
||||||
"oniguruma-to-es": "^4.3.4"
|
"oniguruma-to-es": "^4.3.4"
|
||||||
},
|
},
|
||||||
@@ -492,13 +499,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@shikijs/engine-oniguruma": {
|
"node_modules/@shikijs/engine-oniguruma": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz",
|
||||||
"integrity": "sha512-oCWdCTDch3J8Kc0OZJ98KuUPC02O1VqIE3W/e2uvrHqTxYRR21RGEJMtchrgrxhsoJJCzmIciKsqG+q/yD+Cxg==",
|
"integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shikijs/types": "4.0.1",
|
"@shikijs/types": "4.0.2",
|
||||||
"@shikijs/vscode-textmate": "^10.0.2"
|
"@shikijs/vscode-textmate": "^10.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -506,26 +513,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@shikijs/langs": {
|
"node_modules/@shikijs/langs": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz",
|
||||||
"integrity": "sha512-v/mluaybWdnGJR4GqAR6zh8qAZohW9k+cGYT28Y7M8+jLbC0l4yG085O1A+WkseHTn+awd+P3UBymb2+MXFc8w==",
|
"integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shikijs/types": "4.0.1"
|
"@shikijs/types": "4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@shikijs/primitive": {
|
"node_modules/@shikijs/primitive": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.0.2.tgz",
|
||||||
"integrity": "sha512-ns0hHZc5eWZuvuIEJz2pTx3Qecz0aRVYumVQJ8JgWY2tq/dH8WxdcVM49Fc2NsHEILNIT6vfdW9MF26RANWiTA==",
|
"integrity": "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shikijs/types": "4.0.1",
|
"@shikijs/types": "4.0.2",
|
||||||
"@shikijs/vscode-textmate": "^10.0.2",
|
"@shikijs/vscode-textmate": "^10.0.2",
|
||||||
"@types/hast": "^3.0.4"
|
"@types/hast": "^3.0.4"
|
||||||
},
|
},
|
||||||
@@ -534,16 +541,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@shikijs/rehype": {
|
"node_modules/@shikijs/rehype": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@shikijs/rehype/-/rehype-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@shikijs/rehype/-/rehype-4.0.2.tgz",
|
||||||
"integrity": "sha512-bx7bYA0/p/pgeEICaPO0jT6TXrXHmr9tGRUDhOMy1cAUN2YA0iANfXX7seBnImy8DGu/rxm1ij9/ZofYrAaUjQ==",
|
"integrity": "sha512-cmPlKLD8JeojasNFoY64162ScpEdEdQUMuVodPCrv1nx1z3bjmGwoKWDruQWa/ejSznImlaeB0Ty6Q3zPaVQAA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shikijs/types": "4.0.1",
|
"@shikijs/types": "4.0.2",
|
||||||
"@types/hast": "^3.0.4",
|
"@types/hast": "^3.0.4",
|
||||||
"hast-util-to-string": "^3.0.1",
|
"hast-util-to-string": "^3.0.1",
|
||||||
"shiki": "4.0.1",
|
"shiki": "4.0.2",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
"unist-util-visit": "^5.1.0"
|
"unist-util-visit": "^5.1.0"
|
||||||
},
|
},
|
||||||
@@ -552,22 +559,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@shikijs/themes": {
|
"node_modules/@shikijs/themes": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.2.tgz",
|
||||||
"integrity": "sha512-FW41C/D6j/yKQkzVdjrRPiJCtgeDaYRJFEyCKFCINuRJRj9WcmubhP4KQHPZ4+9eT87jruSrYPyoblNRyDFzvA==",
|
"integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shikijs/types": "4.0.1"
|
"@shikijs/types": "4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@shikijs/types": {
|
"node_modules/@shikijs/types": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.2.tgz",
|
||||||
"integrity": "sha512-EaygPEn57+jJ76mw+nTLvIpJMAcMPokFbrF8lufsZP7Ukk+ToJYEcswN1G0e49nUZAq7aCQtoeW219A8HK1ZOw==",
|
"integrity": "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -668,9 +675,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.6",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
@@ -693,13 +700,13 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/@unhead/react": {
|
"node_modules/@unhead/react": {
|
||||||
"version": "2.1.10",
|
"version": "2.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@unhead/react/-/react-2.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/@unhead/react/-/react-2.1.12.tgz",
|
||||||
"integrity": "sha512-z9IzzkaCI1GyiBwVRMt4dGc2mOvsj9drbAdXGMy6DWpu9FwTR37ZTmAi7UeCVyIkpVdIaNalz7vkbvGG8afFng==",
|
"integrity": "sha512-1xXFrxyw29f+kScXfEb0GxjlgtnHxoYau0qpW9k8sgWhQUNnE5gNaH3u+rNhd5IqhyvbdDRJpQ25zoz0HIyGaw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"unhead": "2.1.10"
|
"unhead": "2.1.12"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/harlan-zw"
|
"url": "https://github.com/sponsors/harlan-zw"
|
||||||
@@ -709,9 +716,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.15.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -989,9 +996,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/decode-named-character-reference": {
|
"node_modules/decode-named-character-reference": {
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
||||||
"integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==",
|
"integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1026,6 +1033,19 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/error-stack-parser": {
|
"node_modules/error-stack-parser": {
|
||||||
"version": "2.1.4",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz",
|
||||||
@@ -1272,6 +1292,19 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-east-asian-width": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/github-slugger": {
|
"node_modules/github-slugger": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
|
||||||
@@ -1479,16 +1512,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hast-util-to-parse5": {
|
"node_modules/hast-util-to-parse5": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz",
|
||||||
"integrity": "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==",
|
"integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/hast": "^3.0.0",
|
"@types/hast": "^3.0.0",
|
||||||
"comma-separated-tokens": "^2.0.0",
|
"comma-separated-tokens": "^2.0.0",
|
||||||
"devlop": "^1.0.0",
|
"devlop": "^1.0.0",
|
||||||
"property-information": "^6.0.0",
|
"property-information": "^7.0.0",
|
||||||
"space-separated-tokens": "^2.0.0",
|
"space-separated-tokens": "^2.0.0",
|
||||||
"web-namespaces": "^2.0.0",
|
"web-namespaces": "^2.0.0",
|
||||||
"zwitch": "^2.0.0"
|
"zwitch": "^2.0.0"
|
||||||
@@ -1498,17 +1531,6 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hast-util-to-parse5/node_modules/property-information": {
|
|
||||||
"version": "6.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz",
|
|
||||||
"integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/hast-util-to-string": {
|
"node_modules/hast-util-to-string": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz",
|
||||||
@@ -1556,9 +1578,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hookable": {
|
"node_modules/hookable": {
|
||||||
"version": "6.0.1",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/hookable/-/hookable-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.0.tgz",
|
||||||
"integrity": "sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==",
|
"integrity": "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -1591,9 +1613,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/inline-style-parser": {
|
"node_modules/inline-style-parser": {
|
||||||
"version": "0.2.6",
|
"version": "0.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
||||||
"integrity": "sha512-gtGXVaBdl5mAes3rPcMedEBm12ibjt1kDMFfheul1wUAOVEJW60voNdMVzVkfLN06O7ZaD/rxhfKgtlgtTbMjg==",
|
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -1811,9 +1833,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mdast-util-from-markdown": {
|
"node_modules/mdast-util-from-markdown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
|
||||||
"integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==",
|
"integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2174,6 +2196,80 @@
|
|||||||
"micromark-util-types": "^2.0.0"
|
"micromark-util-types": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/micromark-extension-cjk-friendly": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-extension-cjk-friendly/-/micromark-extension-cjk-friendly-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-OkzoYVTL1ChbvQ8Cc1ayTIz7paFQz8iS9oIYmewncweUSwmWR+hkJF9spJ1lxB90XldJl26A1F4IkPOKS3bDXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"devlop": "^1.1.0",
|
||||||
|
"micromark-extension-cjk-friendly-util": "3.0.1",
|
||||||
|
"micromark-util-chunked": "^2.0.1",
|
||||||
|
"micromark-util-resolve-all": "^2.0.1",
|
||||||
|
"micromark-util-symbol": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"micromark": "^4.0.0",
|
||||||
|
"micromark-util-types": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"micromark-util-types": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromark-extension-cjk-friendly-gfm-strikethrough": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-extension-cjk-friendly-gfm-strikethrough/-/micromark-extension-cjk-friendly-gfm-strikethrough-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-wVC0zwjJNqQeX+bb07YTPu/CvSAyCTafyYb7sMhX1r62/Lw5M/df3JyYaANyp8g15c1ypJRFSsookTqA1IDsUg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"devlop": "^1.1.0",
|
||||||
|
"get-east-asian-width": "^1.4.0",
|
||||||
|
"micromark-extension-cjk-friendly-util": "3.0.1",
|
||||||
|
"micromark-util-character": "^2.1.1",
|
||||||
|
"micromark-util-chunked": "^2.0.1",
|
||||||
|
"micromark-util-resolve-all": "^2.0.1",
|
||||||
|
"micromark-util-symbol": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"micromark": "^4.0.0",
|
||||||
|
"micromark-util-types": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"micromark-util-types": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromark-extension-cjk-friendly-util": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-extension-cjk-friendly-util/-/micromark-extension-cjk-friendly-util-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-GcbXqTTHOsiZHyF753oIddP/J2eH8j9zpyQPhkof6B2JNxfEJabnQqxbCgzJNuNes0Y2jTNJ3LiYPSXr6eJA8w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"get-east-asian-width": "^1.4.0",
|
||||||
|
"micromark-util-character": "^2.1.1",
|
||||||
|
"micromark-util-symbol": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"micromark-util-types": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/micromark-extension-gfm": {
|
"node_modules/micromark-extension-gfm": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
|
||||||
@@ -2887,14 +2983,14 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/oniguruma-to-es": {
|
"node_modules/oniguruma-to-es": {
|
||||||
"version": "4.3.4",
|
"version": "4.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.5.tgz",
|
||||||
"integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==",
|
"integrity": "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"oniguruma-parser": "^0.12.1",
|
"oniguruma-parser": "^0.12.1",
|
||||||
"regex": "^6.0.1",
|
"regex": "^6.1.0",
|
||||||
"regex-recursion": "^6.0.2"
|
"regex-recursion": "^6.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2938,19 +3034,6 @@
|
|||||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/parse5/node_modules/entities": {
|
|
||||||
"version": "6.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
|
||||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -3253,6 +3336,50 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/remark-cjk-friendly": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/remark-cjk-friendly/-/remark-cjk-friendly-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-6WwkoQyZf/4j5k53zdFYrR8Ca+UVn992jXdLUSBDZR4eBpFhKyVxmA4gUHra/5fesjGIxrDhHesNr/sVoiiysA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"micromark-extension-cjk-friendly": "2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/mdast": "^4.0.0",
|
||||||
|
"unified": "^11.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/mdast": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/remark-cjk-friendly-gfm-strikethrough": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/remark-cjk-friendly-gfm-strikethrough/-/remark-cjk-friendly-gfm-strikethrough-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-pWKj25O2eLXIL1aBupayl1fKhco+Brw8qWUWJPVB9EBzbQNd7nGLj0nLmJpggWsGLR5j5y40PIdjxby9IEYTuA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"micromark-extension-cjk-friendly-gfm-strikethrough": "2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/mdast": "^4.0.0",
|
||||||
|
"unified": "^11.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/mdast": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/remark-gfm": {
|
"node_modules/remark-gfm": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
|
||||||
@@ -3377,18 +3504,18 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/shiki": {
|
"node_modules/shiki": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz",
|
||||||
"integrity": "sha512-EkAEhDTN5WhpoQFXFw79OHIrSAfHhlImeCdSyg4u4XvrpxKEmdo/9x/HWSowujAnUrFsGOwWiE58a6GVentMnQ==",
|
"integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shikijs/core": "4.0.1",
|
"@shikijs/core": "4.0.2",
|
||||||
"@shikijs/engine-javascript": "4.0.1",
|
"@shikijs/engine-javascript": "4.0.2",
|
||||||
"@shikijs/engine-oniguruma": "4.0.1",
|
"@shikijs/engine-oniguruma": "4.0.2",
|
||||||
"@shikijs/langs": "4.0.1",
|
"@shikijs/langs": "4.0.2",
|
||||||
"@shikijs/themes": "4.0.1",
|
"@shikijs/themes": "4.0.2",
|
||||||
"@shikijs/types": "4.0.1",
|
"@shikijs/types": "4.0.2",
|
||||||
"@shikijs/vscode-textmate": "^10.0.2",
|
"@shikijs/vscode-textmate": "^10.0.2",
|
||||||
"@types/hast": "^3.0.4"
|
"@types/hast": "^3.0.4"
|
||||||
},
|
},
|
||||||
@@ -3457,23 +3584,23 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/style-to-js": {
|
"node_modules/style-to-js": {
|
||||||
"version": "1.1.19",
|
"version": "1.1.21",
|
||||||
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.19.tgz",
|
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
|
||||||
"integrity": "sha512-Ev+SgeqiNGT1ufsXyVC5RrJRXdrkRJ1Gol9Qw7Pb72YCKJXrBvP0ckZhBeVSrw2m06DJpei2528uIpjMb4TsoQ==",
|
"integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"style-to-object": "1.0.12"
|
"style-to-object": "1.0.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/style-to-object": {
|
"node_modules/style-to-object": {
|
||||||
"version": "1.0.12",
|
"version": "1.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
|
||||||
"integrity": "sha512-ddJqYnoT4t97QvN2C95bCgt+m7AAgXjVnkk/jxAfmp7EAB8nnqqZYEbMd3em7/vEomDb2LAQKAy1RFfv41mdNw==",
|
"integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"inline-style-parser": "0.2.6"
|
"inline-style-parser": "0.2.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
@@ -3598,9 +3725,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/unhead": {
|
"node_modules/unhead": {
|
||||||
"version": "2.1.10",
|
"version": "2.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.12.tgz",
|
||||||
"integrity": "sha512-We8l9uNF8zz6U8lfQaVG70+R/QBfQx1oPIgXin4BtZnK2IQpz6yazQ0qjMNVBDw2ADgF2ea58BtvSK+XX5AS7g==",
|
"integrity": "sha512-iTHdWD9ztTunOErtfUFk6Wr11BxvzumcYJ0CzaSCBUOEtg+DUZ9+gnE99i8QkLFT2q1rZD48BYYGXpOZVDLYkA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Environment="CONTINUWUITY_DATABASE_PATH=%S/conduwuit"
|
|||||||
Environment="CONTINUWUITY_CONFIG_RELOAD_SIGNAL=true"
|
Environment="CONTINUWUITY_CONFIG_RELOAD_SIGNAL=true"
|
||||||
|
|
||||||
LoadCredential=conduwuit.toml:/etc/conduwuit/conduwuit.toml
|
LoadCredential=conduwuit.toml:/etc/conduwuit/conduwuit.toml
|
||||||
|
RefreshOnReload=yes
|
||||||
|
|
||||||
ExecStart=/usr/bin/conduwuit --config ${CREDENTIALS_DIRECTORY}/conduwuit.toml
|
ExecStart=/usr/bin/conduwuit --config ${CREDENTIALS_DIRECTORY}/conduwuit.toml
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": ["config:recommended", "replacements:all"],
|
"extends": ["config:recommended", "replacements:all", ":semanticCommitTypeAll(chore)"],
|
||||||
"dependencyDashboard": true,
|
"dependencyDashboard": true,
|
||||||
"osvVulnerabilityAlerts": true,
|
"osvVulnerabilityAlerts": true,
|
||||||
"lockFileMaintenance": {
|
"lockFileMaintenance": {
|
||||||
@@ -36,10 +36,18 @@
|
|||||||
},
|
},
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"description": "Batch patch-level Rust dependency updates",
|
"description": "Batch minor and patch Rust dependency updates",
|
||||||
|
"matchManagers": ["cargo"],
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"matchCurrentVersion": ">=1.0.0",
|
||||||
|
"groupName": "rust-non-major"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Batch patch-level zerover Rust dependency updates",
|
||||||
"matchManagers": ["cargo"],
|
"matchManagers": ["cargo"],
|
||||||
"matchUpdateTypes": ["patch"],
|
"matchUpdateTypes": ["patch"],
|
||||||
"groupName": "rust-patch-updates"
|
"matchCurrentVersion": ">=0.1.0,<1.0.0",
|
||||||
|
"groupName": "rust-zerover-patch-updates"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Limit concurrent Cargo PRs",
|
"description": "Limit concurrent Cargo PRs",
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
[toolchain]
|
[toolchain]
|
||||||
profile = "minimal"
|
profile = "minimal"
|
||||||
channel = "1.90.0"
|
channel = "1.92.0"
|
||||||
components = [
|
components = [
|
||||||
# For rust-analyzer
|
# For rust-analyzer
|
||||||
"rust-src",
|
"rust-src",
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ conduwuit-macros.workspace = true
|
|||||||
conduwuit-service.workspace = true
|
conduwuit-service.workspace = true
|
||||||
const-str.workspace = true
|
const-str.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
|
lettre.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
ruma.workspace = true
|
ruma.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use conduwuit::{
|
|||||||
warn,
|
warn,
|
||||||
};
|
};
|
||||||
use futures::{FutureExt, StreamExt, TryStreamExt};
|
use futures::{FutureExt, StreamExt, TryStreamExt};
|
||||||
|
use lettre::message::Mailbox;
|
||||||
use ruma::{
|
use ruma::{
|
||||||
CanonicalJsonObject, CanonicalJsonValue, EventId, OwnedEventId, OwnedRoomId,
|
CanonicalJsonObject, CanonicalJsonValue, EventId, OwnedEventId, OwnedRoomId,
|
||||||
OwnedRoomOrAliasId, OwnedServerName, RoomId, RoomVersionId,
|
OwnedRoomOrAliasId, OwnedServerName, RoomId, RoomVersionId,
|
||||||
@@ -876,3 +877,31 @@ pub(super) async fn trim_memory(&self) -> Result {
|
|||||||
|
|
||||||
writeln!(self, "done").await
|
writeln!(self, "done").await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[admin_command]
|
||||||
|
pub(super) async fn send_test_email(&self) -> Result {
|
||||||
|
self.bail_restricted()?;
|
||||||
|
|
||||||
|
let mailer = self.services.mailer.expect_mailer()?;
|
||||||
|
let Some(sender) = self.sender else {
|
||||||
|
return Err!("No sender user provided in context");
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(email) = self
|
||||||
|
.services
|
||||||
|
.threepid
|
||||||
|
.get_email_for_localpart(sender.localpart())
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return Err!("{} has no associated email address", sender);
|
||||||
|
};
|
||||||
|
|
||||||
|
mailer
|
||||||
|
.send(Mailbox::new(None, email.clone()), service::mailer::messages::Test)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
self.write_str(&format!("Test email successfully sent to {email}"))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -225,6 +225,9 @@ pub enum DebugCommand {
|
|||||||
level: Option<i32>,
|
level: Option<i32>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Send a test email to the invoking admin's email address
|
||||||
|
SendTestEmail,
|
||||||
|
|
||||||
/// Developer test stubs
|
/// Developer test stubs
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
#[allow(non_snake_case)]
|
#[allow(non_snake_case)]
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use conduwuit::{
|
|||||||
warn,
|
warn,
|
||||||
};
|
};
|
||||||
use futures::{FutureExt, StreamExt};
|
use futures::{FutureExt, StreamExt};
|
||||||
|
use lettre::Address;
|
||||||
use ruma::{
|
use ruma::{
|
||||||
OwnedEventId, OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedUserId, UserId,
|
OwnedEventId, OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedUserId, UserId,
|
||||||
events::{
|
events::{
|
||||||
@@ -296,6 +297,31 @@ pub(super) async fn reset_password(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[admin_command]
|
||||||
|
pub(super) async fn issue_password_reset_link(&self, username: String) -> Result {
|
||||||
|
use conduwuit_service::password_reset::{PASSWORD_RESET_PATH, RESET_TOKEN_QUERY_PARAM};
|
||||||
|
|
||||||
|
self.bail_restricted()?;
|
||||||
|
|
||||||
|
let mut reset_url = self
|
||||||
|
.services
|
||||||
|
.config
|
||||||
|
.get_client_domain()
|
||||||
|
.join(PASSWORD_RESET_PATH)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let user_id = parse_local_user_id(self.services, &username)?;
|
||||||
|
let token = self.services.password_reset.issue_token(user_id).await?;
|
||||||
|
reset_url
|
||||||
|
.query_pairs_mut()
|
||||||
|
.append_pair(RESET_TOKEN_QUERY_PARAM, &token.token);
|
||||||
|
|
||||||
|
self.write_str(&format!("Password reset link issued for {username}: {reset_url}"))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[admin_command]
|
#[admin_command]
|
||||||
pub(super) async fn deactivate_all(&self, no_leave_rooms: bool, force: bool) -> Result {
|
pub(super) async fn deactivate_all(&self, no_leave_rooms: bool, force: bool) -> Result {
|
||||||
if self.body.len() < 2
|
if self.body.len() < 2
|
||||||
@@ -1069,3 +1095,106 @@ pub(super) async fn enable_login(&self, user_id: String) -> Result {
|
|||||||
|
|
||||||
self.write_str(&format!("{user_id} can now log in.")).await
|
self.write_str(&format!("{user_id} can now log in.")).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[admin_command]
|
||||||
|
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
|
||||||
|
.services
|
||||||
|
.threepid
|
||||||
|
.get_email_for_localpart(user_id.localpart())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
| Some(email) =>
|
||||||
|
self.write_str(&format!("{user_id} has the associated email address {email}."))
|
||||||
|
.await,
|
||||||
|
| None =>
|
||||||
|
self.write_str(&format!("{user_id} has no associated email address."))
|
||||||
|
.await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[admin_command]
|
||||||
|
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.");
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.services.threepid.get_localpart_for_email(&email).await {
|
||||||
|
| Some(localpart) => {
|
||||||
|
let user_id = OwnedUserId::parse(format!(
|
||||||
|
"@{localpart}:{}",
|
||||||
|
self.services.globals.server_name()
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
self.write_str(&format!("{email} belongs to {user_id}."))
|
||||||
|
.await
|
||||||
|
},
|
||||||
|
| None =>
|
||||||
|
self.write_str(&format!("No user has {email} as their email address."))
|
||||||
|
.await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[admin_command]
|
||||||
|
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.");
|
||||||
|
};
|
||||||
|
|
||||||
|
if self.services.mailer.mailer().is_none() {
|
||||||
|
warn!("SMTP has not been configured on this server, emails cannot be sent.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_email = self
|
||||||
|
.services
|
||||||
|
.threepid
|
||||||
|
.get_email_for_localpart(user_id.localpart())
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match (current_email, new_email) {
|
||||||
|
| (None, None) =>
|
||||||
|
self.write_str(&format!(
|
||||||
|
"{user_id} already had no associated email. No changes have been made."
|
||||||
|
))
|
||||||
|
.await,
|
||||||
|
| (current_email, Some(new_email)) => {
|
||||||
|
self.services
|
||||||
|
.threepid
|
||||||
|
.associate_localpart_email(user_id.localpart(), &new_email)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(current_email) = current_email {
|
||||||
|
self.write_str(&format!(
|
||||||
|
"The associated email of {user_id} has been changed from {current_email} to \
|
||||||
|
{new_email}."
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
self.write_str(&format!(
|
||||||
|
"{user_id} has been associated with the email {new_email}."
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
},
|
||||||
|
| (Some(current_email), None) => {
|
||||||
|
self.services
|
||||||
|
.threepid
|
||||||
|
.disassociate_localpart_email(user_id.localpart())
|
||||||
|
.await;
|
||||||
|
|
||||||
|
self.write_str(&format!(
|
||||||
|
"The associated email of {user_id} has been removed (it was {current_email})."
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,30 @@ pub enum UserCommand {
|
|||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Issue a self-service password reset link for a user.
|
||||||
|
IssuePasswordResetLink {
|
||||||
|
/// Username of the user who may use the link
|
||||||
|
username: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Get a user's associated email address.
|
||||||
|
GetEmail {
|
||||||
|
user_id: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Get the user with the given email address.
|
||||||
|
GetUserByEmail {
|
||||||
|
email: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Update or remove a user's email address.
|
||||||
|
///
|
||||||
|
/// If `email` is not supplied, the user's existing address will be removed.
|
||||||
|
ChangeEmail {
|
||||||
|
user_id: String,
|
||||||
|
email: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
/// Deactivate a user
|
/// Deactivate a user
|
||||||
///
|
///
|
||||||
/// User will be removed from all rooms by default.
|
/// User will be removed from all rooms by default.
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ http-body-util.workspace = true
|
|||||||
hyper.workspace = true
|
hyper.workspace = true
|
||||||
ipaddress.workspace = true
|
ipaddress.workspace = true
|
||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
|
lettre.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
|
|||||||
@@ -1,980 +0,0 @@
|
|||||||
use std::fmt::Write;
|
|
||||||
|
|
||||||
use axum::extract::State;
|
|
||||||
use axum_client_ip::InsecureClientIp;
|
|
||||||
use conduwuit::{
|
|
||||||
Err, Error, Event, Result, debug_info, err, error, info,
|
|
||||||
matrix::pdu::PduBuilder,
|
|
||||||
utils::{self, ReadyExt, stream::BroadbandExt},
|
|
||||||
warn,
|
|
||||||
};
|
|
||||||
use conduwuit_service::Services;
|
|
||||||
use futures::{FutureExt, StreamExt};
|
|
||||||
use register::RegistrationKind;
|
|
||||||
use ruma::{
|
|
||||||
OwnedRoomId, UserId,
|
|
||||||
api::client::{
|
|
||||||
account::{
|
|
||||||
ThirdPartyIdRemovalStatus, change_password, check_registration_token_validity,
|
|
||||||
deactivate, get_3pids, get_username_availability,
|
|
||||||
register::{self, LoginType},
|
|
||||||
request_3pid_management_token_via_email, request_3pid_management_token_via_msisdn,
|
|
||||||
whoami,
|
|
||||||
},
|
|
||||||
uiaa::{AuthFlow, AuthType, UiaaInfo},
|
|
||||||
},
|
|
||||||
events::{
|
|
||||||
GlobalAccountDataEventType, StateEventType,
|
|
||||||
room::{
|
|
||||||
member::{MembershipState, RoomMemberEventContent},
|
|
||||||
message::RoomMessageEventContent,
|
|
||||||
power_levels::{RoomPowerLevels, RoomPowerLevelsEventContent},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
push,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::{DEVICE_ID_LENGTH, SESSION_ID_LENGTH, TOKEN_LENGTH, join_room_by_id_helper};
|
|
||||||
use crate::Ruma;
|
|
||||||
|
|
||||||
const RANDOM_USER_ID_LENGTH: usize = 10;
|
|
||||||
|
|
||||||
/// # `GET /_matrix/client/v3/register/available`
|
|
||||||
///
|
|
||||||
/// Checks if a username is valid and available on this server.
|
|
||||||
///
|
|
||||||
/// Conditions for returning true:
|
|
||||||
/// - The user id is not historical
|
|
||||||
/// - The server name of the user id matches this server
|
|
||||||
/// - No user or appservice on this server already claimed this username
|
|
||||||
///
|
|
||||||
/// Note: This will not reserve the username, so the username might become
|
|
||||||
/// invalid when trying to register
|
|
||||||
#[tracing::instrument(skip_all, fields(%client), name = "register_available", level = "info")]
|
|
||||||
pub(crate) async fn get_register_available_route(
|
|
||||||
State(services): State<crate::State>,
|
|
||||||
InsecureClientIp(client): InsecureClientIp,
|
|
||||||
body: Ruma<get_username_availability::v3::Request>,
|
|
||||||
) -> Result<get_username_availability::v3::Response> {
|
|
||||||
// workaround for https://github.com/matrix-org/matrix-appservice-irc/issues/1780 due to inactivity of fixing the issue
|
|
||||||
let is_matrix_appservice_irc = body.appservice_info.as_ref().is_some_and(|appservice| {
|
|
||||||
appservice.registration.id == "irc"
|
|
||||||
|| appservice.registration.id.contains("matrix-appservice-irc")
|
|
||||||
|| appservice.registration.id.contains("matrix_appservice_irc")
|
|
||||||
});
|
|
||||||
|
|
||||||
if services
|
|
||||||
.globals
|
|
||||||
.forbidden_usernames()
|
|
||||||
.is_match(&body.username)
|
|
||||||
{
|
|
||||||
return Err!(Request(Forbidden("Username is forbidden")));
|
|
||||||
}
|
|
||||||
|
|
||||||
// don't force the username lowercase if it's from matrix-appservice-irc
|
|
||||||
let body_username = if is_matrix_appservice_irc {
|
|
||||||
body.username.clone()
|
|
||||||
} else {
|
|
||||||
body.username.to_lowercase()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validate user id
|
|
||||||
let user_id =
|
|
||||||
match UserId::parse_with_server_name(&body_username, services.globals.server_name()) {
|
|
||||||
| Ok(user_id) => {
|
|
||||||
if let Err(e) = user_id.validate_strict() {
|
|
||||||
// unless the username is from the broken matrix appservice IRC bridge, we
|
|
||||||
// should follow synapse's behaviour on not allowing things like spaces
|
|
||||||
// and UTF-8 characters in usernames
|
|
||||||
if !is_matrix_appservice_irc {
|
|
||||||
return Err!(Request(InvalidUsername(debug_warn!(
|
|
||||||
"Username {body_username} contains disallowed characters or spaces: \
|
|
||||||
{e}"
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
user_id
|
|
||||||
},
|
|
||||||
| Err(e) => {
|
|
||||||
return Err!(Request(InvalidUsername(debug_warn!(
|
|
||||||
"Username {body_username} is not valid: {e}"
|
|
||||||
))));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if username is creative enough
|
|
||||||
if services.users.exists(&user_id).await {
|
|
||||||
return Err!(Request(UserInUse("User ID is not available.")));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref info) = body.appservice_info {
|
|
||||||
if !info.is_user_match(&user_id) {
|
|
||||||
return Err!(Request(Exclusive("Username is not in an appservice namespace.")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if services.appservice.is_exclusive_user_id(&user_id).await {
|
|
||||||
return Err!(Request(Exclusive("Username is reserved by an appservice.")));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(get_username_availability::v3::Response { available: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// # `POST /_matrix/client/v3/register`
|
|
||||||
///
|
|
||||||
/// Register an account on this homeserver.
|
|
||||||
///
|
|
||||||
/// You can use [`GET
|
|
||||||
/// /_matrix/client/v3/register/available`](fn.get_register_available_route.
|
|
||||||
/// html) to check if the user id is valid and available.
|
|
||||||
///
|
|
||||||
/// - Only works if registration is enabled
|
|
||||||
/// - If type is guest: ignores all parameters except
|
|
||||||
/// initial_device_display_name
|
|
||||||
/// - If sender is not appservice: Requires UIAA (but we only use a dummy stage)
|
|
||||||
/// - If type is not guest and no username is given: Always fails after UIAA
|
|
||||||
/// check
|
|
||||||
/// - Creates a new account and populates it with default account data
|
|
||||||
/// - If `inhibit_login` is false: Creates a device and returns device id and
|
|
||||||
/// access_token
|
|
||||||
#[allow(clippy::doc_markdown)]
|
|
||||||
#[tracing::instrument(skip_all, fields(%client), name = "register", level = "info")]
|
|
||||||
pub(crate) async fn register_route(
|
|
||||||
State(services): State<crate::State>,
|
|
||||||
InsecureClientIp(client): InsecureClientIp,
|
|
||||||
body: Ruma<register::v3::Request>,
|
|
||||||
) -> Result<register::v3::Response> {
|
|
||||||
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() {
|
|
||||||
match (body.username.as_ref(), body.initial_device_display_name.as_ref()) {
|
|
||||||
| (Some(username), Some(device_display_name)) => {
|
|
||||||
info!(
|
|
||||||
%is_guest,
|
|
||||||
user = %username,
|
|
||||||
device_name = %device_display_name,
|
|
||||||
"Rejecting registration attempt as registration is disabled"
|
|
||||||
);
|
|
||||||
},
|
|
||||||
| (Some(username), _) => {
|
|
||||||
info!(
|
|
||||||
%is_guest,
|
|
||||||
user = %username,
|
|
||||||
"Rejecting registration attempt as registration is disabled"
|
|
||||||
);
|
|
||||||
},
|
|
||||||
| (_, Some(device_display_name)) => {
|
|
||||||
info!(
|
|
||||||
%is_guest,
|
|
||||||
device_name = %device_display_name,
|
|
||||||
"Rejecting registration attempt as registration is disabled"
|
|
||||||
);
|
|
||||||
},
|
|
||||||
| (None, _) => {
|
|
||||||
info!(
|
|
||||||
%is_guest,
|
|
||||||
"Rejecting registration attempt as registration is disabled"
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return Err!(Request(Forbidden(
|
|
||||||
"This server is not accepting registrations at this time."
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if is_guest && !services.config.allow_guest_registration {
|
|
||||||
info!(
|
|
||||||
"Guest registration disabled, rejecting guest registration attempt, initial device \
|
|
||||||
name: \"{}\"",
|
|
||||||
body.initial_device_display_name.as_deref().unwrap_or("")
|
|
||||||
);
|
|
||||||
return Err!(Request(GuestAccessForbidden("Guest registration is disabled.")));
|
|
||||||
}
|
|
||||||
|
|
||||||
// forbid guests from registering if there is not a real admin user yet. give
|
|
||||||
// generic user error.
|
|
||||||
if is_guest && services.users.count().await < 2 {
|
|
||||||
warn!(
|
|
||||||
"Guest account attempted to register before a real admin user has been registered, \
|
|
||||||
rejecting registration. Guest's initial device name: \"{}\"",
|
|
||||||
body.initial_device_display_name.as_deref().unwrap_or("")
|
|
||||||
);
|
|
||||||
return Err!(Request(Forbidden(
|
|
||||||
"This server is not accepting registrations at this time."
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let user_id = match (body.username.as_ref(), is_guest) {
|
|
||||||
| (Some(username), false) => {
|
|
||||||
// workaround for https://github.com/matrix-org/matrix-appservice-irc/issues/1780 due to inactivity of fixing the issue
|
|
||||||
let is_matrix_appservice_irc =
|
|
||||||
body.appservice_info.as_ref().is_some_and(|appservice| {
|
|
||||||
appservice.registration.id == "irc"
|
|
||||||
|| appservice.registration.id.contains("matrix-appservice-irc")
|
|
||||||
|| appservice.registration.id.contains("matrix_appservice_irc")
|
|
||||||
});
|
|
||||||
|
|
||||||
if services.globals.forbidden_usernames().is_match(username)
|
|
||||||
&& !emergency_mode_enabled
|
|
||||||
{
|
|
||||||
return Err!(Request(Forbidden("Username is forbidden")));
|
|
||||||
}
|
|
||||||
|
|
||||||
// don't force the username lowercase if it's from matrix-appservice-irc
|
|
||||||
let body_username = if is_matrix_appservice_irc {
|
|
||||||
username.clone()
|
|
||||||
} else {
|
|
||||||
username.to_lowercase()
|
|
||||||
};
|
|
||||||
|
|
||||||
let proposed_user_id = match UserId::parse_with_server_name(
|
|
||||||
&body_username,
|
|
||||||
services.globals.server_name(),
|
|
||||||
) {
|
|
||||||
| Ok(user_id) => {
|
|
||||||
if let Err(e) = user_id.validate_strict() {
|
|
||||||
// unless the username is from the broken matrix appservice IRC bridge, or
|
|
||||||
// we are in emergency mode, we should follow synapse's behaviour on
|
|
||||||
// not allowing things like spaces and UTF-8 characters in usernames
|
|
||||||
if !is_matrix_appservice_irc && !emergency_mode_enabled {
|
|
||||||
return Err!(Request(InvalidUsername(debug_warn!(
|
|
||||||
"Username {body_username} contains disallowed characters or \
|
|
||||||
spaces: {e}"
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't allow registration with user IDs that aren't local
|
|
||||||
if !services.globals.user_is_local(&user_id) {
|
|
||||||
return Err!(Request(InvalidUsername(
|
|
||||||
"Username {body_username} is not local to this server"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
user_id
|
|
||||||
},
|
|
||||||
| Err(e) => {
|
|
||||||
return Err!(Request(InvalidUsername(debug_warn!(
|
|
||||||
"Username {body_username} is not valid: {e}"
|
|
||||||
))));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if services.users.exists(&proposed_user_id).await {
|
|
||||||
return Err!(Request(UserInUse("User ID is not available.")));
|
|
||||||
}
|
|
||||||
|
|
||||||
proposed_user_id
|
|
||||||
},
|
|
||||||
| _ => loop {
|
|
||||||
let proposed_user_id = UserId::parse_with_server_name(
|
|
||||||
utils::random_string(RANDOM_USER_ID_LENGTH).to_lowercase(),
|
|
||||||
services.globals.server_name(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
if !services.users.exists(&proposed_user_id).await {
|
|
||||||
break proposed_user_id;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if body.body.login_type == Some(LoginType::ApplicationService) {
|
|
||||||
match body.appservice_info {
|
|
||||||
| Some(ref info) =>
|
|
||||||
if !info.is_user_match(&user_id) && !emergency_mode_enabled {
|
|
||||||
return Err!(Request(Exclusive(
|
|
||||||
"Username is not in an appservice namespace."
|
|
||||||
)));
|
|
||||||
},
|
|
||||||
| _ => {
|
|
||||||
return Err!(Request(MissingToken("Missing appservice token.")));
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} else if services.appservice.is_exclusive_user_id(&user_id).await && !emergency_mode_enabled
|
|
||||||
{
|
|
||||||
return Err!(Request(Exclusive("Username is reserved by an appservice.")));
|
|
||||||
}
|
|
||||||
|
|
||||||
// UIAA
|
|
||||||
let mut uiaainfo = UiaaInfo {
|
|
||||||
flows: Vec::new(),
|
|
||||||
completed: Vec::new(),
|
|
||||||
params: Box::default(),
|
|
||||||
session: None,
|
|
||||||
auth_error: None,
|
|
||||||
};
|
|
||||||
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
|
|
||||||
uiaainfo.flows.push(AuthFlow {
|
|
||||||
stages: vec![AuthType::RegistrationToken],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if services
|
|
||||||
.registration_tokens
|
|
||||||
.iterate_tokens()
|
|
||||||
.next()
|
|
||||||
.await
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
// Registration token required
|
|
||||||
uiaainfo.flows.push(AuthFlow {
|
|
||||||
stages: vec![AuthType::RegistrationToken],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !skip_auth {
|
|
||||||
match &body.auth {
|
|
||||||
| Some(auth) => {
|
|
||||||
let (worked, uiaainfo) = services
|
|
||||||
.uiaa
|
|
||||||
.try_auth(
|
|
||||||
&UserId::parse_with_server_name("", services.globals.server_name())
|
|
||||||
.unwrap(),
|
|
||||||
"".into(),
|
|
||||||
auth,
|
|
||||||
&uiaainfo,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
if !worked {
|
|
||||||
return Err(Error::Uiaa(uiaainfo));
|
|
||||||
}
|
|
||||||
// Success!
|
|
||||||
},
|
|
||||||
| _ => match body.json_body {
|
|
||||||
| Some(ref json) => {
|
|
||||||
uiaainfo.session = Some(utils::random_string(SESSION_ID_LENGTH));
|
|
||||||
services.uiaa.create(
|
|
||||||
&UserId::parse_with_server_name("", services.globals.server_name())
|
|
||||||
.unwrap(),
|
|
||||||
"".into(),
|
|
||||||
&uiaainfo,
|
|
||||||
json,
|
|
||||||
);
|
|
||||||
return Err(Error::Uiaa(uiaainfo));
|
|
||||||
},
|
|
||||||
| _ => {
|
|
||||||
return Err!(Request(NotJson("JSON body is not valid")));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let password = if is_guest { None } else { body.password.as_deref() };
|
|
||||||
|
|
||||||
// Create user
|
|
||||||
services.users.create(&user_id, password, None).await?;
|
|
||||||
|
|
||||||
// Default to pretty displayname
|
|
||||||
let mut displayname = user_id.localpart().to_owned();
|
|
||||||
|
|
||||||
// If `new_user_displayname_suffix` is set, registration will push whatever
|
|
||||||
// content is set to the user's display name with a space before it
|
|
||||||
if !services.globals.new_user_displayname_suffix().is_empty()
|
|
||||||
&& body.appservice_info.is_none()
|
|
||||||
{
|
|
||||||
write!(displayname, " {}", services.server.config.new_user_displayname_suffix)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
services
|
|
||||||
.users
|
|
||||||
.set_displayname(&user_id, Some(displayname.clone()));
|
|
||||||
|
|
||||||
// Initial account data
|
|
||||||
services
|
|
||||||
.account_data
|
|
||||||
.update(
|
|
||||||
None,
|
|
||||||
&user_id,
|
|
||||||
GlobalAccountDataEventType::PushRules.to_string().into(),
|
|
||||||
&serde_json::to_value(ruma::events::push_rules::PushRulesEvent {
|
|
||||||
content: ruma::events::push_rules::PushRulesEventContent {
|
|
||||||
global: push::Ruleset::server_default(&user_id),
|
|
||||||
},
|
|
||||||
})?,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Generate new device id if the user didn't specify one
|
|
||||||
let no_device = body.inhibit_login
|
|
||||||
|| body
|
|
||||||
.appservice_info
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|aps| aps.registration.device_management);
|
|
||||||
let (token, device) = if !no_device {
|
|
||||||
// Don't create a device for inhibited logins
|
|
||||||
let device_id = if is_guest { None } else { body.device_id.clone() }
|
|
||||||
.unwrap_or_else(|| utils::random_string(DEVICE_ID_LENGTH).into());
|
|
||||||
|
|
||||||
// Generate new token for the device
|
|
||||||
let new_token = utils::random_string(TOKEN_LENGTH);
|
|
||||||
|
|
||||||
// Create device for this account
|
|
||||||
services
|
|
||||||
.users
|
|
||||||
.create_device(
|
|
||||||
&user_id,
|
|
||||||
&device_id,
|
|
||||||
&new_token,
|
|
||||||
body.initial_device_display_name.clone(),
|
|
||||||
Some(client.to_string()),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
debug_info!(%user_id, %device_id, "User account was created");
|
|
||||||
(Some(new_token), Some(device_id))
|
|
||||||
} else {
|
|
||||||
(None, None)
|
|
||||||
};
|
|
||||||
|
|
||||||
let device_display_name = body.initial_device_display_name.as_deref().unwrap_or("");
|
|
||||||
|
|
||||||
// log in conduit admin channel if a non-guest user registered
|
|
||||||
if body.appservice_info.is_none() && !is_guest {
|
|
||||||
if !device_display_name.is_empty() {
|
|
||||||
let notice = format!(
|
|
||||||
"New user \"{user_id}\" registered on this server from IP {client} and device \
|
|
||||||
display name \"{device_display_name}\""
|
|
||||||
);
|
|
||||||
|
|
||||||
info!("{notice}");
|
|
||||||
if services.server.config.admin_room_notices {
|
|
||||||
services.admin.notice(¬ice).await;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let notice = format!("New user \"{user_id}\" registered on this server.");
|
|
||||||
|
|
||||||
info!("{notice}");
|
|
||||||
if services.server.config.admin_room_notices {
|
|
||||||
services.admin.notice(¬ice).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// log in conduit admin channel if a guest registered
|
|
||||||
if body.appservice_info.is_none() && is_guest && services.config.log_guest_registrations {
|
|
||||||
debug_info!("New guest user \"{user_id}\" registered on this server.");
|
|
||||||
|
|
||||||
if !device_display_name.is_empty() {
|
|
||||||
if services.server.config.admin_room_notices {
|
|
||||||
services
|
|
||||||
.admin
|
|
||||||
.notice(&format!(
|
|
||||||
"Guest user \"{user_id}\" with device display name \
|
|
||||||
\"{device_display_name}\" registered on this server from IP {client}"
|
|
||||||
))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
#[allow(clippy::collapsible_else_if)]
|
|
||||||
if services.server.config.admin_room_notices {
|
|
||||||
services
|
|
||||||
.admin
|
|
||||||
.notice(&format!(
|
|
||||||
"Guest user \"{user_id}\" with no device display name registered on \
|
|
||||||
this server from IP {client}",
|
|
||||||
))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
services
|
|
||||||
.admin
|
|
||||||
.send_loud_message(RoomMessageEventContent::text_plain(format!(
|
|
||||||
"User {user_id} has been suspended as they are not the first user on \
|
|
||||||
this server. Please review and unsuspend them if appropriate."
|
|
||||||
)))
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if body.appservice_info.is_none()
|
|
||||||
&& !services.server.config.auto_join_rooms.is_empty()
|
|
||||||
&& (services.config.allow_guests_auto_join_rooms || !is_guest)
|
|
||||||
{
|
|
||||||
for room in &services.server.config.auto_join_rooms {
|
|
||||||
let Ok(room_id) = services.rooms.alias.resolve(room).await else {
|
|
||||||
error!(
|
|
||||||
"Failed to resolve room alias to room ID when attempting to auto join \
|
|
||||||
{room}, skipping"
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
if !services
|
|
||||||
.rooms
|
|
||||||
.state_cache
|
|
||||||
.server_in_room(services.globals.server_name(), &room_id)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!(
|
|
||||||
"Skipping room {room} to automatically join as we have never joined before."
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(room_server_name) = room.server_name() {
|
|
||||||
match join_room_by_id_helper(
|
|
||||||
&services,
|
|
||||||
&user_id,
|
|
||||||
&room_id,
|
|
||||||
Some("Automatically joining this room upon registration".to_owned()),
|
|
||||||
&[services.globals.server_name().to_owned(), room_server_name.to_owned()],
|
|
||||||
&body.appservice_info,
|
|
||||||
)
|
|
||||||
.boxed()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
| Err(e) => {
|
|
||||||
// don't return this error so we don't fail registrations
|
|
||||||
error!(
|
|
||||||
"Failed to automatically join room {room} for user {user_id}: {e}"
|
|
||||||
);
|
|
||||||
},
|
|
||||||
| _ => {
|
|
||||||
info!("Automatically joined room {room} for user {user_id}");
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(register::v3::Response {
|
|
||||||
access_token: token,
|
|
||||||
user_id,
|
|
||||||
device_id: device,
|
|
||||||
refresh_token: None,
|
|
||||||
expires_in: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// # `POST /_matrix/client/r0/account/password`
|
|
||||||
///
|
|
||||||
/// Changes the password of this account.
|
|
||||||
///
|
|
||||||
/// - Requires UIAA to verify user password
|
|
||||||
/// - Changes the password of the sender user
|
|
||||||
/// - The password hash is calculated using argon2 with 32 character salt, the
|
|
||||||
/// plain password is
|
|
||||||
/// not saved
|
|
||||||
///
|
|
||||||
/// If logout_devices is true it does the following for each device except the
|
|
||||||
/// sender device:
|
|
||||||
/// - Invalidates access token
|
|
||||||
/// - Deletes device metadata (device id, device display name, last seen ip,
|
|
||||||
/// last seen ts)
|
|
||||||
/// - Forgets to-device events
|
|
||||||
/// - Triggers device list updates
|
|
||||||
#[tracing::instrument(skip_all, fields(%client), name = "change_password", level = "info")]
|
|
||||||
pub(crate) async fn change_password_route(
|
|
||||||
State(services): State<crate::State>,
|
|
||||||
InsecureClientIp(client): InsecureClientIp,
|
|
||||||
body: Ruma<change_password::v3::Request>,
|
|
||||||
) -> Result<change_password::v3::Response> {
|
|
||||||
// Authentication for this endpoint was made optional, but we need
|
|
||||||
// authentication currently
|
|
||||||
let sender_user = body
|
|
||||||
.sender_user
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| err!(Request(MissingToken("Missing access token."))))?;
|
|
||||||
|
|
||||||
let mut uiaainfo = UiaaInfo {
|
|
||||||
flows: vec![AuthFlow { stages: vec![AuthType::Password] }],
|
|
||||||
completed: Vec::new(),
|
|
||||||
params: Box::default(),
|
|
||||||
session: None,
|
|
||||||
auth_error: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
match &body.auth {
|
|
||||||
| Some(auth) => {
|
|
||||||
let (worked, uiaainfo) = services
|
|
||||||
.uiaa
|
|
||||||
.try_auth(sender_user, body.sender_device(), auth, &uiaainfo)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !worked {
|
|
||||||
return Err(Error::Uiaa(uiaainfo));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Success!
|
|
||||||
},
|
|
||||||
| _ => match body.json_body {
|
|
||||||
| Some(ref json) => {
|
|
||||||
uiaainfo.session = Some(utils::random_string(SESSION_ID_LENGTH));
|
|
||||||
services
|
|
||||||
.uiaa
|
|
||||||
.create(sender_user, body.sender_device(), &uiaainfo, json);
|
|
||||||
|
|
||||||
return Err(Error::Uiaa(uiaainfo));
|
|
||||||
},
|
|
||||||
| _ => {
|
|
||||||
return Err!(Request(NotJson("JSON body is not valid")));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
services
|
|
||||||
.users
|
|
||||||
.set_password(sender_user, Some(&body.new_password))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if body.logout_devices {
|
|
||||||
// Logout all devices except the current one
|
|
||||||
services
|
|
||||||
.users
|
|
||||||
.all_device_ids(sender_user)
|
|
||||||
.ready_filter(|id| *id != body.sender_device())
|
|
||||||
.for_each(|id| services.users.remove_device(sender_user, id))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Remove all pushers except the ones associated with this session
|
|
||||||
services
|
|
||||||
.pusher
|
|
||||||
.get_pushkeys(sender_user)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.broad_filter_map(async |pushkey| {
|
|
||||||
services
|
|
||||||
.pusher
|
|
||||||
.get_pusher_device(&pushkey)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.filter(|pusher_device| pusher_device != body.sender_device())
|
|
||||||
.is_some()
|
|
||||||
.then_some(pushkey)
|
|
||||||
})
|
|
||||||
.for_each(async |pushkey| {
|
|
||||||
services.pusher.delete_pusher(sender_user, &pushkey).await;
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("User {sender_user} changed their password.");
|
|
||||||
|
|
||||||
if services.server.config.admin_room_notices {
|
|
||||||
services
|
|
||||||
.admin
|
|
||||||
.notice(&format!("User {sender_user} changed their password."))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(change_password::v3::Response {})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// # `GET /_matrix/client/v3/account/whoami`
|
|
||||||
///
|
|
||||||
/// Get `user_id` of the sender user.
|
|
||||||
///
|
|
||||||
/// Note: Also works for Application Services
|
|
||||||
pub(crate) async fn whoami_route(
|
|
||||||
State(services): State<crate::State>,
|
|
||||||
body: Ruma<whoami::v3::Request>,
|
|
||||||
) -> Result<whoami::v3::Response> {
|
|
||||||
let is_guest = services
|
|
||||||
.users
|
|
||||||
.is_deactivated(body.sender_user())
|
|
||||||
.await
|
|
||||||
.map_err(|_| {
|
|
||||||
err!(Request(Forbidden("Application service has not registered this user.")))
|
|
||||||
})? && body.appservice_info.is_none();
|
|
||||||
Ok(whoami::v3::Response {
|
|
||||||
user_id: body.sender_user().to_owned(),
|
|
||||||
device_id: body.sender_device.clone(),
|
|
||||||
is_guest,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// # `POST /_matrix/client/r0/account/deactivate`
|
|
||||||
///
|
|
||||||
/// Deactivate sender user account.
|
|
||||||
///
|
|
||||||
/// - Leaves all rooms and rejects all invitations
|
|
||||||
/// - Invalidates all access tokens
|
|
||||||
/// - Deletes all device metadata (device id, device display name, last seen ip,
|
|
||||||
/// last seen ts)
|
|
||||||
/// - Forgets all to-device events
|
|
||||||
/// - Triggers device list updates
|
|
||||||
/// - Removes ability to log in again
|
|
||||||
#[tracing::instrument(skip_all, fields(%client), name = "deactivate", level = "info")]
|
|
||||||
pub(crate) async fn deactivate_route(
|
|
||||||
State(services): State<crate::State>,
|
|
||||||
InsecureClientIp(client): InsecureClientIp,
|
|
||||||
body: Ruma<deactivate::v3::Request>,
|
|
||||||
) -> Result<deactivate::v3::Response> {
|
|
||||||
// Authentication for this endpoint was made optional, but we need
|
|
||||||
// authentication currently
|
|
||||||
let sender_user = body
|
|
||||||
.sender_user
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| err!(Request(MissingToken("Missing access token."))))?;
|
|
||||||
|
|
||||||
let mut uiaainfo = UiaaInfo {
|
|
||||||
flows: vec![AuthFlow { stages: vec![AuthType::Password] }],
|
|
||||||
completed: Vec::new(),
|
|
||||||
params: Box::default(),
|
|
||||||
session: None,
|
|
||||||
auth_error: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
match &body.auth {
|
|
||||||
| Some(auth) => {
|
|
||||||
let (worked, uiaainfo) = services
|
|
||||||
.uiaa
|
|
||||||
.try_auth(sender_user, body.sender_device(), auth, &uiaainfo)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !worked {
|
|
||||||
return Err(Error::Uiaa(uiaainfo));
|
|
||||||
}
|
|
||||||
// Success!
|
|
||||||
},
|
|
||||||
| _ => match body.json_body {
|
|
||||||
| Some(ref json) => {
|
|
||||||
uiaainfo.session = Some(utils::random_string(SESSION_ID_LENGTH));
|
|
||||||
services
|
|
||||||
.uiaa
|
|
||||||
.create(sender_user, body.sender_device(), &uiaainfo, json);
|
|
||||||
|
|
||||||
return Err(Error::Uiaa(uiaainfo));
|
|
||||||
},
|
|
||||||
| _ => {
|
|
||||||
return Err!(Request(NotJson("JSON body is not valid")));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove profile pictures and display name
|
|
||||||
let all_joined_rooms: Vec<OwnedRoomId> = services
|
|
||||||
.rooms
|
|
||||||
.state_cache
|
|
||||||
.rooms_joined(sender_user)
|
|
||||||
.map(Into::into)
|
|
||||||
.collect()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
full_user_deactivate(&services, sender_user, &all_joined_rooms)
|
|
||||||
.boxed()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
info!("User {sender_user} deactivated their account.");
|
|
||||||
|
|
||||||
if services.server.config.admin_room_notices {
|
|
||||||
services
|
|
||||||
.admin
|
|
||||||
.notice(&format!("User {sender_user} deactivated their account."))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(deactivate::v3::Response {
|
|
||||||
id_server_unbind_result: ThirdPartyIdRemovalStatus::NoSupport,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// # `GET _matrix/client/v3/account/3pid`
|
|
||||||
///
|
|
||||||
/// Get a list of third party identifiers associated with this account.
|
|
||||||
///
|
|
||||||
/// - Currently always returns empty list
|
|
||||||
pub(crate) async fn third_party_route(
|
|
||||||
body: Ruma<get_3pids::v3::Request>,
|
|
||||||
) -> Result<get_3pids::v3::Response> {
|
|
||||||
let _sender_user = body.sender_user.as_ref().expect("user is authenticated");
|
|
||||||
|
|
||||||
Ok(get_3pids::v3::Response::new(Vec::new()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// # `POST /_matrix/client/v3/account/3pid/email/requestToken`
|
|
||||||
///
|
|
||||||
/// "This API should be used to request validation tokens when adding an email
|
|
||||||
/// address to an account"
|
|
||||||
///
|
|
||||||
/// - 403 signals that The homeserver does not allow the third party identifier
|
|
||||||
/// as a contact option.
|
|
||||||
pub(crate) async fn request_3pid_management_token_via_email_route(
|
|
||||||
_body: Ruma<request_3pid_management_token_via_email::v3::Request>,
|
|
||||||
) -> Result<request_3pid_management_token_via_email::v3::Response> {
|
|
||||||
Err!(Request(ThreepidDenied("Third party identifiers are not implemented")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// # `POST /_matrix/client/v3/account/3pid/msisdn/requestToken`
|
|
||||||
///
|
|
||||||
/// "This API should be used to request validation tokens when adding an phone
|
|
||||||
/// number to an account"
|
|
||||||
///
|
|
||||||
/// - 403 signals that The homeserver does not allow the third party identifier
|
|
||||||
/// as a contact option.
|
|
||||||
pub(crate) async fn request_3pid_management_token_via_msisdn_route(
|
|
||||||
_body: Ruma<request_3pid_management_token_via_msisdn::v3::Request>,
|
|
||||||
) -> Result<request_3pid_management_token_via_msisdn::v3::Response> {
|
|
||||||
Err!(Request(ThreepidDenied("Third party identifiers are not implemented")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// # `GET /_matrix/client/v1/register/m.login.registration_token/validity`
|
|
||||||
///
|
|
||||||
/// Checks if the provided registration token is valid at the time of checking.
|
|
||||||
pub(crate) async fn check_registration_token_validity(
|
|
||||||
State(services): State<crate::State>,
|
|
||||||
body: Ruma<check_registration_token_validity::v1::Request>,
|
|
||||||
) -> Result<check_registration_token_validity::v1::Response> {
|
|
||||||
// TODO: ratelimit this pretty heavily
|
|
||||||
|
|
||||||
let valid = services
|
|
||||||
.registration_tokens
|
|
||||||
.validate_token(body.token.clone())
|
|
||||||
.await
|
|
||||||
.is_some();
|
|
||||||
|
|
||||||
Ok(check_registration_token_validity::v1::Response { valid })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs through all the deactivation steps:
|
|
||||||
///
|
|
||||||
/// - Mark as deactivated
|
|
||||||
/// - Removing display name
|
|
||||||
/// - Removing avatar URL and blurhash
|
|
||||||
/// - Removing all profile data
|
|
||||||
/// - Leaving all rooms (and forgets all of them)
|
|
||||||
pub async fn full_user_deactivate(
|
|
||||||
services: &Services,
|
|
||||||
user_id: &UserId,
|
|
||||||
all_joined_rooms: &[OwnedRoomId],
|
|
||||||
) -> Result<()> {
|
|
||||||
services.users.deactivate_account(user_id).await.ok();
|
|
||||||
|
|
||||||
services
|
|
||||||
.users
|
|
||||||
.all_profile_keys(user_id)
|
|
||||||
.ready_for_each(|(profile_key, _)| {
|
|
||||||
services.users.set_profile_key(user_id, &profile_key, None);
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// TODO: Rescind all user invites
|
|
||||||
|
|
||||||
let mut pdu_queue: Vec<(PduBuilder, &OwnedRoomId)> = Vec::new();
|
|
||||||
|
|
||||||
for room_id in all_joined_rooms {
|
|
||||||
let room_power_levels = services
|
|
||||||
.rooms
|
|
||||||
.state_accessor
|
|
||||||
.room_state_get_content::<RoomPowerLevelsEventContent>(
|
|
||||||
room_id,
|
|
||||||
&StateEventType::RoomPowerLevels,
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
let user_can_demote_self =
|
|
||||||
room_power_levels
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|power_levels_content| {
|
|
||||||
RoomPowerLevels::from(power_levels_content.clone())
|
|
||||||
.user_can_change_user_power_level(user_id, user_id)
|
|
||||||
}) || services
|
|
||||||
.rooms
|
|
||||||
.state_accessor
|
|
||||||
.room_state_get(room_id, &StateEventType::RoomCreate, "")
|
|
||||||
.await
|
|
||||||
.is_ok_and(|event| event.sender() == user_id);
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
use axum::extract::State;
|
||||||
|
use axum_client_ip::InsecureClientIp;
|
||||||
|
use conduwuit::{
|
||||||
|
Err, Event, Result, err, info,
|
||||||
|
pdu::PduBuilder,
|
||||||
|
utils::{ReadyExt, stream::BroadbandExt},
|
||||||
|
};
|
||||||
|
use conduwuit_service::Services;
|
||||||
|
use futures::{FutureExt, StreamExt};
|
||||||
|
use lettre::{Address, message::Mailbox};
|
||||||
|
use ruma::{
|
||||||
|
OwnedRoomId, OwnedUserId, UserId,
|
||||||
|
api::client::{
|
||||||
|
account::{
|
||||||
|
ThirdPartyIdRemovalStatus, change_password, check_registration_token_validity,
|
||||||
|
deactivate, get_username_availability, request_password_change_token_via_email,
|
||||||
|
whoami,
|
||||||
|
},
|
||||||
|
uiaa::{AuthFlow, AuthType},
|
||||||
|
},
|
||||||
|
events::{
|
||||||
|
StateEventType,
|
||||||
|
room::{
|
||||||
|
member::{MembershipState, RoomMemberEventContent},
|
||||||
|
power_levels::{RoomPowerLevels, RoomPowerLevelsEventContent},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use service::{mailer::messages, uiaa::Identity};
|
||||||
|
|
||||||
|
use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH, join_room_by_id_helper};
|
||||||
|
use crate::Ruma;
|
||||||
|
|
||||||
|
pub(crate) mod register;
|
||||||
|
pub(crate) mod threepid;
|
||||||
|
|
||||||
|
/// # `GET /_matrix/client/v3/register/available`
|
||||||
|
///
|
||||||
|
/// Checks if a username is valid and available on this server.
|
||||||
|
///
|
||||||
|
/// Conditions for returning true:
|
||||||
|
/// - The user id is not historical
|
||||||
|
/// - The server name of the user id matches this server
|
||||||
|
/// - No user or appservice on this server already claimed this username
|
||||||
|
///
|
||||||
|
/// Note: This will not reserve the username, so the username might become
|
||||||
|
/// invalid when trying to register
|
||||||
|
#[tracing::instrument(skip_all, fields(%client), name = "register_available", level = "info")]
|
||||||
|
pub(crate) async fn get_register_available_route(
|
||||||
|
State(services): State<crate::State>,
|
||||||
|
InsecureClientIp(client): InsecureClientIp,
|
||||||
|
body: Ruma<get_username_availability::v3::Request>,
|
||||||
|
) -> Result<get_username_availability::v3::Response> {
|
||||||
|
// Validate user id
|
||||||
|
let user_id =
|
||||||
|
match UserId::parse_with_server_name(&body.username, services.globals.server_name()) {
|
||||||
|
| Ok(user_id) => {
|
||||||
|
if let Err(e) = user_id.validate_strict() {
|
||||||
|
return Err!(Request(InvalidUsername(debug_warn!(
|
||||||
|
"Username {} contains disallowed characters or spaces: {e}",
|
||||||
|
body.username
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
|
||||||
|
user_id
|
||||||
|
},
|
||||||
|
| Err(e) => {
|
||||||
|
return Err!(Request(InvalidUsername(debug_warn!(
|
||||||
|
"Username {} is not valid: {e}",
|
||||||
|
body.username
|
||||||
|
))));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if username is creative enough
|
||||||
|
if services.users.exists(&user_id).await {
|
||||||
|
return Err!(Request(UserInUse("User ID is not available.")));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref info) = body.appservice_info {
|
||||||
|
if !info.is_user_match(&user_id) {
|
||||||
|
return Err!(Request(Exclusive("Username is not in an appservice namespace.")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if services.appservice.is_exclusive_user_id(&user_id).await {
|
||||||
|
return Err!(Request(Exclusive("Username is reserved by an appservice.")));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(get_username_availability::v3::Response { available: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # `POST /_matrix/client/r0/account/password`
|
||||||
|
///
|
||||||
|
/// Changes the password of this account.
|
||||||
|
///
|
||||||
|
/// - Requires UIAA to verify user password
|
||||||
|
/// - Changes the password of the sender user
|
||||||
|
/// - The password hash is calculated using argon2 with 32 character salt, the
|
||||||
|
/// plain password is
|
||||||
|
/// not saved
|
||||||
|
///
|
||||||
|
/// If logout_devices is true it does the following for each device except the
|
||||||
|
/// sender device:
|
||||||
|
/// - Invalidates access token
|
||||||
|
/// - Deletes device metadata (device id, device display name, last seen ip,
|
||||||
|
/// last seen ts)
|
||||||
|
/// - Forgets to-device events
|
||||||
|
/// - Triggers device list updates
|
||||||
|
#[tracing::instrument(skip_all, fields(%client), name = "change_password", level = "info")]
|
||||||
|
pub(crate) async fn change_password_route(
|
||||||
|
State(services): State<crate::State>,
|
||||||
|
InsecureClientIp(client): InsecureClientIp,
|
||||||
|
body: Ruma<change_password::v3::Request>,
|
||||||
|
) -> Result<change_password::v3::Response> {
|
||||||
|
let identity = if let Some(ref user_id) = body.sender_user {
|
||||||
|
// A signed-in user is trying to change their password, prompt them for their
|
||||||
|
// existing one
|
||||||
|
|
||||||
|
services
|
||||||
|
.uiaa
|
||||||
|
.authenticate(
|
||||||
|
&body.auth,
|
||||||
|
vec![AuthFlow::new(vec![AuthType::Password])],
|
||||||
|
Box::default(),
|
||||||
|
Some(Identity::from_user_id(user_id)),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
// A signed-out user is trying to reset their password, prompt them for email
|
||||||
|
// confirmation. Note that we do not _send_ an email here, their client should
|
||||||
|
// have already hit `/account/password/requestToken` to send the email. We
|
||||||
|
// just validate it.
|
||||||
|
|
||||||
|
services
|
||||||
|
.uiaa
|
||||||
|
.authenticate(
|
||||||
|
&body.auth,
|
||||||
|
vec![AuthFlow::new(vec![AuthType::EmailIdentity])],
|
||||||
|
Box::default(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
|
||||||
|
let sender_user = OwnedUserId::parse(format!(
|
||||||
|
"@{}:{}",
|
||||||
|
identity.localpart.expect("localpart should be known"),
|
||||||
|
services.globals.server_name()
|
||||||
|
))
|
||||||
|
.expect("user ID should be valid");
|
||||||
|
|
||||||
|
services
|
||||||
|
.users
|
||||||
|
.set_password(&sender_user, Some(&body.new_password))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if body.logout_devices {
|
||||||
|
// Logout all devices except the current one
|
||||||
|
services
|
||||||
|
.users
|
||||||
|
.all_device_ids(&sender_user)
|
||||||
|
.ready_filter(|id| *id != body.sender_device())
|
||||||
|
.for_each(|id| services.users.remove_device(&sender_user, id))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Remove all pushers except the ones associated with this session
|
||||||
|
services
|
||||||
|
.pusher
|
||||||
|
.get_pushkeys(&sender_user)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.broad_filter_map(async |pushkey| {
|
||||||
|
services
|
||||||
|
.pusher
|
||||||
|
.get_pusher_device(&pushkey)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.filter(|pusher_device| pusher_device != body.sender_device())
|
||||||
|
.is_some()
|
||||||
|
.then_some(pushkey)
|
||||||
|
})
|
||||||
|
.for_each(async |pushkey| {
|
||||||
|
services.pusher.delete_pusher(&sender_user, &pushkey).await;
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("User {} changed their password.", &sender_user);
|
||||||
|
|
||||||
|
if services.server.config.admin_room_notices {
|
||||||
|
services
|
||||||
|
.admin
|
||||||
|
.notice(&format!("User {} changed their password.", &sender_user))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(change_password::v3::Response {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # `POST /_matrix/client/v3/account/password/email/requestToken`
|
||||||
|
///
|
||||||
|
/// Requests a validation email for the purpose of resetting a user's password.
|
||||||
|
pub(crate) async fn request_password_change_token_via_email_route(
|
||||||
|
State(services): State<crate::State>,
|
||||||
|
body: Ruma<request_password_change_token_via_email::v3::Request>,
|
||||||
|
) -> Result<request_password_change_token_via_email::v3::Response> {
|
||||||
|
let Ok(email) = Address::try_from(body.email.clone()) else {
|
||||||
|
return Err!(Request(InvalidParam("Invalid email address.")));
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(localpart) = services.threepid.get_localpart_for_email(&email).await else {
|
||||||
|
return Err!(Request(ThreepidNotFound(
|
||||||
|
"No account is associated with this email address"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
let user_id =
|
||||||
|
OwnedUserId::parse(format!("@{localpart}:{}", services.globals.server_name())).unwrap();
|
||||||
|
let display_name = services.users.displayname(&user_id).await.ok();
|
||||||
|
|
||||||
|
let session = services
|
||||||
|
.threepid
|
||||||
|
.send_validation_email(
|
||||||
|
Mailbox::new(display_name.clone(), email),
|
||||||
|
|verification_link| messages::PasswordReset {
|
||||||
|
display_name: display_name.as_deref(),
|
||||||
|
user_id: &user_id,
|
||||||
|
verification_link,
|
||||||
|
},
|
||||||
|
&body.client_secret,
|
||||||
|
body.send_attempt.try_into().unwrap(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(request_password_change_token_via_email::v3::Response::new(session))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # `GET /_matrix/client/v3/account/whoami`
|
||||||
|
///
|
||||||
|
/// Get `user_id` of the sender user.
|
||||||
|
///
|
||||||
|
/// Note: Also works for Application Services
|
||||||
|
pub(crate) async fn whoami_route(
|
||||||
|
State(services): State<crate::State>,
|
||||||
|
body: Ruma<whoami::v3::Request>,
|
||||||
|
) -> Result<whoami::v3::Response> {
|
||||||
|
let is_guest = services
|
||||||
|
.users
|
||||||
|
.is_deactivated(body.sender_user())
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
err!(Request(Forbidden("Application service has not registered this user.")))
|
||||||
|
})? && body.appservice_info.is_none();
|
||||||
|
Ok(whoami::v3::Response {
|
||||||
|
user_id: body.sender_user().to_owned(),
|
||||||
|
device_id: body.sender_device.clone(),
|
||||||
|
is_guest,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # `POST /_matrix/client/r0/account/deactivate`
|
||||||
|
///
|
||||||
|
/// Deactivate sender user account.
|
||||||
|
///
|
||||||
|
/// - Leaves all rooms and rejects all invitations
|
||||||
|
/// - Invalidates all access tokens
|
||||||
|
/// - Deletes all device metadata (device id, device display name, last seen ip,
|
||||||
|
/// last seen ts)
|
||||||
|
/// - Forgets all to-device events
|
||||||
|
/// - Triggers device list updates
|
||||||
|
/// - Removes ability to log in again
|
||||||
|
#[tracing::instrument(skip_all, fields(%client), name = "deactivate", level = "info")]
|
||||||
|
pub(crate) async fn deactivate_route(
|
||||||
|
State(services): State<crate::State>,
|
||||||
|
InsecureClientIp(client): InsecureClientIp,
|
||||||
|
body: Ruma<deactivate::v3::Request>,
|
||||||
|
) -> Result<deactivate::v3::Response> {
|
||||||
|
// Authentication for this endpoint is technically optional,
|
||||||
|
// but we require the user to be logged in
|
||||||
|
let sender_user = body
|
||||||
|
.sender_user
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| err!(Request(MissingToken("Missing access token."))))?;
|
||||||
|
|
||||||
|
// Prompt the user to confirm with their password using UIAA
|
||||||
|
let _ = services
|
||||||
|
.uiaa
|
||||||
|
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Remove profile pictures and display name
|
||||||
|
let all_joined_rooms: Vec<OwnedRoomId> = services
|
||||||
|
.rooms
|
||||||
|
.state_cache
|
||||||
|
.rooms_joined(sender_user)
|
||||||
|
.map(Into::into)
|
||||||
|
.collect()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
full_user_deactivate(&services, sender_user, &all_joined_rooms)
|
||||||
|
.boxed()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
info!("User {sender_user} deactivated their account.");
|
||||||
|
|
||||||
|
if services.server.config.admin_room_notices {
|
||||||
|
services
|
||||||
|
.admin
|
||||||
|
.notice(&format!("User {sender_user} deactivated their account."))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(deactivate::v3::Response {
|
||||||
|
id_server_unbind_result: ThirdPartyIdRemovalStatus::Success,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # `GET /_matrix/client/v1/register/m.login.registration_token/validity`
|
||||||
|
///
|
||||||
|
/// Checks if the provided registration token is valid at the time of checking.
|
||||||
|
pub(crate) async fn check_registration_token_validity(
|
||||||
|
State(services): State<crate::State>,
|
||||||
|
body: Ruma<check_registration_token_validity::v1::Request>,
|
||||||
|
) -> Result<check_registration_token_validity::v1::Response> {
|
||||||
|
// TODO: ratelimit this pretty heavily
|
||||||
|
|
||||||
|
let valid = services
|
||||||
|
.registration_tokens
|
||||||
|
.validate_token(body.token.clone())
|
||||||
|
.await
|
||||||
|
.is_some();
|
||||||
|
|
||||||
|
Ok(check_registration_token_validity::v1::Response { valid })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs through all the deactivation steps:
|
||||||
|
///
|
||||||
|
/// - Mark as deactivated
|
||||||
|
/// - Removing display name
|
||||||
|
/// - Removing avatar URL and blurhash
|
||||||
|
/// - Removing all profile data
|
||||||
|
/// - Leaving all rooms (and forgets all of them)
|
||||||
|
pub async fn full_user_deactivate(
|
||||||
|
services: &Services,
|
||||||
|
user_id: &UserId,
|
||||||
|
all_joined_rooms: &[OwnedRoomId],
|
||||||
|
) -> Result<()> {
|
||||||
|
services.users.deactivate_account(user_id).await.ok();
|
||||||
|
|
||||||
|
if services.globals.user_is_local(user_id) {
|
||||||
|
let _ = services
|
||||||
|
.threepid
|
||||||
|
.disassociate_localpart_email(user_id.localpart())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
services
|
||||||
|
.users
|
||||||
|
.all_profile_keys(user_id)
|
||||||
|
.ready_for_each(|(profile_key, _)| {
|
||||||
|
services.users.set_profile_key(user_id, &profile_key, None);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// TODO: Rescind all user invites
|
||||||
|
|
||||||
|
let mut pdu_queue: Vec<(PduBuilder, &OwnedRoomId)> = Vec::new();
|
||||||
|
|
||||||
|
for room_id in all_joined_rooms {
|
||||||
|
let room_power_levels = services
|
||||||
|
.rooms
|
||||||
|
.state_accessor
|
||||||
|
.room_state_get_content::<RoomPowerLevelsEventContent>(
|
||||||
|
room_id,
|
||||||
|
&StateEventType::RoomPowerLevels,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
let user_can_demote_self =
|
||||||
|
room_power_levels
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|power_levels_content| {
|
||||||
|
RoomPowerLevels::from(power_levels_content.clone())
|
||||||
|
.user_can_change_user_power_level(user_id, user_id)
|
||||||
|
}) || services
|
||||||
|
.rooms
|
||||||
|
.state_accessor
|
||||||
|
.room_state_get(room_id, &StateEventType::RoomCreate, "")
|
||||||
|
.await
|
||||||
|
.is_ok_and(|event| event.sender() == user_id);
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,601 @@
|
|||||||
|
use std::{collections::HashMap, fmt::Write};
|
||||||
|
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum_client_ip::InsecureClientIp;
|
||||||
|
use conduwuit::{
|
||||||
|
Err, Result, debug_info, error, info,
|
||||||
|
utils::{self},
|
||||||
|
warn,
|
||||||
|
};
|
||||||
|
use conduwuit_service::Services;
|
||||||
|
use futures::{FutureExt, StreamExt};
|
||||||
|
use lettre::{Address, message::Mailbox};
|
||||||
|
use register::RegistrationKind;
|
||||||
|
use ruma::{
|
||||||
|
OwnedUserId, UserId,
|
||||||
|
api::client::{
|
||||||
|
account::{
|
||||||
|
register::{self, LoginType},
|
||||||
|
request_registration_token_via_email,
|
||||||
|
},
|
||||||
|
uiaa::{AuthFlow, AuthType},
|
||||||
|
},
|
||||||
|
events::{GlobalAccountDataEventType, room::message::RoomMessageEventContent},
|
||||||
|
push,
|
||||||
|
};
|
||||||
|
use serde_json::value::RawValue;
|
||||||
|
use service::mailer::messages;
|
||||||
|
|
||||||
|
use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH, join_room_by_id_helper};
|
||||||
|
use crate::Ruma;
|
||||||
|
|
||||||
|
const RANDOM_USER_ID_LENGTH: usize = 10;
|
||||||
|
|
||||||
|
/// # `POST /_matrix/client/v3/register`
|
||||||
|
///
|
||||||
|
/// Register an account on this homeserver.
|
||||||
|
///
|
||||||
|
/// You can use [`GET
|
||||||
|
/// /_matrix/client/v3/register/available`](fn.get_register_available_route.
|
||||||
|
/// html) to check if the user id is valid and available.
|
||||||
|
///
|
||||||
|
/// - Only works if registration is enabled
|
||||||
|
/// - If type is guest: ignores all parameters except
|
||||||
|
/// initial_device_display_name
|
||||||
|
/// - If sender is not appservice: Requires UIAA (but we only use a dummy stage)
|
||||||
|
/// - If type is not guest and no username is given: Always fails after UIAA
|
||||||
|
/// check
|
||||||
|
/// - Creates a new account and populates it with default account data
|
||||||
|
/// - If `inhibit_login` is false: Creates a device and returns device id and
|
||||||
|
/// access_token
|
||||||
|
#[allow(clippy::doc_markdown)]
|
||||||
|
#[tracing::instrument(skip_all, fields(%client), name = "register", level = "info")]
|
||||||
|
pub(crate) async fn register_route(
|
||||||
|
State(services): State<crate::State>,
|
||||||
|
InsecureClientIp(client): InsecureClientIp,
|
||||||
|
body: Ruma<register::v3::Request>,
|
||||||
|
) -> Result<register::v3::Response> {
|
||||||
|
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() {
|
||||||
|
match (body.username.as_ref(), body.initial_device_display_name.as_ref()) {
|
||||||
|
| (Some(username), Some(device_display_name)) => {
|
||||||
|
info!(
|
||||||
|
%is_guest,
|
||||||
|
user = %username,
|
||||||
|
device_name = %device_display_name,
|
||||||
|
"Rejecting registration attempt as registration is disabled"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
| (Some(username), _) => {
|
||||||
|
info!(
|
||||||
|
%is_guest,
|
||||||
|
user = %username,
|
||||||
|
"Rejecting registration attempt as registration is disabled"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
| (_, Some(device_display_name)) => {
|
||||||
|
info!(
|
||||||
|
%is_guest,
|
||||||
|
device_name = %device_display_name,
|
||||||
|
"Rejecting registration attempt as registration is disabled"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
| (None, _) => {
|
||||||
|
info!(
|
||||||
|
%is_guest,
|
||||||
|
"Rejecting registration attempt as registration is disabled"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return Err!(Request(Forbidden(
|
||||||
|
"This server is not accepting registrations at this time."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_guest && !services.config.allow_guest_registration {
|
||||||
|
info!(
|
||||||
|
"Guest registration disabled, rejecting guest registration attempt, initial device \
|
||||||
|
name: \"{}\"",
|
||||||
|
body.initial_device_display_name.as_deref().unwrap_or("")
|
||||||
|
);
|
||||||
|
return Err!(Request(GuestAccessForbidden("Guest registration is disabled.")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// forbid guests from registering if there is not a real admin user yet. give
|
||||||
|
// generic user error.
|
||||||
|
if is_guest && services.firstrun.is_first_run() {
|
||||||
|
warn!(
|
||||||
|
"Guest account attempted to register before a real admin user has been registered, \
|
||||||
|
rejecting registration. Guest's initial device name: \"{}\"",
|
||||||
|
body.initial_device_display_name.as_deref().unwrap_or("")
|
||||||
|
);
|
||||||
|
return Err!(Request(Forbidden(
|
||||||
|
"This server is not accepting registrations at this time."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appeservices and guests get to skip auth
|
||||||
|
let skip_auth = body.appservice_info.is_some() || is_guest;
|
||||||
|
|
||||||
|
let identity = if skip_auth {
|
||||||
|
// Appservices and guests have no identity
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
// Perform UIAA to determine the user's identity
|
||||||
|
let (flows, params) = create_registration_uiaa_session(&services).await?;
|
||||||
|
|
||||||
|
Some(
|
||||||
|
services
|
||||||
|
.uiaa
|
||||||
|
.authenticate(&body.auth, flows, params, None)
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// If the user didn't supply a username but did supply an email, use
|
||||||
|
// the email's user as their initial localpart to avoid falling back to
|
||||||
|
// a randomly generated localpart
|
||||||
|
let supplied_username = body.username.clone().or_else(|| {
|
||||||
|
if let Some(identity) = &identity
|
||||||
|
&& let Some(email) = &identity.email
|
||||||
|
{
|
||||||
|
Some(email.user().to_owned())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let user_id = determine_registration_user_id(
|
||||||
|
&services,
|
||||||
|
supplied_username,
|
||||||
|
is_guest,
|
||||||
|
emergency_mode_enabled,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if body.body.login_type == Some(LoginType::ApplicationService) {
|
||||||
|
// For appservice logins, make sure that the user ID is in the appservice's
|
||||||
|
// namespace
|
||||||
|
|
||||||
|
match body.appservice_info {
|
||||||
|
| Some(ref info) =>
|
||||||
|
if !info.is_user_match(&user_id) && !emergency_mode_enabled {
|
||||||
|
return Err!(Request(Exclusive(
|
||||||
|
"Username is not in an appservice namespace."
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
| _ => {
|
||||||
|
return Err!(Request(MissingToken("Missing appservice token.")));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else if services.appservice.is_exclusive_user_id(&user_id).await && !emergency_mode_enabled
|
||||||
|
{
|
||||||
|
// For non-appservice logins, ban user IDs which are in an appservice's
|
||||||
|
// namespace (unless emergency mode is enabled)
|
||||||
|
return Err!(Request(Exclusive("Username is reserved by an appservice.")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let password = if is_guest { None } else { body.password.as_deref() };
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
services.users.create(&user_id, password, None).await?;
|
||||||
|
|
||||||
|
// Set an initial display name
|
||||||
|
let mut displayname = user_id.localpart().to_owned();
|
||||||
|
|
||||||
|
// Apply the new user displayname suffix, if it's set
|
||||||
|
if !services.globals.new_user_displayname_suffix().is_empty()
|
||||||
|
&& body.appservice_info.is_none()
|
||||||
|
{
|
||||||
|
write!(displayname, " {}", services.server.config.new_user_displayname_suffix)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
services
|
||||||
|
.users
|
||||||
|
.set_displayname(&user_id, Some(displayname.clone()));
|
||||||
|
|
||||||
|
// Initial account data
|
||||||
|
services
|
||||||
|
.account_data
|
||||||
|
.update(
|
||||||
|
None,
|
||||||
|
&user_id,
|
||||||
|
GlobalAccountDataEventType::PushRules.to_string().into(),
|
||||||
|
&serde_json::to_value(ruma::events::push_rules::PushRulesEvent {
|
||||||
|
content: ruma::events::push_rules::PushRulesEventContent {
|
||||||
|
global: push::Ruleset::server_default(&user_id),
|
||||||
|
},
|
||||||
|
})?,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Generate new device id if the user didn't specify one
|
||||||
|
let no_device = body.inhibit_login
|
||||||
|
|| body
|
||||||
|
.appservice_info
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|aps| aps.registration.device_management);
|
||||||
|
|
||||||
|
let (token, device) = if !no_device {
|
||||||
|
// Don't create a device for inhibited logins
|
||||||
|
let device_id = if is_guest { None } else { body.device_id.clone() }
|
||||||
|
.unwrap_or_else(|| utils::random_string(DEVICE_ID_LENGTH).into());
|
||||||
|
|
||||||
|
// Generate new token for the device
|
||||||
|
let new_token = utils::random_string(TOKEN_LENGTH);
|
||||||
|
|
||||||
|
// Create device for this account
|
||||||
|
services
|
||||||
|
.users
|
||||||
|
.create_device(
|
||||||
|
&user_id,
|
||||||
|
&device_id,
|
||||||
|
&new_token,
|
||||||
|
body.initial_device_display_name.clone(),
|
||||||
|
Some(client.to_string()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
debug_info!(%user_id, %device_id, "User account was created");
|
||||||
|
(Some(new_token), Some(device_id))
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
// If the user registered with an email, associate it with their account.
|
||||||
|
if let Some(identity) = identity
|
||||||
|
&& let Some(email) = identity.email
|
||||||
|
{
|
||||||
|
// This may fail if the email is already in use, but we already check for that
|
||||||
|
// in `/requestToken`, so ignoring the error is acceptable here in the rare case
|
||||||
|
// that an email is sniped by another user between the `/requestToken` request
|
||||||
|
// and the `/register` request.
|
||||||
|
let _ = services
|
||||||
|
.threepid
|
||||||
|
.associate_localpart_email(user_id.localpart(), &email)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let device_display_name = body.initial_device_display_name.as_deref().unwrap_or("");
|
||||||
|
|
||||||
|
// log in conduit admin channel if a non-guest user registered
|
||||||
|
if body.appservice_info.is_none() && !is_guest {
|
||||||
|
if !device_display_name.is_empty() {
|
||||||
|
let notice = format!(
|
||||||
|
"New user \"{user_id}\" registered on this server from IP {client} and device \
|
||||||
|
display name \"{device_display_name}\""
|
||||||
|
);
|
||||||
|
|
||||||
|
info!("{notice}");
|
||||||
|
if services.server.config.admin_room_notices {
|
||||||
|
services.admin.notice(¬ice).await;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let notice = format!("New user \"{user_id}\" registered on this server.");
|
||||||
|
|
||||||
|
info!("{notice}");
|
||||||
|
if services.server.config.admin_room_notices {
|
||||||
|
services.admin.notice(¬ice).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// log in conduit admin channel if a guest registered
|
||||||
|
if body.appservice_info.is_none() && is_guest && services.config.log_guest_registrations {
|
||||||
|
debug_info!("New guest user \"{user_id}\" registered on this server.");
|
||||||
|
|
||||||
|
if !device_display_name.is_empty() {
|
||||||
|
if services.server.config.admin_room_notices {
|
||||||
|
services
|
||||||
|
.admin
|
||||||
|
.notice(&format!(
|
||||||
|
"Guest user \"{user_id}\" with device display name \
|
||||||
|
\"{device_display_name}\" registered on this server from IP {client}"
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
#[allow(clippy::collapsible_else_if)]
|
||||||
|
if services.server.config.admin_room_notices {
|
||||||
|
services
|
||||||
|
.admin
|
||||||
|
.notice(&format!(
|
||||||
|
"Guest user \"{user_id}\" with no device display name registered on \
|
||||||
|
this server from IP {client}",
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
services
|
||||||
|
.admin
|
||||||
|
.send_loud_message(RoomMessageEventContent::text_plain(format!(
|
||||||
|
"User {user_id} has been suspended as they are not the first user on \
|
||||||
|
this server. Please review and unsuspend them if appropriate."
|
||||||
|
)))
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.appservice_info.is_none()
|
||||||
|
&& !services.server.config.auto_join_rooms.is_empty()
|
||||||
|
&& (services.config.allow_guests_auto_join_rooms || !is_guest)
|
||||||
|
{
|
||||||
|
for room in &services.server.config.auto_join_rooms {
|
||||||
|
let Ok(room_id) = services.rooms.alias.resolve(room).await else {
|
||||||
|
error!(
|
||||||
|
"Failed to resolve room alias to room ID when attempting to auto join \
|
||||||
|
{room}, skipping"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !services
|
||||||
|
.rooms
|
||||||
|
.state_cache
|
||||||
|
.server_in_room(services.globals.server_name(), &room_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
"Skipping room {room} to automatically join as we have never joined before."
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(room_server_name) = room.server_name() {
|
||||||
|
match join_room_by_id_helper(
|
||||||
|
&services,
|
||||||
|
&user_id,
|
||||||
|
&room_id,
|
||||||
|
Some("Automatically joining this room upon registration".to_owned()),
|
||||||
|
&[services.globals.server_name().to_owned(), room_server_name.to_owned()],
|
||||||
|
&body.appservice_info,
|
||||||
|
)
|
||||||
|
.boxed()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
| Err(e) => {
|
||||||
|
// don't return this error so we don't fail registrations
|
||||||
|
error!(
|
||||||
|
"Failed to automatically join room {room} for user {user_id}: {e}"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
| _ => {
|
||||||
|
info!("Automatically joined room {room} for user {user_id}");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(register::v3::Response {
|
||||||
|
access_token: token,
|
||||||
|
user_id,
|
||||||
|
device_id: device,
|
||||||
|
refresh_token: None,
|
||||||
|
expires_in: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determine which flows and parameters should be presented when
|
||||||
|
/// registering a new account.
|
||||||
|
async fn create_registration_uiaa_session(
|
||||||
|
services: &Services,
|
||||||
|
) -> Result<(Vec<AuthFlow>, Box<RawValue>)> {
|
||||||
|
let mut params = HashMap::<String, serde_json::Value>::new();
|
||||||
|
|
||||||
|
let flows = if services.firstrun.is_first_run() {
|
||||||
|
// Registration token forced while in first-run mode
|
||||||
|
vec![AuthFlow::new(vec![AuthType::RegistrationToken])]
|
||||||
|
} else {
|
||||||
|
let mut flows = vec![];
|
||||||
|
|
||||||
|
if services
|
||||||
|
.registration_tokens
|
||||||
|
.iterate_tokens()
|
||||||
|
.next()
|
||||||
|
.await
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
// Trusted registration flow with a token is available
|
||||||
|
let mut token_flow = AuthFlow::new(vec![AuthType::RegistrationToken]);
|
||||||
|
|
||||||
|
if let Some(smtp) = &services.config.smtp
|
||||||
|
&& smtp.require_email_for_token_registration
|
||||||
|
{
|
||||||
|
// Email is required for token registrations
|
||||||
|
token_flow.stages.push(AuthType::EmailIdentity);
|
||||||
|
}
|
||||||
|
|
||||||
|
flows.push(token_flow);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut untrusted_flow = AuthFlow::default();
|
||||||
|
|
||||||
|
if services.config.recaptcha_private_site_key.is_some() {
|
||||||
|
if let Some(pubkey) = &services.config.recaptcha_site_key {
|
||||||
|
// ReCaptcha is configured for untrusted registrations
|
||||||
|
untrusted_flow.stages.push(AuthType::ReCaptcha);
|
||||||
|
|
||||||
|
params.insert(
|
||||||
|
AuthType::ReCaptcha.as_str().to_owned(),
|
||||||
|
serde_json::json!({
|
||||||
|
"public_key": pubkey,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(smtp) = &services.config.smtp
|
||||||
|
&& smtp.require_email_for_registration
|
||||||
|
{
|
||||||
|
// Email is required for untrusted registrations
|
||||||
|
untrusted_flow.stages.push(AuthType::EmailIdentity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !untrusted_flow.stages.is_empty() {
|
||||||
|
flows.push(untrusted_flow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if flows.is_empty() {
|
||||||
|
// No flows are configured. 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 flow
|
||||||
|
flows.push(AuthFlow::new(vec![AuthType::Dummy]));
|
||||||
|
}
|
||||||
|
|
||||||
|
flows
|
||||||
|
};
|
||||||
|
|
||||||
|
let params = serde_json::value::to_raw_value(¶ms).expect("params should be valid JSON");
|
||||||
|
|
||||||
|
Ok((flows, params))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn determine_registration_user_id(
|
||||||
|
services: &Services,
|
||||||
|
supplied_username: Option<String>,
|
||||||
|
is_guest: bool,
|
||||||
|
emergency_mode_enabled: bool,
|
||||||
|
) -> Result<OwnedUserId> {
|
||||||
|
if let Some(supplied_username) = supplied_username
|
||||||
|
&& !is_guest
|
||||||
|
{
|
||||||
|
// The user gets to pick their username. Do some validation to make sure it's
|
||||||
|
// acceptable.
|
||||||
|
|
||||||
|
// Don't allow registration with forbidden usernames.
|
||||||
|
if services
|
||||||
|
.globals
|
||||||
|
.forbidden_usernames()
|
||||||
|
.is_match(&supplied_username)
|
||||||
|
&& !emergency_mode_enabled
|
||||||
|
{
|
||||||
|
return Err!(Request(Forbidden("Username is forbidden")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and validate the user ID
|
||||||
|
let user_id = match UserId::parse_with_server_name(
|
||||||
|
&supplied_username,
|
||||||
|
services.globals.server_name(),
|
||||||
|
) {
|
||||||
|
| Ok(user_id) => {
|
||||||
|
if let Err(e) = user_id.validate_strict() {
|
||||||
|
// Unless we are in emergency mode, we should follow synapse's behaviour on
|
||||||
|
// not allowing things like spaces and UTF-8 characters in usernames
|
||||||
|
if !emergency_mode_enabled {
|
||||||
|
return Err!(Request(InvalidUsername(debug_warn!(
|
||||||
|
"Username {supplied_username} contains disallowed characters or \
|
||||||
|
spaces: {e}"
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow registration with user IDs that aren't local
|
||||||
|
if !services.globals.user_is_local(&user_id) {
|
||||||
|
return Err!(Request(InvalidUsername(
|
||||||
|
"Username {supplied_username} is not local to this server"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
user_id
|
||||||
|
},
|
||||||
|
| Err(e) => {
|
||||||
|
return Err!(Request(InvalidUsername(debug_warn!(
|
||||||
|
"Username {supplied_username} is not valid: {e}"
|
||||||
|
))));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if services.users.exists(&user_id).await {
|
||||||
|
return Err!(Request(UserInUse("User ID is not available.")));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(user_id)
|
||||||
|
} else {
|
||||||
|
// The user is a guest or didn't specify a username. Generate a username for
|
||||||
|
// them.
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let user_id = UserId::parse_with_server_name(
|
||||||
|
utils::random_string(RANDOM_USER_ID_LENGTH).to_lowercase(),
|
||||||
|
services.globals.server_name(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if !services.users.exists(&user_id).await {
|
||||||
|
break Ok(user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # `POST /_matrix/client/v3/register/email/requestToken`
|
||||||
|
///
|
||||||
|
/// Requests a validation email for the purpose of registering a new account.
|
||||||
|
pub(crate) async fn request_registration_token_via_email_route(
|
||||||
|
State(services): State<crate::State>,
|
||||||
|
body: Ruma<request_registration_token_via_email::v3::Request>,
|
||||||
|
) -> Result<request_registration_token_via_email::v3::Response> {
|
||||||
|
let Ok(email) = Address::try_from(body.email.clone()) else {
|
||||||
|
return Err!(Request(InvalidParam("Invalid email address.")));
|
||||||
|
};
|
||||||
|
|
||||||
|
if services
|
||||||
|
.threepid
|
||||||
|
.get_localpart_for_email(&email)
|
||||||
|
.await
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err!(Request(ThreepidInUse("This email address is already in use.")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = services
|
||||||
|
.threepid
|
||||||
|
.send_validation_email(
|
||||||
|
Mailbox::new(None, email),
|
||||||
|
|verification_link| messages::NewAccount {
|
||||||
|
server_name: services.config.server_name.as_ref(),
|
||||||
|
verification_link,
|
||||||
|
},
|
||||||
|
&body.client_secret,
|
||||||
|
body.send_attempt.try_into().unwrap(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(request_registration_token_via_email::v3::Response::new(session))
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
use axum::extract::State;
|
||||||
|
use conduwuit::{Err, Result, err};
|
||||||
|
use lettre::{Address, message::Mailbox};
|
||||||
|
use ruma::{
|
||||||
|
MilliSecondsSinceUnixEpoch,
|
||||||
|
api::client::account::{
|
||||||
|
ThirdPartyIdRemovalStatus, add_3pid, delete_3pid, get_3pids,
|
||||||
|
request_3pid_management_token_via_email, request_3pid_management_token_via_msisdn,
|
||||||
|
},
|
||||||
|
thirdparty::{Medium, ThirdPartyIdentifierInit},
|
||||||
|
};
|
||||||
|
use service::{mailer::messages, uiaa::Identity};
|
||||||
|
|
||||||
|
use crate::Ruma;
|
||||||
|
|
||||||
|
/// # `GET _matrix/client/v3/account/3pid`
|
||||||
|
///
|
||||||
|
/// Get a list of third party identifiers associated with this account.
|
||||||
|
pub(crate) async fn third_party_route(
|
||||||
|
State(services): State<crate::State>,
|
||||||
|
body: Ruma<get_3pids::v3::Request>,
|
||||||
|
) -> Result<get_3pids::v3::Response> {
|
||||||
|
let sender_user = body.sender_user();
|
||||||
|
let mut threepids = vec![];
|
||||||
|
|
||||||
|
if let Some(email) = services
|
||||||
|
.threepid
|
||||||
|
.get_email_for_localpart(sender_user.localpart())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
threepids.push(
|
||||||
|
ThirdPartyIdentifierInit {
|
||||||
|
address: email.to_string(),
|
||||||
|
medium: Medium::Email,
|
||||||
|
// We don't currently track these, and they aren't used for much
|
||||||
|
validated_at: MilliSecondsSinceUnixEpoch::now(),
|
||||||
|
added_at: MilliSecondsSinceUnixEpoch::from_system_time(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap(),
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(get_3pids::v3::Response::new(threepids))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # `POST /_matrix/client/v3/account/3pid/email/requestToken`
|
||||||
|
///
|
||||||
|
/// Requests a validation email for the purpose of changing an account's email.
|
||||||
|
pub(crate) async fn request_3pid_management_token_via_email_route(
|
||||||
|
State(services): State<crate::State>,
|
||||||
|
body: Ruma<request_3pid_management_token_via_email::v3::Request>,
|
||||||
|
) -> Result<request_3pid_management_token_via_email::v3::Response> {
|
||||||
|
let Ok(email) = Address::try_from(body.email.clone()) else {
|
||||||
|
return Err!(Request(InvalidParam("Invalid email address.")));
|
||||||
|
};
|
||||||
|
|
||||||
|
if services
|
||||||
|
.threepid
|
||||||
|
.get_localpart_for_email(&email)
|
||||||
|
.await
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err!(Request(ThreepidInUse("This email address is already in use.")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = services
|
||||||
|
.threepid
|
||||||
|
.send_validation_email(
|
||||||
|
Mailbox::new(None, email),
|
||||||
|
|verification_link| messages::ChangeEmail {
|
||||||
|
server_name: services.config.server_name.as_str(),
|
||||||
|
user_id: body.sender_user.as_deref(),
|
||||||
|
verification_link,
|
||||||
|
},
|
||||||
|
&body.client_secret,
|
||||||
|
body.send_attempt.try_into().unwrap(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(request_3pid_management_token_via_email::v3::Response::new(session))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # `POST /_matrix/client/v3/account/3pid/msisdn/requestToken`
|
||||||
|
///
|
||||||
|
/// "This API should be used to request validation tokens when adding an email
|
||||||
|
/// address to an account"
|
||||||
|
///
|
||||||
|
/// - 403 signals that The homeserver does not allow the third party identifier
|
||||||
|
/// as a contact option.
|
||||||
|
pub(crate) async fn request_3pid_management_token_via_msisdn_route(
|
||||||
|
_body: Ruma<request_3pid_management_token_via_msisdn::v3::Request>,
|
||||||
|
) -> Result<request_3pid_management_token_via_msisdn::v3::Response> {
|
||||||
|
Err!(Request(ThreepidMediumNotSupported(
|
||||||
|
"MSISDN third-party identifiers are not supported."
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # `POST /_matrix/client/v3/account/3pid/add`
|
||||||
|
pub(crate) async fn add_3pid_route(
|
||||||
|
State(services): State<crate::State>,
|
||||||
|
body: Ruma<add_3pid::v3::Request>,
|
||||||
|
) -> Result<add_3pid::v3::Response> {
|
||||||
|
let sender_user = body.sender_user();
|
||||||
|
|
||||||
|
// Require password auth to add an email
|
||||||
|
let _ = services
|
||||||
|
.uiaa
|
||||||
|
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let email = services
|
||||||
|
.threepid
|
||||||
|
.consume_valid_session(&body.sid, &body.client_secret)
|
||||||
|
.await
|
||||||
|
.map_err(|message| err!(Request(ThreepidAuthFailed("{message}"))))?;
|
||||||
|
|
||||||
|
services
|
||||||
|
.threepid
|
||||||
|
.associate_localpart_email(sender_user.localpart(), &email)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(add_3pid::v3::Response::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # `POST /_matrix/client/v3/account/3pid/delete`
|
||||||
|
pub(crate) async fn delete_3pid_route(
|
||||||
|
State(services): State<crate::State>,
|
||||||
|
body: Ruma<delete_3pid::v3::Request>,
|
||||||
|
) -> Result<delete_3pid::v3::Response> {
|
||||||
|
let sender_user = body.sender_user();
|
||||||
|
|
||||||
|
if body.medium != Medium::Email {
|
||||||
|
return Ok(delete_3pid::v3::Response {
|
||||||
|
id_server_unbind_result: ThirdPartyIdRemovalStatus::NoSupport,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if services
|
||||||
|
.threepid
|
||||||
|
.disassociate_localpart_email(sender_user.localpart())
|
||||||
|
.await
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return Err!(Request(ThreepidNotFound("Your account has no associated email.")));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(delete_3pid::v3::Response {
|
||||||
|
id_server_unbind_result: ThirdPartyIdRemovalStatus::Success,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -30,8 +30,10 @@ pub(crate) async fn get_capabilities_route(
|
|||||||
default: services.server.config.default_room_version.clone(),
|
default: services.server.config.default_room_version.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// we do not implement 3PID stuff
|
// Only allow 3pid changes if SMTP is configured
|
||||||
capabilities.thirdparty_id_changes = ThirdPartyIdChangesCapability { enabled: false };
|
capabilities.thirdparty_id_changes = ThirdPartyIdChangesCapability {
|
||||||
|
enabled: services.mailer.mailer().is_some(),
|
||||||
|
};
|
||||||
|
|
||||||
capabilities.get_login_token = GetLoginTokenCapability {
|
capabilities.get_login_token = GetLoginTokenCapability {
|
||||||
enabled: services.server.config.login_via_existing_session,
|
enabled: services.server.config.login_via_existing_session,
|
||||||
@@ -51,7 +53,7 @@ pub(crate) async fn get_capabilities_route(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
// Advertise suspension API
|
// Advertise suspension API
|
||||||
capabilities.set("uk.timedout.msc4323", json!({"suspend":true, "lock": false}))?;
|
capabilities.set("uk.timedout.msc4323", json!({"suspend": true, "lock": false}))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(get_capabilities::v3::Response { capabilities })
|
Ok(get_capabilities::v3::Response { capabilities })
|
||||||
|
|||||||
+16
-78
@@ -1,17 +1,15 @@
|
|||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum_client_ip::InsecureClientIp;
|
use axum_client_ip::InsecureClientIp;
|
||||||
use conduwuit::{Err, Error, Result, debug, err, utils};
|
use conduwuit::{Err, Result, debug, err, utils};
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use ruma::{
|
use ruma::{
|
||||||
MilliSecondsSinceUnixEpoch, OwnedDeviceId,
|
MilliSecondsSinceUnixEpoch, OwnedDeviceId,
|
||||||
api::client::{
|
api::client::device::{
|
||||||
device::{self, delete_device, delete_devices, get_device, get_devices, update_device},
|
self, delete_device, delete_devices, get_device, get_devices, update_device,
|
||||||
error::ErrorKind,
|
|
||||||
uiaa::{AuthFlow, AuthType, UiaaInfo},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
use service::uiaa::Identity;
|
||||||
|
|
||||||
use super::SESSION_ID_LENGTH;
|
|
||||||
use crate::{Ruma, client::DEVICE_ID_LENGTH};
|
use crate::{Ruma, client::DEVICE_ID_LENGTH};
|
||||||
|
|
||||||
/// # `GET /_matrix/client/r0/devices`
|
/// # `GET /_matrix/client/r0/devices`
|
||||||
@@ -123,7 +121,7 @@ pub(crate) async fn delete_device_route(
|
|||||||
State(services): State<crate::State>,
|
State(services): State<crate::State>,
|
||||||
body: Ruma<delete_device::v3::Request>,
|
body: Ruma<delete_device::v3::Request>,
|
||||||
) -> Result<delete_device::v3::Response> {
|
) -> Result<delete_device::v3::Response> {
|
||||||
let (sender_user, sender_device) = body.sender();
|
let sender_user = body.sender_user();
|
||||||
let appservice = body.appservice_info.as_ref();
|
let appservice = body.appservice_info.as_ref();
|
||||||
|
|
||||||
if appservice.is_some_and(|appservice| appservice.registration.device_management) {
|
if appservice.is_some_and(|appservice| appservice.registration.device_management) {
|
||||||
@@ -139,41 +137,11 @@ pub(crate) async fn delete_device_route(
|
|||||||
return Ok(delete_device::v3::Response {});
|
return Ok(delete_device::v3::Response {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// UIAA
|
// Prompt the user to confirm with their password using UIAA
|
||||||
let mut uiaainfo = UiaaInfo {
|
let _ = services
|
||||||
flows: vec![AuthFlow { stages: vec![AuthType::Password] }],
|
.uiaa
|
||||||
completed: Vec::new(),
|
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
|
||||||
params: Box::default(),
|
.await?;
|
||||||
session: None,
|
|
||||||
auth_error: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
match &body.auth {
|
|
||||||
| Some(auth) => {
|
|
||||||
let (worked, uiaainfo) = services
|
|
||||||
.uiaa
|
|
||||||
.try_auth(sender_user, sender_device, auth, &uiaainfo)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !worked {
|
|
||||||
return Err!(Uiaa(uiaainfo));
|
|
||||||
}
|
|
||||||
// Success!
|
|
||||||
},
|
|
||||||
| _ => match body.json_body {
|
|
||||||
| Some(ref json) => {
|
|
||||||
uiaainfo.session = Some(utils::random_string(SESSION_ID_LENGTH));
|
|
||||||
services
|
|
||||||
.uiaa
|
|
||||||
.create(sender_user, sender_device, &uiaainfo, json);
|
|
||||||
|
|
||||||
return Err!(Uiaa(uiaainfo));
|
|
||||||
},
|
|
||||||
| _ => {
|
|
||||||
return Err!(Request(NotJson("Not json.")));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
services
|
services
|
||||||
.users
|
.users
|
||||||
@@ -200,7 +168,7 @@ pub(crate) async fn delete_devices_route(
|
|||||||
State(services): State<crate::State>,
|
State(services): State<crate::State>,
|
||||||
body: Ruma<delete_devices::v3::Request>,
|
body: Ruma<delete_devices::v3::Request>,
|
||||||
) -> Result<delete_devices::v3::Response> {
|
) -> Result<delete_devices::v3::Response> {
|
||||||
let (sender_user, sender_device) = body.sender();
|
let sender_user = body.sender_user();
|
||||||
let appservice = body.appservice_info.as_ref();
|
let appservice = body.appservice_info.as_ref();
|
||||||
|
|
||||||
if appservice.is_some_and(|appservice| appservice.registration.device_management) {
|
if appservice.is_some_and(|appservice| appservice.registration.device_management) {
|
||||||
@@ -215,41 +183,11 @@ pub(crate) async fn delete_devices_route(
|
|||||||
return Ok(delete_devices::v3::Response {});
|
return Ok(delete_devices::v3::Response {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// UIAA
|
// Prompt the user to confirm with their password using UIAA
|
||||||
let mut uiaainfo = UiaaInfo {
|
let _ = services
|
||||||
flows: vec![AuthFlow { stages: vec![AuthType::Password] }],
|
.uiaa
|
||||||
completed: Vec::new(),
|
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
|
||||||
params: Box::default(),
|
.await?;
|
||||||
session: None,
|
|
||||||
auth_error: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
match &body.auth {
|
|
||||||
| Some(auth) => {
|
|
||||||
let (worked, uiaainfo) = services
|
|
||||||
.uiaa
|
|
||||||
.try_auth(sender_user, sender_device, auth, &uiaainfo)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !worked {
|
|
||||||
return Err(Error::Uiaa(uiaainfo));
|
|
||||||
}
|
|
||||||
// Success!
|
|
||||||
},
|
|
||||||
| _ => match body.json_body {
|
|
||||||
| Some(ref json) => {
|
|
||||||
uiaainfo.session = Some(utils::random_string(SESSION_ID_LENGTH));
|
|
||||||
services
|
|
||||||
.uiaa
|
|
||||||
.create(sender_user, sender_device, &uiaainfo, json);
|
|
||||||
|
|
||||||
return Err(Error::Uiaa(uiaainfo));
|
|
||||||
},
|
|
||||||
| _ => {
|
|
||||||
return Err(Error::BadRequest(ErrorKind::NotJson, "Not json."));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for device_id in &body.devices {
|
for device_id in &body.devices {
|
||||||
services.users.remove_device(sender_user, device_id).await;
|
services.users.remove_device(sender_user, device_id).await;
|
||||||
|
|||||||
+6
-39
@@ -7,7 +7,6 @@ use axum::extract::State;
|
|||||||
use conduwuit::{
|
use conduwuit::{
|
||||||
Err, Error, Result, debug, debug_warn, err,
|
Err, Error, Result, debug, debug_warn, err,
|
||||||
result::NotFound,
|
result::NotFound,
|
||||||
utils,
|
|
||||||
utils::{IterStream, stream::WidebandExt},
|
utils::{IterStream, stream::WidebandExt},
|
||||||
};
|
};
|
||||||
use conduwuit_service::{Services, users::parse_master_key};
|
use conduwuit_service::{Services, users::parse_master_key};
|
||||||
@@ -22,7 +21,6 @@ use ruma::{
|
|||||||
upload_signatures::{self},
|
upload_signatures::{self},
|
||||||
upload_signing_keys,
|
upload_signing_keys,
|
||||||
},
|
},
|
||||||
uiaa::{AuthFlow, AuthType, UiaaInfo},
|
|
||||||
},
|
},
|
||||||
federation,
|
federation,
|
||||||
},
|
},
|
||||||
@@ -30,8 +28,8 @@ use ruma::{
|
|||||||
serde::Raw,
|
serde::Raw,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use service::uiaa::Identity;
|
||||||
|
|
||||||
use super::SESSION_ID_LENGTH;
|
|
||||||
use crate::Ruma;
|
use crate::Ruma;
|
||||||
|
|
||||||
/// # `POST /_matrix/client/r0/keys/upload`
|
/// # `POST /_matrix/client/r0/keys/upload`
|
||||||
@@ -174,16 +172,7 @@ pub(crate) async fn upload_signing_keys_route(
|
|||||||
State(services): State<crate::State>,
|
State(services): State<crate::State>,
|
||||||
body: Ruma<upload_signing_keys::v3::Request>,
|
body: Ruma<upload_signing_keys::v3::Request>,
|
||||||
) -> Result<upload_signing_keys::v3::Response> {
|
) -> Result<upload_signing_keys::v3::Response> {
|
||||||
let (sender_user, sender_device) = body.sender();
|
let sender_user = body.sender_user();
|
||||||
|
|
||||||
// UIAA
|
|
||||||
let mut uiaainfo = UiaaInfo {
|
|
||||||
flows: vec![AuthFlow { stages: vec![AuthType::Password] }],
|
|
||||||
completed: Vec::new(),
|
|
||||||
params: Box::default(),
|
|
||||||
session: None,
|
|
||||||
auth_error: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
match check_for_new_keys(
|
match check_for_new_keys(
|
||||||
services,
|
services,
|
||||||
@@ -207,32 +196,10 @@ pub(crate) async fn upload_signing_keys_route(
|
|||||||
// Some of the keys weren't found, so we let them upload
|
// Some of the keys weren't found, so we let them upload
|
||||||
},
|
},
|
||||||
| _ => {
|
| _ => {
|
||||||
match &body.auth {
|
let _ = services
|
||||||
| Some(auth) => {
|
.uiaa
|
||||||
let (worked, uiaainfo) = services
|
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
|
||||||
.uiaa
|
.await?;
|
||||||
.try_auth(sender_user, sender_device, auth, &uiaainfo)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !worked {
|
|
||||||
return Err(Error::Uiaa(uiaainfo));
|
|
||||||
}
|
|
||||||
// Success!
|
|
||||||
},
|
|
||||||
| _ => match body.json_body.as_ref() {
|
|
||||||
| Some(json) => {
|
|
||||||
uiaainfo.session = Some(utils::random_string(SESSION_ID_LENGTH));
|
|
||||||
services
|
|
||||||
.uiaa
|
|
||||||
.create(sender_user, sender_device, &uiaainfo, json);
|
|
||||||
|
|
||||||
return Err(Error::Uiaa(uiaainfo));
|
|
||||||
},
|
|
||||||
| _ => {
|
|
||||||
return Err(Error::BadRequest(ErrorKind::NotJson, "Not json."));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,3 @@ const DEVICE_ID_LENGTH: usize = 10;
|
|||||||
|
|
||||||
/// generated user access token length
|
/// generated user access token length
|
||||||
const TOKEN_LENGTH: usize = 32;
|
const TOKEN_LENGTH: usize = 32;
|
||||||
|
|
||||||
/// generated user session ID length
|
|
||||||
const SESSION_ID_LENGTH: usize = service::uiaa::SESSION_ID_LENGTH;
|
|
||||||
|
|||||||
+38
-58
@@ -8,8 +8,9 @@ use conduwuit::{
|
|||||||
warn,
|
warn,
|
||||||
};
|
};
|
||||||
use conduwuit_core::{debug_error, debug_warn};
|
use conduwuit_core::{debug_error, debug_warn};
|
||||||
use conduwuit_service::{Services, uiaa::SESSION_ID_LENGTH};
|
use conduwuit_service::Services;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
|
use lettre::Address;
|
||||||
use ruma::{
|
use ruma::{
|
||||||
OwnedUserId, UserId,
|
OwnedUserId, UserId,
|
||||||
api::client::{
|
api::client::{
|
||||||
@@ -26,9 +27,10 @@ use ruma::{
|
|||||||
},
|
},
|
||||||
logout, logout_all,
|
logout, logout_all,
|
||||||
},
|
},
|
||||||
uiaa,
|
uiaa::UserIdentifier,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
use service::uiaa::Identity;
|
||||||
|
|
||||||
use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH};
|
use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH};
|
||||||
use crate::Ruma;
|
use crate::Ruma;
|
||||||
@@ -80,7 +82,7 @@ pub(crate) async fn password_login(
|
|||||||
.password_hash(lowercased_user_id)
|
.password_hash(lowercased_user_id)
|
||||||
.await
|
.await
|
||||||
.map(|hash| (hash, lowercased_user_id))
|
.map(|hash| (hash, lowercased_user_id))
|
||||||
.map_err(|_| err!(Request(Forbidden("Wrong username or password."))))?,
|
.map_err(|_| err!(Request(Forbidden("Invalid identifier or password."))))?,
|
||||||
};
|
};
|
||||||
|
|
||||||
if hash.is_empty() {
|
if hash.is_empty() {
|
||||||
@@ -89,7 +91,7 @@ pub(crate) async fn password_login(
|
|||||||
|
|
||||||
hash::verify_password(password, &hash)
|
hash::verify_password(password, &hash)
|
||||||
.inspect_err(|e| debug_error!("{e}"))
|
.inspect_err(|e| debug_error!("{e}"))
|
||||||
.map_err(|_| err!(Request(Forbidden("Wrong username or password."))))?;
|
.map_err(|_| err!(Request(Forbidden("Invalid identifier or password."))))?;
|
||||||
|
|
||||||
Ok(user_id.to_owned())
|
Ok(user_id.to_owned())
|
||||||
}
|
}
|
||||||
@@ -161,28 +163,38 @@ pub(super) async fn ldap_login(
|
|||||||
|
|
||||||
pub(crate) async fn handle_login(
|
pub(crate) async fn handle_login(
|
||||||
services: &Services,
|
services: &Services,
|
||||||
body: &Ruma<login::v3::Request>,
|
identifier: Option<&UserIdentifier>,
|
||||||
identifier: Option<&uiaa::UserIdentifier>,
|
|
||||||
password: &str,
|
password: &str,
|
||||||
user: Option<&String>,
|
user: Option<&String>,
|
||||||
) -> Result<OwnedUserId> {
|
) -> Result<OwnedUserId> {
|
||||||
debug!("Got password login type");
|
debug!("Got password login type");
|
||||||
|
let user_id_or_localpart = match (identifier, user) {
|
||||||
|
| (Some(UserIdentifier::UserIdOrLocalpart(localpart)), _) => localpart,
|
||||||
|
| (Some(UserIdentifier::Email { address }), _) => {
|
||||||
|
let email = Address::try_from(address.to_owned())
|
||||||
|
.map_err(|_| err!(Request(InvalidParam("Email is malformed"))))?;
|
||||||
|
|
||||||
|
&services
|
||||||
|
.threepid
|
||||||
|
.get_localpart_for_email(&email)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| err!(Request(Forbidden("Invalid identifier or password"))))?
|
||||||
|
},
|
||||||
|
| (None, Some(user)) => user,
|
||||||
|
| _ => {
|
||||||
|
return Err!(Request(InvalidParam("Identifier type not recognized")));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
let user_id =
|
let user_id =
|
||||||
if let Some(uiaa::UserIdentifier::UserIdOrLocalpart(user_id)) = identifier {
|
UserId::parse_with_server_name(user_id_or_localpart, &services.config.server_name)
|
||||||
UserId::parse_with_server_name(user_id, &services.config.server_name)
|
.map_err(|_| err!(Request(InvalidUsername("User ID is malformed"))))?;
|
||||||
} else if let Some(user) = user {
|
|
||||||
UserId::parse_with_server_name(user, &services.config.server_name)
|
|
||||||
} else {
|
|
||||||
return Err!(Request(Unknown(
|
|
||||||
debug_warn!(?body.login_info, "Valid identifier or username was not provided (invalid or unsupported login type?)")
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
.map_err(|e| err!(Request(InvalidUsername(warn!("Username is invalid: {e}")))))?;
|
|
||||||
|
|
||||||
let lowercased_user_id = UserId::parse_with_server_name(
|
let lowercased_user_id = UserId::parse_with_server_name(
|
||||||
user_id.localpart().to_lowercase(),
|
user_id.localpart().to_lowercase(),
|
||||||
&services.config.server_name,
|
&services.config.server_name,
|
||||||
)?;
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
if !services.globals.user_is_local(&user_id)
|
if !services.globals.user_is_local(&user_id)
|
||||||
|| !services.globals.user_is_local(&lowercased_user_id)
|
|| !services.globals.user_is_local(&lowercased_user_id)
|
||||||
@@ -244,7 +256,7 @@ pub(crate) async fn login_route(
|
|||||||
password,
|
password,
|
||||||
user,
|
user,
|
||||||
..
|
..
|
||||||
}) => handle_login(&services, &body, identifier.as_ref(), password, user.as_ref()).await?,
|
}) => handle_login(&services, identifier.as_ref(), password, user.as_ref()).await?,
|
||||||
| login::v3::LoginInfo::Token(login::v3::Token { token }) => {
|
| login::v3::LoginInfo::Token(login::v3::Token { token }) => {
|
||||||
debug!("Got token login type");
|
debug!("Got token login type");
|
||||||
if !services.server.config.login_via_existing_session {
|
if !services.server.config.login_via_existing_session {
|
||||||
@@ -264,7 +276,7 @@ pub(crate) async fn login_route(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let user_id =
|
let user_id =
|
||||||
if let Some(uiaa::UserIdentifier::UserIdOrLocalpart(user_id)) = identifier {
|
if let Some(UserIdentifier::UserIdOrLocalpart(user_id)) = identifier {
|
||||||
UserId::parse_with_server_name(user_id, &services.config.server_name)
|
UserId::parse_with_server_name(user_id, &services.config.server_name)
|
||||||
} else if let Some(user) = user {
|
} else if let Some(user) = user {
|
||||||
UserId::parse_with_server_name(user, &services.config.server_name)
|
UserId::parse_with_server_name(user, &services.config.server_name)
|
||||||
@@ -273,7 +285,7 @@ pub(crate) async fn login_route(
|
|||||||
debug_warn!(?body.login_info, "Valid identifier or username was not provided (invalid or unsupported login type?)")
|
debug_warn!(?body.login_info, "Valid identifier or username was not provided (invalid or unsupported login type?)")
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
.map_err(|e| err!(Request(InvalidUsername(warn!("Username is invalid: {e}")))))?;
|
.map_err(|_| err!(Request(InvalidUsername(warn!("User ID is malformed")))))?;
|
||||||
|
|
||||||
if !services.globals.user_is_local(&user_id) {
|
if !services.globals.user_is_local(&user_id) {
|
||||||
return Err!(Request(Unknown("User ID does not belong to this homeserver")));
|
return Err!(Request(Unknown("User ID does not belong to this homeserver")));
|
||||||
@@ -370,45 +382,13 @@ pub(crate) async fn login_token_route(
|
|||||||
return Err!(Request(Forbidden("Login via an existing session is not enabled")));
|
return Err!(Request(Forbidden("Login via an existing session is not enabled")));
|
||||||
}
|
}
|
||||||
|
|
||||||
// This route SHOULD have UIA
|
let sender_user = body.sender_user();
|
||||||
// TODO: How do we make only UIA sessions that have not been used before valid?
|
|
||||||
let (sender_user, sender_device) = body.sender();
|
|
||||||
|
|
||||||
let mut uiaainfo = uiaa::UiaaInfo {
|
// Prompt the user to confirm with their password using UIAA
|
||||||
flows: vec![uiaa::AuthFlow { stages: vec![uiaa::AuthType::Password] }],
|
let _ = services
|
||||||
completed: Vec::new(),
|
.uiaa
|
||||||
params: Box::default(),
|
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
|
||||||
session: None,
|
.await?;
|
||||||
auth_error: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
match &body.auth {
|
|
||||||
| Some(auth) => {
|
|
||||||
let (worked, uiaainfo) = services
|
|
||||||
.uiaa
|
|
||||||
.try_auth(sender_user, sender_device, auth, &uiaainfo)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !worked {
|
|
||||||
return Err(Error::Uiaa(uiaainfo));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Success!
|
|
||||||
},
|
|
||||||
| _ => match body.json_body.as_ref() {
|
|
||||||
| Some(json) => {
|
|
||||||
uiaainfo.session = Some(utils::random_string(SESSION_ID_LENGTH));
|
|
||||||
services
|
|
||||||
.uiaa
|
|
||||||
.create(sender_user, sender_device, &uiaainfo, json);
|
|
||||||
|
|
||||||
return Err(Error::Uiaa(uiaainfo));
|
|
||||||
},
|
|
||||||
| _ => {
|
|
||||||
return Err!(Request(NotJson("No JSON body was sent when required.")));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
let login_token = utils::random_string(TOKEN_LENGTH);
|
let login_token = utils::random_string(TOKEN_LENGTH);
|
||||||
let expires_in = services.users.create_login_token(sender_user, &login_token);
|
let expires_in = services.users.create_login_token(sender_user, &login_token);
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ async fn build_state_and_timeline(
|
|||||||
// joined since the last sync, that being the syncing user's join event. if
|
// joined since the last sync, that being the syncing user's join event. if
|
||||||
// it's empty something is wrong.
|
// it's empty something is wrong.
|
||||||
if joined_since_last_sync && timeline.pdus.is_empty() {
|
if joined_since_last_sync && timeline.pdus.is_empty() {
|
||||||
warn!("timeline for newly joined room is empty");
|
debug_warn!("timeline for newly joined room is empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
let (summary, device_list_updates) = try_join(
|
let (summary, device_list_updates) = try_join(
|
||||||
|
|||||||
+8
-4
@@ -28,7 +28,8 @@ pub fn build(router: Router<State>, server: &Server) -> Router<State> {
|
|||||||
.ruma_route(&client::appservice_ping)
|
.ruma_route(&client::appservice_ping)
|
||||||
.ruma_route(&client::get_supported_versions_route)
|
.ruma_route(&client::get_supported_versions_route)
|
||||||
.ruma_route(&client::get_register_available_route)
|
.ruma_route(&client::get_register_available_route)
|
||||||
.ruma_route(&client::register_route)
|
.ruma_route(&client::register::register_route)
|
||||||
|
.ruma_route(&client::register::request_registration_token_via_email_route)
|
||||||
.ruma_route(&client::get_login_types_route)
|
.ruma_route(&client::get_login_types_route)
|
||||||
.ruma_route(&client::login_route)
|
.ruma_route(&client::login_route)
|
||||||
.ruma_route(&client::login_token_route)
|
.ruma_route(&client::login_token_route)
|
||||||
@@ -36,10 +37,13 @@ pub fn build(router: Router<State>, server: &Server) -> Router<State> {
|
|||||||
.ruma_route(&client::logout_route)
|
.ruma_route(&client::logout_route)
|
||||||
.ruma_route(&client::logout_all_route)
|
.ruma_route(&client::logout_all_route)
|
||||||
.ruma_route(&client::change_password_route)
|
.ruma_route(&client::change_password_route)
|
||||||
|
.ruma_route(&client::request_password_change_token_via_email_route)
|
||||||
.ruma_route(&client::deactivate_route)
|
.ruma_route(&client::deactivate_route)
|
||||||
.ruma_route(&client::third_party_route)
|
.ruma_route(&client::threepid::third_party_route)
|
||||||
.ruma_route(&client::request_3pid_management_token_via_email_route)
|
.ruma_route(&client::threepid::request_3pid_management_token_via_email_route)
|
||||||
.ruma_route(&client::request_3pid_management_token_via_msisdn_route)
|
.ruma_route(&client::threepid::request_3pid_management_token_via_msisdn_route)
|
||||||
|
.ruma_route(&client::threepid::add_3pid_route)
|
||||||
|
.ruma_route(&client::threepid::delete_3pid_route)
|
||||||
.ruma_route(&client::check_registration_token_validity)
|
.ruma_route(&client::check_registration_token_validity)
|
||||||
.ruma_route(&client::get_capabilities_route)
|
.ruma_route(&client::get_capabilities_route)
|
||||||
.ruma_route(&client::get_pushrules_all_route)
|
.ruma_route(&client::get_pushrules_all_route)
|
||||||
|
|||||||
+6
-39
@@ -2,14 +2,13 @@ use std::{mem, ops::Deref};
|
|||||||
|
|
||||||
use axum::{body::Body, extract::FromRequest};
|
use axum::{body::Body, extract::FromRequest};
|
||||||
use bytes::{BufMut, Bytes, BytesMut};
|
use bytes::{BufMut, Bytes, BytesMut};
|
||||||
use conduwuit::{Error, Result, debug, debug_warn, err, trace, utils::string::EMPTY};
|
use conduwuit::{Error, Result, debug, debug_warn, err, trace};
|
||||||
use ruma::{
|
use ruma::{
|
||||||
CanonicalJsonObject, CanonicalJsonValue, DeviceId, OwnedDeviceId, OwnedServerName,
|
CanonicalJsonObject, CanonicalJsonValue, DeviceId, OwnedDeviceId, OwnedServerName,
|
||||||
OwnedUserId, ServerName, UserId, api::IncomingRequest,
|
OwnedUserId, ServerName, UserId, api::IncomingRequest,
|
||||||
};
|
};
|
||||||
use service::Services;
|
|
||||||
|
|
||||||
use super::{auth, auth::Auth, request, request::Request};
|
use super::{auth, request, request::Request};
|
||||||
use crate::{State, service::appservice::RegistrationInfo};
|
use crate::{State, service::appservice::RegistrationInfo};
|
||||||
|
|
||||||
/// Extractor for Ruma request structs
|
/// Extractor for Ruma request structs
|
||||||
@@ -108,7 +107,7 @@ where
|
|||||||
}
|
}
|
||||||
let auth = auth::auth(services, &mut request, json_body.as_ref(), &T::METADATA).await?;
|
let auth = auth::auth(services, &mut request, json_body.as_ref(), &T::METADATA).await?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
body: make_body::<T>(services, &mut request, json_body.as_mut(), &auth)?,
|
body: make_body::<T>(&mut request, json_body.as_mut())?,
|
||||||
origin: auth.origin,
|
origin: auth.origin,
|
||||||
sender_user: auth.sender_user,
|
sender_user: auth.sender_user,
|
||||||
sender_device: auth.sender_device,
|
sender_device: auth.sender_device,
|
||||||
@@ -118,16 +117,11 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_body<T>(
|
fn make_body<T>(request: &mut Request, json_body: Option<&mut CanonicalJsonValue>) -> Result<T>
|
||||||
services: &Services,
|
|
||||||
request: &mut Request,
|
|
||||||
json_body: Option<&mut CanonicalJsonValue>,
|
|
||||||
auth: &Auth,
|
|
||||||
) -> Result<T>
|
|
||||||
where
|
where
|
||||||
T: IncomingRequest,
|
T: IncomingRequest,
|
||||||
{
|
{
|
||||||
let body = take_body(services, request, json_body, auth);
|
let body = take_body(request, json_body);
|
||||||
let http_request = into_http_request(request, body);
|
let http_request = into_http_request(request, body);
|
||||||
T::try_from_http_request(http_request, &request.path)
|
T::try_from_http_request(http_request, &request.path)
|
||||||
.map_err(|e| err!(Request(BadJson(debug_warn!("{e}")))))
|
.map_err(|e| err!(Request(BadJson(debug_warn!("{e}")))))
|
||||||
@@ -151,38 +145,11 @@ fn into_http_request(request: &Request, body: Bytes) -> hyper::Request<Bytes> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
fn take_body(
|
fn take_body(request: &mut Request, json_body: Option<&mut CanonicalJsonValue>) -> Bytes {
|
||||||
services: &Services,
|
|
||||||
request: &mut Request,
|
|
||||||
json_body: Option<&mut CanonicalJsonValue>,
|
|
||||||
auth: &Auth,
|
|
||||||
) -> Bytes {
|
|
||||||
let Some(CanonicalJsonValue::Object(json_body)) = json_body else {
|
let Some(CanonicalJsonValue::Object(json_body)) = json_body else {
|
||||||
return mem::take(&mut request.body);
|
return mem::take(&mut request.body);
|
||||||
};
|
};
|
||||||
|
|
||||||
let user_id = auth.sender_user.clone().unwrap_or_else(|| {
|
|
||||||
let server_name = services.globals.server_name();
|
|
||||||
UserId::parse_with_server_name(EMPTY, server_name).expect("valid user_id")
|
|
||||||
});
|
|
||||||
|
|
||||||
let uiaa_request = json_body
|
|
||||||
.get("auth")
|
|
||||||
.and_then(CanonicalJsonValue::as_object)
|
|
||||||
.and_then(|auth| auth.get("session"))
|
|
||||||
.and_then(CanonicalJsonValue::as_str)
|
|
||||||
.and_then(|session| {
|
|
||||||
services
|
|
||||||
.uiaa
|
|
||||||
.get_uiaa_request(&user_id, auth.sender_device.as_deref(), session)
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(CanonicalJsonValue::Object(initial_request)) = uiaa_request {
|
|
||||||
for (key, value) in initial_request {
|
|
||||||
json_body.entry(key).or_insert(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut buf = BytesMut::new().writer();
|
let mut buf = BytesMut::new().writer();
|
||||||
serde_json::to_writer(&mut buf, &json_body).expect("value serialization can't fail");
|
serde_json::to_writer(&mut buf, &json_body).expect("value serialization can't fail");
|
||||||
buf.into_inner().freeze()
|
buf.into_inner().freeze()
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ libc.workspace = true
|
|||||||
libloading.workspace = true
|
libloading.workspace = true
|
||||||
libloading.optional = true
|
libloading.optional = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
lettre.workspace = true
|
||||||
num-traits.workspace = true
|
num-traits.workspace = true
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
rand_core = { version = "0.6.4", features = ["getrandom"] }
|
rand_core = { version = "0.6.4", features = ["getrandom"] }
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use either::{
|
|||||||
};
|
};
|
||||||
use figment::providers::{Env, Format, Toml};
|
use figment::providers::{Env, Format, Toml};
|
||||||
pub use figment::{Figment, value::Value as FigmentValue};
|
pub use figment::{Figment, value::Value as FigmentValue};
|
||||||
|
use lettre::message::Mailbox;
|
||||||
use regex::RegexSet;
|
use regex::RegexSet;
|
||||||
use ruma::{
|
use ruma::{
|
||||||
OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedUserId, RoomVersionId,
|
OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedUserId, RoomVersionId,
|
||||||
@@ -68,6 +69,10 @@ pub struct Config {
|
|||||||
///
|
///
|
||||||
/// Also see the `[global.well_known]` config section at the very bottom.
|
/// Also see the `[global.well_known]` config section at the very bottom.
|
||||||
///
|
///
|
||||||
|
/// If `client` is not set under `[global.well_known]`, the server name will
|
||||||
|
/// be used as the base domain for user-facing links (such as password
|
||||||
|
/// reset links) created by Continuwuity.
|
||||||
|
///
|
||||||
/// Examples of delegation:
|
/// Examples of delegation:
|
||||||
/// - https://continuwuity.org/.well-known/matrix/server
|
/// - https://continuwuity.org/.well-known/matrix/server
|
||||||
/// - https://continuwuity.org/.well-known/matrix/client
|
/// - https://continuwuity.org/.well-known/matrix/client
|
||||||
@@ -752,6 +757,9 @@ pub struct Config {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub well_known: WellKnownConfig,
|
pub well_known: WellKnownConfig,
|
||||||
|
|
||||||
|
/// display: nested
|
||||||
|
pub smtp: Option<SmtpConfig>,
|
||||||
|
|
||||||
/// Enable OpenTelemetry OTLP tracing export. This replaces the deprecated
|
/// Enable OpenTelemetry OTLP tracing export. This replaces the deprecated
|
||||||
/// Jaeger exporter. Traces will be sent via OTLP to a collector (such as
|
/// Jaeger exporter. Traces will be sent via OTLP to a collector (such as
|
||||||
/// Jaeger) that supports the OpenTelemetry Protocol.
|
/// Jaeger) that supports the OpenTelemetry Protocol.
|
||||||
@@ -1735,6 +1743,11 @@ pub struct Config {
|
|||||||
/// default: "continuwuity/<version> (bot; +https://continuwuity.org)"
|
/// default: "continuwuity/<version> (bot; +https://continuwuity.org)"
|
||||||
pub url_preview_user_agent: Option<String>,
|
pub url_preview_user_agent: Option<String>,
|
||||||
|
|
||||||
|
/// Determines whether audio and video files will be downloaded for URL
|
||||||
|
/// previews.
|
||||||
|
#[serde(default)]
|
||||||
|
pub url_preview_allow_audio_video: bool,
|
||||||
|
|
||||||
/// List of forbidden room aliases and room IDs as strings of regex
|
/// List of forbidden room aliases and room IDs as strings of regex
|
||||||
/// patterns.
|
/// patterns.
|
||||||
///
|
///
|
||||||
@@ -2084,6 +2097,13 @@ pub struct Config {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub force_disable_first_run_mode: bool,
|
pub force_disable_first_run_mode: bool,
|
||||||
|
|
||||||
|
/// Allow search engines and crawlers to index Continuwuity's built-in
|
||||||
|
/// webpages served under the `/_continuwuity/` prefix.
|
||||||
|
///
|
||||||
|
/// default: false
|
||||||
|
#[serde(default)]
|
||||||
|
pub allow_web_indexing: bool,
|
||||||
|
|
||||||
/// display: nested
|
/// display: nested
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ldap: LdapConfig,
|
pub ldap: LdapConfig,
|
||||||
@@ -2424,6 +2444,52 @@ pub struct DraupnirConfig {
|
|||||||
pub secret: String,
|
pub secret: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[config_example_generator(
|
||||||
|
filename = "conduwuit-example.toml",
|
||||||
|
section = "global.smtp",
|
||||||
|
optional = "true"
|
||||||
|
)]
|
||||||
|
pub struct SmtpConfig {
|
||||||
|
/// A `smtp://`` URI which will be used to connect to a mail server.
|
||||||
|
/// Uncommenting the [global.smtp] group and setting this option enables
|
||||||
|
/// features which depend on the ability to send email,
|
||||||
|
/// such as self-service password resets.
|
||||||
|
///
|
||||||
|
/// For most modern mail servers, format the URI like this:
|
||||||
|
/// `smtps://username:password@hostname:port`
|
||||||
|
/// Note that you will need to URL-encode the username and password. If your
|
||||||
|
/// username _is_ your email address, you will need to replace the `@` with
|
||||||
|
/// `%40`.
|
||||||
|
///
|
||||||
|
/// For a guide on the accepted URI syntax, consult Lettre's documentation:
|
||||||
|
/// https://docs.rs/lettre/latest/lettre/transport/smtp/struct.AsyncSmtpTransport.html#method.from_url
|
||||||
|
pub connection_uri: String,
|
||||||
|
|
||||||
|
/// The outgoing address which will be used for sending emails.
|
||||||
|
///
|
||||||
|
/// For a syntax guide, see https://datatracker.ietf.org/doc/html/rfc2822#section-3.4
|
||||||
|
///
|
||||||
|
/// ...or if you don't want to read the RFC, for some reason:
|
||||||
|
/// - `Name <address@domain.org>` to specify a sender name
|
||||||
|
/// - `address@domain.org` to not use a name
|
||||||
|
pub sender: Mailbox,
|
||||||
|
|
||||||
|
/// Whether to require that users provide an email address when they
|
||||||
|
/// register.
|
||||||
|
///
|
||||||
|
/// default: false
|
||||||
|
#[serde(default)]
|
||||||
|
pub require_email_for_registration: bool,
|
||||||
|
|
||||||
|
/// Whether to require that users who register with a registration token
|
||||||
|
/// provide an email address.
|
||||||
|
///
|
||||||
|
/// default: false
|
||||||
|
#[serde(default)]
|
||||||
|
pub require_email_for_token_registration: bool,
|
||||||
|
}
|
||||||
|
|
||||||
const DEPRECATED_KEYS: &[&str] = &[
|
const DEPRECATED_KEYS: &[&str] = &[
|
||||||
"cache_capacity",
|
"cache_capacity",
|
||||||
"conduit_cache_capacity_modifier",
|
"conduit_cache_capacity_modifier",
|
||||||
|
|||||||
@@ -1224,6 +1224,7 @@ fn can_send_event(event: &impl Event, ple: Option<&impl Event>, user_level: Int)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Confirm that the event sender has the required power levels.
|
/// Confirm that the event sender has the required power levels.
|
||||||
|
#[allow(clippy::cognitive_complexity)]
|
||||||
fn check_power_levels(
|
fn check_power_levels(
|
||||||
room_version: &RoomVersion,
|
room_version: &RoomVersion,
|
||||||
power_event: &impl Event,
|
power_event: &impl Event,
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ type Result<T, E = Error> = crate::Result<T, E>;
|
|||||||
/// event is part of the same room.
|
/// event is part of the same room.
|
||||||
//#[tracing::instrument(level = "debug", skip(state_sets, auth_chain_sets,
|
//#[tracing::instrument(level = "debug", skip(state_sets, auth_chain_sets,
|
||||||
//#[tracing::instrument(level event_fetch))]
|
//#[tracing::instrument(level event_fetch))]
|
||||||
|
#[allow(clippy::cognitive_complexity)]
|
||||||
pub async fn resolve<'a, Pdu, Sets, SetIter, Hasher, Fetch, FetchFut, Exists, ExistsFut>(
|
pub async fn resolve<'a, Pdu, Sets, SetIter, Hasher, Fetch, FetchFut, Exists, ExistsFut>(
|
||||||
room_version: &RoomVersionId,
|
room_version: &RoomVersionId,
|
||||||
state_sets: Sets,
|
state_sets: Sets,
|
||||||
|
|||||||
@@ -415,13 +415,6 @@ impl<'a, 'de: 'a> de::Deserializer<'de> for &'a mut Deserializer<'de> {
|
|||||||
tracing::instrument(level = "trace", skip_all, fields(?self.buf))
|
tracing::instrument(level = "trace", skip_all, fields(?self.buf))
|
||||||
)]
|
)]
|
||||||
fn deserialize_any<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value> {
|
fn deserialize_any<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value> {
|
||||||
debug_assert_eq!(
|
|
||||||
conduwuit::debug::type_name::<V>(),
|
|
||||||
"serde_json::value::de::<impl serde_core::de::Deserialize for \
|
|
||||||
serde_json::value::Value>::deserialize::ValueVisitor",
|
|
||||||
"deserialize_any: type not expected"
|
|
||||||
);
|
|
||||||
|
|
||||||
match self.record_peek_byte() {
|
match self.record_peek_byte() {
|
||||||
| Some(b'{') => self.deserialize_map(visitor),
|
| Some(b'{') => self.deserialize_map(visitor),
|
||||||
| Some(b'[') => serde_json::Deserializer::from_slice(self.record_next())
|
| Some(b'[') => serde_json::Deserializer::from_slice(self.record_next())
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ pub(super) static MAPS: &[Descriptor] = &[
|
|||||||
name: "disabledroomids",
|
name: "disabledroomids",
|
||||||
..descriptor::RANDOM_SMALL
|
..descriptor::RANDOM_SMALL
|
||||||
},
|
},
|
||||||
|
Descriptor {
|
||||||
|
name: "email_localpart",
|
||||||
|
..descriptor::RANDOM_SMALL
|
||||||
|
},
|
||||||
Descriptor {
|
Descriptor {
|
||||||
name: "eventid_outlierpdu",
|
name: "eventid_outlierpdu",
|
||||||
cache_disp: CacheDisp::SharedWith("pduid_pdu"),
|
cache_disp: CacheDisp::SharedWith("pduid_pdu"),
|
||||||
@@ -100,6 +104,10 @@ pub(super) static MAPS: &[Descriptor] = &[
|
|||||||
name: "lazyloadedids",
|
name: "lazyloadedids",
|
||||||
..descriptor::RANDOM_SMALL
|
..descriptor::RANDOM_SMALL
|
||||||
},
|
},
|
||||||
|
Descriptor {
|
||||||
|
name: "localpart_email",
|
||||||
|
..descriptor::RANDOM_SMALL
|
||||||
|
},
|
||||||
Descriptor {
|
Descriptor {
|
||||||
name: "mediaid_file",
|
name: "mediaid_file",
|
||||||
..descriptor::RANDOM_SMALL
|
..descriptor::RANDOM_SMALL
|
||||||
@@ -112,6 +120,10 @@ pub(super) static MAPS: &[Descriptor] = &[
|
|||||||
name: "onetimekeyid_onetimekeys",
|
name: "onetimekeyid_onetimekeys",
|
||||||
..descriptor::RANDOM_SMALL
|
..descriptor::RANDOM_SMALL
|
||||||
},
|
},
|
||||||
|
Descriptor {
|
||||||
|
name: "passwordresettoken_info",
|
||||||
|
..descriptor::RANDOM_SMALL
|
||||||
|
},
|
||||||
Descriptor {
|
Descriptor {
|
||||||
name: "pduid_pdu",
|
name: "pduid_pdu",
|
||||||
cache_disp: CacheDisp::SharedWith("eventid_outlierpdu"),
|
cache_disp: CacheDisp::SharedWith("eventid_outlierpdu"),
|
||||||
|
|||||||
@@ -18,5 +18,5 @@ pub(crate) fn build(services: &Arc<Services>) -> (Router, Guard) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn not_found(_uri: Uri) -> impl IntoResponse {
|
async fn not_found(_uri: Uri) -> impl IntoResponse {
|
||||||
Error::Request(ErrorKind::Unrecognized, "Not Found".into(), StatusCode::NOT_FOUND)
|
Error::Request(ErrorKind::Unrecognized, "not found :(".into(), StatusCode::NOT_FOUND)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ conduwuit-database.workspace = true
|
|||||||
const-str.workspace = true
|
const-str.workspace = true
|
||||||
either.workspace = true
|
either.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
|
governor.workspace = true
|
||||||
hickory-resolver.workspace = true
|
hickory-resolver.workspace = true
|
||||||
http.workspace = true
|
http.workspace = true
|
||||||
image.workspace = true
|
image.workspace = true
|
||||||
@@ -102,6 +103,7 @@ ldap3.optional = true
|
|||||||
log.workspace = true
|
log.workspace = true
|
||||||
loole.workspace = true
|
loole.workspace = true
|
||||||
lru-cache.workspace = true
|
lru-cache.workspace = true
|
||||||
|
nonzero_ext.workspace = true
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
regex.workspace = true
|
regex.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
@@ -121,8 +123,9 @@ webpage.workspace = true
|
|||||||
webpage.optional = true
|
webpage.optional = true
|
||||||
blurhash.workspace = true
|
blurhash.workspace = true
|
||||||
blurhash.optional = true
|
blurhash.optional = true
|
||||||
recaptcha-verify = { version = "0.1.5", default-features = false }
|
recaptcha-verify = { version = "0.2.0", default-features = false }
|
||||||
yansi.workspace = true
|
yansi.workspace = true
|
||||||
|
lettre.workspace = true
|
||||||
|
|
||||||
[target.'cfg(all(unix, target_os = "linux"))'.dependencies]
|
[target.'cfg(all(unix, target_os = "linux"))'.dependencies]
|
||||||
sd-notify.workspace = true
|
sd-notify.workspace = true
|
||||||
|
|||||||
@@ -272,7 +272,10 @@ impl Service {
|
|||||||
.get(id)
|
.get(id)
|
||||||
.await
|
.await
|
||||||
.and_then(|ref bytes| serde_saphyr::from_slice(bytes).map_err(Into::into))
|
.and_then(|ref bytes| serde_saphyr::from_slice(bytes).map_err(Into::into))
|
||||||
.map_err(|e| err!(Database("Invalid appservice {id:?} registration: {e:?}")))
|
.map_err(|e| {
|
||||||
|
self.db.id_appserviceregistrations.remove(id);
|
||||||
|
err!(Database("Invalid appservice {id:?} registration: {e:?}. Removed."))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read(&self) -> impl Future<Output = RwLockReadGuard<'_, Registrations>> + Send {
|
pub fn read(&self) -> impl Future<Output = RwLockReadGuard<'_, Registrations>> + Send {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use conduwuit::{
|
|||||||
config::{Config, check},
|
config::{Config, check},
|
||||||
error, implement,
|
error, implement,
|
||||||
};
|
};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
use crate::registration_tokens::{ValidToken, ValidTokenSource};
|
use crate::registration_tokens::{ValidToken, ValidTokenSource};
|
||||||
|
|
||||||
@@ -23,6 +24,18 @@ impl Service {
|
|||||||
.clone()
|
.clone()
|
||||||
.map(|token| ValidToken { token, source: ValidTokenSource::Config })
|
.map(|token| ValidToken { token, source: ValidTokenSource::Config })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the base domain to use for user-facing URLs.
|
||||||
|
#[must_use]
|
||||||
|
pub fn get_client_domain(&self) -> Url {
|
||||||
|
self.well_known.client.clone().unwrap_or_else(|| {
|
||||||
|
let host = self.server_name.host();
|
||||||
|
format!("https://{host}")
|
||||||
|
.as_str()
|
||||||
|
.try_into()
|
||||||
|
.expect("server name should be a valid host")
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ impl Service {
|
|||||||
/// if they were not.
|
/// if they were not.
|
||||||
pub async fn empower_first_user(&self, user: &UserId) -> Result<bool> {
|
pub async fn empower_first_user(&self, user: &UserId) -> Result<bool> {
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "welcome.md.j2")]
|
#[template(path = "welcome.md")]
|
||||||
struct WelcomeMessage<'a> {
|
struct WelcomeMessage<'a> {
|
||||||
config: &'a Dep<config::Service>,
|
config: &'a Dep<config::Service>,
|
||||||
domain: &'a str,
|
domain: &'a str,
|
||||||
@@ -228,19 +228,34 @@ impl Service {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.services.config.suspend_on_register {
|
||||||
|
eprintln!(
|
||||||
|
"{} Accounts created after yours will be suspended, as set in your \
|
||||||
|
configuration.",
|
||||||
|
"Your account will not be suspended when you register.".green()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(smtp) = &self.services.config.smtp {
|
||||||
|
if smtp.require_email_for_registration || smtp.require_email_for_token_registration {
|
||||||
|
eprintln!(
|
||||||
|
"{} Accounts created after yours may be required to provide an email \
|
||||||
|
address, as set in your configuration.",
|
||||||
|
"You will not be asked for your email address when you register.".yellow(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
eprintln!(
|
||||||
|
"If you wish to associate an email address with your account, you may do so \
|
||||||
|
after registration in your client's settings (if supported)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"{} https://matrix.org/ecosystem/clients/",
|
"{} https://matrix.org/ecosystem/clients/",
|
||||||
"Find a list of Matrix clients here:".bold()
|
"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
|
if self
|
||||||
.services
|
.services
|
||||||
.config
|
.config
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ impl Service {
|
|||||||
self.server.config.url_preview_check_root_domain
|
self.server.config.url_preview_check_root_domain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn url_preview_allow_audio_video(&self) -> bool {
|
||||||
|
self.server.config.url_preview_allow_audio_video
|
||||||
|
}
|
||||||
|
|
||||||
pub fn forbidden_alias_names(&self) -> &RegexSet { &self.server.config.forbidden_alias_names }
|
pub fn forbidden_alias_names(&self) -> &RegexSet { &self.server.config.forbidden_alias_names }
|
||||||
|
|
||||||
pub fn forbidden_usernames(&self) -> &RegexSet { &self.server.config.forbidden_usernames }
|
pub fn forbidden_usernames(&self) -> &RegexSet { &self.server.config.forbidden_usernames }
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
use askama::Template;
|
||||||
|
use ruma::UserId;
|
||||||
|
|
||||||
|
pub trait MessageTemplate: Template {
|
||||||
|
fn subject(&self) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "mail/change_email.txt")]
|
||||||
|
pub struct ChangeEmail<'a> {
|
||||||
|
pub server_name: &'a str,
|
||||||
|
pub user_id: Option<&'a UserId>,
|
||||||
|
pub verification_link: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MessageTemplate for ChangeEmail<'_> {
|
||||||
|
fn subject(&self) -> String { "Verify your email address".to_owned() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "mail/new_account.txt")]
|
||||||
|
pub struct NewAccount<'a> {
|
||||||
|
pub server_name: &'a str,
|
||||||
|
pub verification_link: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MessageTemplate for NewAccount<'_> {
|
||||||
|
fn subject(&self) -> String { "Create your new Matrix account".to_owned() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "mail/password_reset.txt")]
|
||||||
|
pub struct PasswordReset<'a> {
|
||||||
|
pub display_name: Option<&'a str>,
|
||||||
|
pub user_id: &'a UserId,
|
||||||
|
pub verification_link: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MessageTemplate for PasswordReset<'_> {
|
||||||
|
fn subject(&self) -> String { format!("Password reset request for {}", &self.user_id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "mail/test.txt")]
|
||||||
|
pub struct Test;
|
||||||
|
|
||||||
|
impl MessageTemplate for Test {
|
||||||
|
fn subject(&self) -> String { "Test message".to_owned() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use conduwuit::{Err, Result, err, info};
|
||||||
|
use lettre::{
|
||||||
|
AsyncSmtpTransport, AsyncTransport, Tokio1Executor,
|
||||||
|
message::{Mailbox, MessageBuilder, header::ContentType},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{Args, mailer::messages::MessageTemplate};
|
||||||
|
|
||||||
|
pub mod messages;
|
||||||
|
|
||||||
|
type Transport = AsyncSmtpTransport<Tokio1Executor>;
|
||||||
|
type TransportError = lettre::transport::smtp::Error;
|
||||||
|
|
||||||
|
pub struct Service {
|
||||||
|
transport: Option<(Mailbox, Transport)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl crate::Service for Service {
|
||||||
|
fn build(args: Args<'_>) -> Result<Arc<Self>> {
|
||||||
|
let transport = args
|
||||||
|
.server
|
||||||
|
.config
|
||||||
|
.smtp
|
||||||
|
.as_ref()
|
||||||
|
.map(|config| {
|
||||||
|
Ok((config.sender.clone(), Transport::from_url(&config.connection_uri)?.build()))
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
.map_err(|err: TransportError| err!("Failed to set up SMTP transport: {err}"))?;
|
||||||
|
|
||||||
|
Ok(Arc::new(Self { transport }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
|
||||||
|
|
||||||
|
async fn worker(self: Arc<Self>) -> Result<()> {
|
||||||
|
if let Some((_, ref transport)) = self.transport {
|
||||||
|
match transport.test_connection().await {
|
||||||
|
| Ok(true) => {
|
||||||
|
info!("SMTP connection test successful");
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
| Ok(false) => {
|
||||||
|
Err!("SMTP connection test failed")
|
||||||
|
},
|
||||||
|
| Err(err) => {
|
||||||
|
Err!("SMTP connection test failed: {err}")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
info!("SMTP is not configured, email functionality will be unavailable");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Service {
|
||||||
|
/// Returns a mailer which allows email to be sent, if SMTP is configured.
|
||||||
|
#[must_use]
|
||||||
|
pub fn mailer(&self) -> Option<Mailer<'_>> {
|
||||||
|
self.transport
|
||||||
|
.as_ref()
|
||||||
|
.map(|(sender, transport)| Mailer { sender, transport })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expect_mailer(&self) -> Result<Mailer<'_>> {
|
||||||
|
self.mailer().ok_or_else(|| {
|
||||||
|
err!(Request(FeatureDisabled("This homeserver is not configured to send email.")))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Mailer<'a> {
|
||||||
|
sender: &'a Mailbox,
|
||||||
|
transport: &'a Transport,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mailer<'_> {
|
||||||
|
/// Sends an email.
|
||||||
|
pub async fn send<Template: MessageTemplate>(
|
||||||
|
&self,
|
||||||
|
recipient: Mailbox,
|
||||||
|
message: Template,
|
||||||
|
) -> Result<()> {
|
||||||
|
let subject = message.subject();
|
||||||
|
let body = message
|
||||||
|
.render()
|
||||||
|
.map_err(|err| err!("Failed to render message template: {err}"))?;
|
||||||
|
|
||||||
|
let message = MessageBuilder::new()
|
||||||
|
.from(self.sender.clone())
|
||||||
|
.to(recipient)
|
||||||
|
.subject(subject)
|
||||||
|
.date_now()
|
||||||
|
.header(ContentType::TEXT_PLAIN)
|
||||||
|
.body(body)
|
||||||
|
.expect("should have been able to construct message");
|
||||||
|
|
||||||
|
self.transport
|
||||||
|
.send(message)
|
||||||
|
.await
|
||||||
|
.map_err(|err: TransportError| err!("Failed to send message: {err}"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -207,6 +207,28 @@ impl Data {
|
|||||||
value.extend_from_slice(&data.image_width.unwrap_or(0).to_be_bytes());
|
value.extend_from_slice(&data.image_width.unwrap_or(0).to_be_bytes());
|
||||||
value.push(0xFF);
|
value.push(0xFF);
|
||||||
value.extend_from_slice(&data.image_height.unwrap_or(0).to_be_bytes());
|
value.extend_from_slice(&data.image_height.unwrap_or(0).to_be_bytes());
|
||||||
|
value.push(0xFF);
|
||||||
|
value.extend_from_slice(
|
||||||
|
data.video
|
||||||
|
.as_ref()
|
||||||
|
.map(String::as_bytes)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
);
|
||||||
|
value.push(0xFF);
|
||||||
|
value.extend_from_slice(&data.video_size.unwrap_or(0).to_be_bytes());
|
||||||
|
value.push(0xFF);
|
||||||
|
value.extend_from_slice(&data.video_width.unwrap_or(0).to_be_bytes());
|
||||||
|
value.push(0xFF);
|
||||||
|
value.extend_from_slice(&data.video_height.unwrap_or(0).to_be_bytes());
|
||||||
|
value.push(0xFF);
|
||||||
|
value.extend_from_slice(
|
||||||
|
data.audio
|
||||||
|
.as_ref()
|
||||||
|
.map(String::as_bytes)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
);
|
||||||
|
value.push(0xFF);
|
||||||
|
value.extend_from_slice(&data.audio_size.unwrap_or(0).to_be_bytes());
|
||||||
|
|
||||||
self.url_previews.insert(url.as_bytes(), &value);
|
self.url_previews.insert(url.as_bytes(), &value);
|
||||||
|
|
||||||
@@ -267,6 +289,48 @@ impl Data {
|
|||||||
| Some(0) => None,
|
| Some(0) => None,
|
||||||
| x => x,
|
| x => x,
|
||||||
};
|
};
|
||||||
|
let video = match values
|
||||||
|
.next()
|
||||||
|
.and_then(|b| String::from_utf8(b.to_vec()).ok())
|
||||||
|
{
|
||||||
|
| Some(s) if s.is_empty() => None,
|
||||||
|
| x => x,
|
||||||
|
};
|
||||||
|
let video_size = match values
|
||||||
|
.next()
|
||||||
|
.map(|b| usize::from_be_bytes(b.try_into().unwrap_or_default()))
|
||||||
|
{
|
||||||
|
| Some(0) => None,
|
||||||
|
| x => x,
|
||||||
|
};
|
||||||
|
let video_width = match values
|
||||||
|
.next()
|
||||||
|
.map(|b| u32::from_be_bytes(b.try_into().unwrap_or_default()))
|
||||||
|
{
|
||||||
|
| Some(0) => None,
|
||||||
|
| x => x,
|
||||||
|
};
|
||||||
|
let video_height = match values
|
||||||
|
.next()
|
||||||
|
.map(|b| u32::from_be_bytes(b.try_into().unwrap_or_default()))
|
||||||
|
{
|
||||||
|
| Some(0) => None,
|
||||||
|
| x => x,
|
||||||
|
};
|
||||||
|
let audio = match values
|
||||||
|
.next()
|
||||||
|
.and_then(|b| String::from_utf8(b.to_vec()).ok())
|
||||||
|
{
|
||||||
|
| Some(s) if s.is_empty() => None,
|
||||||
|
| x => x,
|
||||||
|
};
|
||||||
|
let audio_size = match values
|
||||||
|
.next()
|
||||||
|
.map(|b| usize::from_be_bytes(b.try_into().unwrap_or_default()))
|
||||||
|
{
|
||||||
|
| Some(0) => None,
|
||||||
|
| x => x,
|
||||||
|
};
|
||||||
|
|
||||||
Ok(UrlPreviewData {
|
Ok(UrlPreviewData {
|
||||||
title,
|
title,
|
||||||
@@ -275,6 +339,12 @@ impl Data {
|
|||||||
image_size,
|
image_size,
|
||||||
image_width,
|
image_width,
|
||||||
image_height,
|
image_height,
|
||||||
|
video,
|
||||||
|
video_size,
|
||||||
|
video_width,
|
||||||
|
video_height,
|
||||||
|
audio,
|
||||||
|
audio_size,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+155
-23
@@ -10,6 +10,8 @@ use std::time::SystemTime;
|
|||||||
use conduwuit::{Err, Result, debug, err, utils::response::LimitReadExt};
|
use conduwuit::{Err, Result, debug, err, utils::response::LimitReadExt};
|
||||||
use conduwuit_core::implement;
|
use conduwuit_core::implement;
|
||||||
use ipaddress::IPAddress;
|
use ipaddress::IPAddress;
|
||||||
|
#[cfg(feature = "url_preview")]
|
||||||
|
use ruma::OwnedMxcUri;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
@@ -29,6 +31,18 @@ pub struct UrlPreviewData {
|
|||||||
pub image_width: Option<u32>,
|
pub image_width: Option<u32>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none", rename(serialize = "og:image:height"))]
|
#[serde(skip_serializing_if = "Option::is_none", rename(serialize = "og:image:height"))]
|
||||||
pub image_height: Option<u32>,
|
pub image_height: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none", rename(serialize = "og:video"))]
|
||||||
|
pub video: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none", rename(serialize = "matrix:video:size"))]
|
||||||
|
pub video_size: Option<usize>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none", rename(serialize = "og:video:width"))]
|
||||||
|
pub video_width: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none", rename(serialize = "og:video:height"))]
|
||||||
|
pub video_height: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none", rename(serialize = "og:audio"))]
|
||||||
|
pub audio: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none", rename(serialize = "matrix:audio:size"))]
|
||||||
|
pub audio_size: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[implement(Service)]
|
#[implement(Service)]
|
||||||
@@ -96,7 +110,9 @@ async fn request_url_preview(&self, url: &Url) -> Result<UrlPreviewData> {
|
|||||||
|
|
||||||
let data = match content_type {
|
let data = match content_type {
|
||||||
| html if html.starts_with("text/html") => self.download_html(url.as_str()).await?,
|
| html if html.starts_with("text/html") => self.download_html(url.as_str()).await?,
|
||||||
| img if img.starts_with("image/") => self.download_image(url.as_str()).await?,
|
| img if img.starts_with("image/") => self.download_image(url.as_str(), None).await?,
|
||||||
|
| video if video.starts_with("video/") => self.download_video(url.as_str(), None).await?,
|
||||||
|
| audio if audio.starts_with("audio/") => self.download_audio(url.as_str(), None).await?,
|
||||||
| _ => return Err!(Request(Unknown("Unsupported Content-Type"))),
|
| _ => return Err!(Request(Unknown("Unsupported Content-Type"))),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,11 +123,17 @@ async fn request_url_preview(&self, url: &Url) -> Result<UrlPreviewData> {
|
|||||||
|
|
||||||
#[cfg(feature = "url_preview")]
|
#[cfg(feature = "url_preview")]
|
||||||
#[implement(Service)]
|
#[implement(Service)]
|
||||||
pub async fn download_image(&self, url: &str) -> Result<UrlPreviewData> {
|
pub async fn download_image(
|
||||||
|
&self,
|
||||||
|
url: &str,
|
||||||
|
preview_data: Option<UrlPreviewData>,
|
||||||
|
) -> Result<UrlPreviewData> {
|
||||||
use conduwuit::utils::random_string;
|
use conduwuit::utils::random_string;
|
||||||
use image::ImageReader;
|
use image::ImageReader;
|
||||||
use ruma::Mxc;
|
use ruma::Mxc;
|
||||||
|
|
||||||
|
let mut preview_data = preview_data.unwrap_or_default();
|
||||||
|
|
||||||
let image = self
|
let image = self
|
||||||
.services
|
.services
|
||||||
.client
|
.client
|
||||||
@@ -128,6 +150,7 @@ pub async fn download_image(&self, url: &str) -> Result<UrlPreviewData> {
|
|||||||
.expect("u64 should fit in usize"),
|
.expect("u64 should fit in usize"),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mxc = Mxc {
|
let mxc = Mxc {
|
||||||
server_name: self.services.globals.server_name(),
|
server_name: self.services.globals.server_name(),
|
||||||
media_id: &random_string(super::MXC_LENGTH),
|
media_id: &random_string(super::MXC_LENGTH),
|
||||||
@@ -135,27 +158,125 @@ pub async fn download_image(&self, url: &str) -> Result<UrlPreviewData> {
|
|||||||
|
|
||||||
self.create(&mxc, None, None, None, &image).await?;
|
self.create(&mxc, None, None, None, &image).await?;
|
||||||
|
|
||||||
let cursor = std::io::Cursor::new(&image);
|
preview_data.image = Some(mxc.to_string());
|
||||||
let (width, height) = match ImageReader::new(cursor).with_guessed_format() {
|
if preview_data.image_height.is_none() || preview_data.image_width.is_none() {
|
||||||
| Err(_) => (None, None),
|
let cursor = std::io::Cursor::new(&image);
|
||||||
| Ok(reader) => match reader.into_dimensions() {
|
let (width, height) = match ImageReader::new(cursor).with_guessed_format() {
|
||||||
| Err(_) => (None, None),
|
| Err(_) => (None, None),
|
||||||
| Ok((width, height)) => (Some(width), Some(height)),
|
| Ok(reader) => match reader.into_dimensions() {
|
||||||
},
|
| Err(_) => (None, None),
|
||||||
|
| Ok((width, height)) => (Some(width), Some(height)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
preview_data.image_width = width;
|
||||||
|
preview_data.image_height = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(preview_data)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "url_preview")]
|
||||||
|
#[implement(Service)]
|
||||||
|
pub async fn download_video(
|
||||||
|
&self,
|
||||||
|
url: &str,
|
||||||
|
preview_data: Option<UrlPreviewData>,
|
||||||
|
) -> Result<UrlPreviewData> {
|
||||||
|
let mut preview_data = preview_data.unwrap_or_default();
|
||||||
|
|
||||||
|
if self.services.globals.url_preview_allow_audio_video() {
|
||||||
|
let (url, size) = self.download_media(url).await?;
|
||||||
|
preview_data.video = Some(url.to_string());
|
||||||
|
preview_data.video_size = Some(size);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(preview_data)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "url_preview")]
|
||||||
|
#[implement(Service)]
|
||||||
|
pub async fn download_audio(
|
||||||
|
&self,
|
||||||
|
url: &str,
|
||||||
|
preview_data: Option<UrlPreviewData>,
|
||||||
|
) -> Result<UrlPreviewData> {
|
||||||
|
let mut preview_data = preview_data.unwrap_or_default();
|
||||||
|
|
||||||
|
if self.services.globals.url_preview_allow_audio_video() {
|
||||||
|
let (url, size) = self.download_media(url).await?;
|
||||||
|
preview_data.audio = Some(url.to_string());
|
||||||
|
preview_data.audio_size = Some(size);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(preview_data)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "url_preview")]
|
||||||
|
#[implement(Service)]
|
||||||
|
pub async fn download_media(&self, url: &str) -> Result<(OwnedMxcUri, usize)> {
|
||||||
|
use conduwuit::utils::random_string;
|
||||||
|
use http::header::CONTENT_TYPE;
|
||||||
|
use ruma::Mxc;
|
||||||
|
|
||||||
|
let response = self.services.client.url_preview.get(url).send().await?;
|
||||||
|
let content_type = response.headers().get(CONTENT_TYPE).cloned();
|
||||||
|
let media = response
|
||||||
|
.limit_read(
|
||||||
|
self.services
|
||||||
|
.server
|
||||||
|
.config
|
||||||
|
.max_request_size
|
||||||
|
.try_into()
|
||||||
|
.expect("u64 should fit in usize"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mxc = Mxc {
|
||||||
|
server_name: self.services.globals.server_name(),
|
||||||
|
media_id: &random_string(super::MXC_LENGTH),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(UrlPreviewData {
|
let content_type = content_type.and_then(|v| v.to_str().map(ToOwned::to_owned).ok());
|
||||||
image: Some(mxc.to_string()),
|
self.create(&mxc, None, None, content_type.as_deref(), &media)
|
||||||
image_size: Some(image.len()),
|
.await?;
|
||||||
image_width: width,
|
|
||||||
image_height: height,
|
Ok((OwnedMxcUri::from(mxc.to_string()), media.len()))
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "url_preview"))]
|
#[cfg(not(feature = "url_preview"))]
|
||||||
#[implement(Service)]
|
#[implement(Service)]
|
||||||
pub async fn download_image(&self, _url: &str) -> Result<UrlPreviewData> {
|
pub async fn download_image(
|
||||||
|
&self,
|
||||||
|
_url: &str,
|
||||||
|
_preview_data: Option<UrlPreviewData>,
|
||||||
|
) -> Result<UrlPreviewData> {
|
||||||
|
Err!(FeatureDisabled("url_preview"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "url_preview"))]
|
||||||
|
#[implement(Service)]
|
||||||
|
pub async fn download_video(
|
||||||
|
&self,
|
||||||
|
_url: &str,
|
||||||
|
_preview_data: Option<UrlPreviewData>,
|
||||||
|
) -> Result<UrlPreviewData> {
|
||||||
|
Err!(FeatureDisabled("url_preview"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "url_preview"))]
|
||||||
|
#[implement(Service)]
|
||||||
|
pub async fn download_audio(
|
||||||
|
&self,
|
||||||
|
_url: &str,
|
||||||
|
_preview_data: Option<UrlPreviewData>,
|
||||||
|
) -> Result<UrlPreviewData> {
|
||||||
|
Err!(FeatureDisabled("url_preview"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "url_preview"))]
|
||||||
|
#[implement(Service)]
|
||||||
|
pub async fn download_media(&self, _url: &str) -> Result<UrlPreviewData> {
|
||||||
Err!(FeatureDisabled("url_preview"))
|
Err!(FeatureDisabled("url_preview"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,18 +303,29 @@ async fn download_html(&self, url: &str) -> Result<UrlPreviewData> {
|
|||||||
return Err!(Request(Unknown("Failed to parse HTML")));
|
return Err!(Request(Unknown("Failed to parse HTML")));
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut data = match html.opengraph.images.first() {
|
let mut preview_data = UrlPreviewData::default();
|
||||||
| None => UrlPreviewData::default(),
|
|
||||||
| Some(obj) => self.download_image(&obj.url).await?,
|
if let Some(obj) = html.opengraph.images.first() {
|
||||||
};
|
preview_data = self.download_image(&obj.url, Some(preview_data)).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(obj) = html.opengraph.videos.first() {
|
||||||
|
preview_data = self.download_video(&obj.url, Some(preview_data)).await?;
|
||||||
|
preview_data.video_width = obj.properties.get("width").and_then(|v| v.parse().ok());
|
||||||
|
preview_data.video_height = obj.properties.get("height").and_then(|v| v.parse().ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(obj) = html.opengraph.audios.first() {
|
||||||
|
preview_data = self.download_audio(&obj.url, Some(preview_data)).await?;
|
||||||
|
}
|
||||||
|
|
||||||
let props = html.opengraph.properties;
|
let props = html.opengraph.properties;
|
||||||
|
|
||||||
/* use OpenGraph title/description, but fall back to HTML if not available */
|
/* use OpenGraph title/description, but fall back to HTML if not available */
|
||||||
data.title = props.get("title").cloned().or(html.title);
|
preview_data.title = props.get("title").cloned().or(html.title);
|
||||||
data.description = props.get("description").cloned().or(html.description);
|
preview_data.description = props.get("description").cloned().or(html.description);
|
||||||
|
|
||||||
Ok(data)
|
Ok(preview_data)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "url_preview"))]
|
#[cfg(not(feature = "url_preview"))]
|
||||||
|
|||||||
+90
-22
@@ -1,8 +1,9 @@
|
|||||||
use std::{cmp, collections::HashMap, future::ready};
|
use std::{cmp, collections::HashMap, future::ready};
|
||||||
|
|
||||||
use conduwuit::{
|
use conduwuit::{
|
||||||
Err, Event, Pdu, Result, debug, debug_info, debug_warn, error, info,
|
Err, Event, Pdu, Result, debug, debug_info, debug_warn, err, error, info,
|
||||||
result::NotFound,
|
result::NotFound,
|
||||||
|
trace,
|
||||||
utils::{
|
utils::{
|
||||||
IterStream, ReadyExt,
|
IterStream, ReadyExt,
|
||||||
stream::{TryExpect, TryIgnore},
|
stream::{TryExpect, TryIgnore},
|
||||||
@@ -57,6 +58,7 @@ pub(crate) async fn migrations(services: &Services) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn fresh(services: &Services) -> Result<()> {
|
async fn fresh(services: &Services) -> Result<()> {
|
||||||
|
info!("Creating new fresh database");
|
||||||
let db = &services.db;
|
let db = &services.db;
|
||||||
|
|
||||||
services.globals.db.bump_database_version(DATABASE_VERSION);
|
services.globals.db.bump_database_version(DATABASE_VERSION);
|
||||||
@@ -66,11 +68,18 @@ async fn fresh(services: &Services) -> Result<()> {
|
|||||||
db["global"].insert(b"retroactively_fix_bad_data_from_roomuserid_joined", []);
|
db["global"].insert(b"retroactively_fix_bad_data_from_roomuserid_joined", []);
|
||||||
db["global"].insert(b"fix_referencedevents_missing_sep", []);
|
db["global"].insert(b"fix_referencedevents_missing_sep", []);
|
||||||
db["global"].insert(b"fix_readreceiptid_readreceipt_duplicates", []);
|
db["global"].insert(b"fix_readreceiptid_readreceipt_duplicates", []);
|
||||||
|
db["global"].insert(b"fix_corrupt_msc4133_fields", []);
|
||||||
|
db["global"].insert(b"populate_userroomid_leftstate_table", []);
|
||||||
|
db["global"].insert(b"fix_local_invite_state", []);
|
||||||
|
|
||||||
// Create the admin room and server user on first run
|
// Create the admin room and server user on first run
|
||||||
crate::admin::create_admin_room(services).boxed().await?;
|
info!("Creating admin room and server user");
|
||||||
|
crate::admin::create_admin_room(services)
|
||||||
|
.boxed()
|
||||||
|
.await
|
||||||
|
.inspect_err(|e| error!("Failed to create admin room during db init: {e}"))?;
|
||||||
|
|
||||||
warn!("Created new RocksDB database with version {DATABASE_VERSION}");
|
info!("Created new database with version {DATABASE_VERSION}");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -88,19 +97,33 @@ async fn migrate(services: &Services) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if services.globals.db.database_version().await < 12 {
|
if services.globals.db.database_version().await < 12 {
|
||||||
db_lt_12(services).await?;
|
db_lt_12(services)
|
||||||
|
.await
|
||||||
|
.map_err(|e| err!("Failed to run v12 migrations: {e}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This migration can be reused as-is anytime the server-default rules are
|
// This migration can be reused as-is anytime the server-default rules are
|
||||||
// updated.
|
// updated.
|
||||||
if services.globals.db.database_version().await < 13 {
|
if services.globals.db.database_version().await < 13 {
|
||||||
db_lt_13(services).await?;
|
db_lt_13(services)
|
||||||
|
.await
|
||||||
|
.map_err(|e| err!("Failed to run v13 migrations: {e}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if db["global"].get(b"feat_sha256_media").await.is_not_found() {
|
if db["global"].get(b"feat_sha256_media").await.is_not_found() {
|
||||||
media::migrations::migrate_sha256_media(services).await?;
|
media::migrations::migrate_sha256_media(services)
|
||||||
|
.await
|
||||||
|
.map_err(|e| err!("Failed to run SHA256 media migration: {e}"))?;
|
||||||
} else if config.media_startup_check {
|
} else if config.media_startup_check {
|
||||||
media::migrations::checkup_sha256_media(services).await?;
|
info!("Starting media startup integrity check.");
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
media::migrations::checkup_sha256_media(services)
|
||||||
|
.await
|
||||||
|
.map_err(|e| err!("Failed to verify media integrity: {e}"))?;
|
||||||
|
info!(
|
||||||
|
"Finished media startup integrity check in {} seconds.",
|
||||||
|
now.elapsed().as_secs_f32()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if db["global"]
|
if db["global"]
|
||||||
@@ -108,7 +131,12 @@ async fn migrate(services: &Services) -> Result<()> {
|
|||||||
.await
|
.await
|
||||||
.is_not_found()
|
.is_not_found()
|
||||||
{
|
{
|
||||||
fix_bad_double_separator_in_state_cache(services).await?;
|
info!("Running migration 'fix_bad_double_separator_in_state_cache'");
|
||||||
|
fix_bad_double_separator_in_state_cache(services)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
err!("Failed to run 'fix_bad_double_separator_in_state_cache' migration: {e}")
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if db["global"]
|
if db["global"]
|
||||||
@@ -116,7 +144,15 @@ async fn migrate(services: &Services) -> Result<()> {
|
|||||||
.await
|
.await
|
||||||
.is_not_found()
|
.is_not_found()
|
||||||
{
|
{
|
||||||
retroactively_fix_bad_data_from_roomuserid_joined(services).await?;
|
info!("Running migration 'retroactively_fix_bad_data_from_roomuserid_joined'");
|
||||||
|
retroactively_fix_bad_data_from_roomuserid_joined(services)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
err!(
|
||||||
|
"Failed to run 'retroactively_fix_bad_data_from_roomuserid_joined' \
|
||||||
|
migration: {e}"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if db["global"]
|
if db["global"]
|
||||||
@@ -125,7 +161,12 @@ async fn migrate(services: &Services) -> Result<()> {
|
|||||||
.is_not_found()
|
.is_not_found()
|
||||||
|| services.globals.db.database_version().await < 17
|
|| services.globals.db.database_version().await < 17
|
||||||
{
|
{
|
||||||
fix_referencedevents_missing_sep(services).await?;
|
info!("Running migration 'fix_referencedevents_missing_sep'");
|
||||||
|
fix_referencedevents_missing_sep(services)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
err!("Failed to run 'fix_referencedevents_missing_sep' migration': {e}")
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if db["global"]
|
if db["global"]
|
||||||
@@ -134,7 +175,12 @@ async fn migrate(services: &Services) -> Result<()> {
|
|||||||
.is_not_found()
|
.is_not_found()
|
||||||
|| services.globals.db.database_version().await < 17
|
|| services.globals.db.database_version().await < 17
|
||||||
{
|
{
|
||||||
fix_readreceiptid_readreceipt_duplicates(services).await?;
|
info!("Running migration 'fix_readreceiptid_readreceipt_duplicates'");
|
||||||
|
fix_readreceiptid_readreceipt_duplicates(services)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
err!("Failed to run 'fix_readreceiptid_readreceipt_duplicates' migration': {e}")
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if services.globals.db.database_version().await < 17 {
|
if services.globals.db.database_version().await < 17 {
|
||||||
@@ -147,7 +193,10 @@ async fn migrate(services: &Services) -> Result<()> {
|
|||||||
.await
|
.await
|
||||||
.is_not_found()
|
.is_not_found()
|
||||||
{
|
{
|
||||||
fix_corrupt_msc4133_fields(services).await?;
|
info!("Running migration 'fix_corrupt_msc4133_fields'");
|
||||||
|
fix_corrupt_msc4133_fields(services)
|
||||||
|
.await
|
||||||
|
.map_err(|e| err!("Failed to run 'fix_corrupt_msc4133_fields' migration': {e}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if services.globals.db.database_version().await < 18 {
|
if services.globals.db.database_version().await < 18 {
|
||||||
@@ -160,7 +209,12 @@ async fn migrate(services: &Services) -> Result<()> {
|
|||||||
.await
|
.await
|
||||||
.is_not_found()
|
.is_not_found()
|
||||||
{
|
{
|
||||||
populate_userroomid_leftstate_table(services).await?;
|
info!("Running migration 'populate_userroomid_leftstate_table'");
|
||||||
|
populate_userroomid_leftstate_table(services)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
err!("Failed to run 'populate_userroomid_leftstate_table' migration': {e}")
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if db["global"]
|
if db["global"]
|
||||||
@@ -168,14 +222,17 @@ async fn migrate(services: &Services) -> Result<()> {
|
|||||||
.await
|
.await
|
||||||
.is_not_found()
|
.is_not_found()
|
||||||
{
|
{
|
||||||
fix_local_invite_state(services).await?;
|
info!("Running migration 'fix_local_invite_state'");
|
||||||
|
fix_local_invite_state(services)
|
||||||
|
.await
|
||||||
|
.map_err(|e| err!("Failed to run 'fix_local_invite_state' migration': {e}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
services.globals.db.database_version().await,
|
services.globals.db.database_version().await,
|
||||||
DATABASE_VERSION,
|
DATABASE_VERSION,
|
||||||
"Failed asserting local database version {} is equal to known latest conduwuit database \
|
"Failed asserting local database version {} is equal to known latest continuwuity \
|
||||||
version {}",
|
database version {}",
|
||||||
services.globals.db.database_version().await,
|
services.globals.db.database_version().await,
|
||||||
DATABASE_VERSION,
|
DATABASE_VERSION,
|
||||||
);
|
);
|
||||||
@@ -370,7 +427,7 @@ async fn db_lt_13(services: &Services) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn fix_bad_double_separator_in_state_cache(services: &Services) -> Result<()> {
|
async fn fix_bad_double_separator_in_state_cache(services: &Services) -> Result<()> {
|
||||||
warn!("Fixing bad double separator in state_cache roomuserid_joined");
|
info!("Fixing bad double separator in state_cache roomuserid_joined");
|
||||||
|
|
||||||
let db = &services.db;
|
let db = &services.db;
|
||||||
let roomuserid_joined = &db["roomuserid_joined"];
|
let roomuserid_joined = &db["roomuserid_joined"];
|
||||||
@@ -414,7 +471,7 @@ async fn fix_bad_double_separator_in_state_cache(services: &Services) -> Result<
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn retroactively_fix_bad_data_from_roomuserid_joined(services: &Services) -> Result<()> {
|
async fn retroactively_fix_bad_data_from_roomuserid_joined(services: &Services) -> Result<()> {
|
||||||
warn!("Retroactively fixing bad data from broken roomuserid_joined");
|
info!("Retroactively fixing bad data from broken roomuserid_joined");
|
||||||
|
|
||||||
let db = &services.db;
|
let db = &services.db;
|
||||||
let _cork = db.cork_and_sync();
|
let _cork = db.cork_and_sync();
|
||||||
@@ -504,7 +561,7 @@ async fn retroactively_fix_bad_data_from_roomuserid_joined(services: &Services)
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn fix_referencedevents_missing_sep(services: &Services) -> Result {
|
async fn fix_referencedevents_missing_sep(services: &Services) -> Result {
|
||||||
warn!("Fixing missing record separator between room_id and event_id in referencedevents");
|
info!("Fixing missing record separator between room_id and event_id in referencedevents");
|
||||||
|
|
||||||
let db = &services.db;
|
let db = &services.db;
|
||||||
let cork = db.cork_and_sync();
|
let cork = db.cork_and_sync();
|
||||||
@@ -552,7 +609,7 @@ async fn fix_readreceiptid_readreceipt_duplicates(services: &Services) -> Result
|
|||||||
type ArrayId = ArrayString<MAX_BYTES>;
|
type ArrayId = ArrayString<MAX_BYTES>;
|
||||||
type Key<'a> = (&'a RoomId, u64, &'a UserId);
|
type Key<'a> = (&'a RoomId, u64, &'a UserId);
|
||||||
|
|
||||||
warn!("Fixing undeleted entries in readreceiptid_readreceipt...");
|
info!("Fixing undeleted entries in readreceiptid_readreceipt...");
|
||||||
|
|
||||||
let db = &services.db;
|
let db = &services.db;
|
||||||
let cork = db.cork_and_sync();
|
let cork = db.cork_and_sync();
|
||||||
@@ -606,7 +663,7 @@ async fn fix_corrupt_msc4133_fields(services: &Services) -> Result {
|
|||||||
use serde_json::{Value, from_slice};
|
use serde_json::{Value, from_slice};
|
||||||
type KeyVal<'a> = ((OwnedUserId, String), &'a [u8]);
|
type KeyVal<'a> = ((OwnedUserId, String), &'a [u8]);
|
||||||
|
|
||||||
warn!("Fixing corrupted `us.cloke.msc4175.tz` fields...");
|
info!("Fixing corrupted `us.cloke.msc4175.tz` fields...");
|
||||||
|
|
||||||
let db = &services.db;
|
let db = &services.db;
|
||||||
let cork = db.cork_and_sync();
|
let cork = db.cork_and_sync();
|
||||||
@@ -746,7 +803,18 @@ async fn fix_local_invite_state(services: &Services) -> Result {
|
|||||||
let fixed = userroomid_invitestate.stream()
|
let fixed = userroomid_invitestate.stream()
|
||||||
// if they're a local user on this homeserver
|
// if they're a local user on this homeserver
|
||||||
.try_filter(|((user_id, _), _): &KeyVal<'_>| ready(services.globals.user_is_local(user_id)))
|
.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()?)))
|
.and_then(async |((user_id, room_id), stripped_state): KeyVal<'_>| Ok::<_,
|
||||||
|
conduwuit::Error>((user_id.to_owned(), room_id.to_owned(), stripped_state.deserialize
|
||||||
|
().unwrap_or_else(|e| {
|
||||||
|
trace!("Failed to deserialize: {:?}", stripped_state.json());
|
||||||
|
warn!(
|
||||||
|
%user_id,
|
||||||
|
%room_id,
|
||||||
|
"Failed to deserialize stripped state for invite, removing from db: {e}"
|
||||||
|
);
|
||||||
|
userroomid_invitestate.del((user_id, room_id));
|
||||||
|
vec![]
|
||||||
|
}))))
|
||||||
.try_fold(0_usize, async |mut fixed, (user_id, room_id, stripped_state)| {
|
.try_fold(0_usize, async |mut fixed, (user_id, room_id, stripped_state)| {
|
||||||
// and their invite state is None
|
// and their invite state is None
|
||||||
if stripped_state.is_empty()
|
if stripped_state.is_empty()
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ pub mod federation;
|
|||||||
pub mod firstrun;
|
pub mod firstrun;
|
||||||
pub mod globals;
|
pub mod globals;
|
||||||
pub mod key_backups;
|
pub mod key_backups;
|
||||||
|
pub mod mailer;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
pub mod moderation;
|
pub mod moderation;
|
||||||
|
pub mod password_reset;
|
||||||
pub mod presence;
|
pub mod presence;
|
||||||
pub mod pusher;
|
pub mod pusher;
|
||||||
pub mod registration_tokens;
|
pub mod registration_tokens;
|
||||||
@@ -31,6 +33,7 @@ pub mod rooms;
|
|||||||
pub mod sending;
|
pub mod sending;
|
||||||
pub mod server_keys;
|
pub mod server_keys;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
|
pub mod threepid;
|
||||||
pub mod transactions;
|
pub mod transactions;
|
||||||
pub mod uiaa;
|
pub mod uiaa;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
use std::{
|
||||||
|
sync::Arc,
|
||||||
|
time::{Duration, SystemTime},
|
||||||
|
};
|
||||||
|
|
||||||
|
use conduwuit::utils::{ReadyExt, stream::TryExpect};
|
||||||
|
use database::{Database, Deserialized, Json, Map};
|
||||||
|
use ruma::{OwnedUserId, UserId};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub(super) struct Data {
|
||||||
|
passwordresettoken_info: Arc<Map>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ResetTokenInfo {
|
||||||
|
pub user: OwnedUserId,
|
||||||
|
pub issued_at: SystemTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResetTokenInfo {
|
||||||
|
// one hour
|
||||||
|
const MAX_TOKEN_AGE: Duration = Duration::from_secs(60 * 60);
|
||||||
|
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
let now = SystemTime::now();
|
||||||
|
|
||||||
|
now.duration_since(self.issued_at)
|
||||||
|
.is_ok_and(|duration| duration < Self::MAX_TOKEN_AGE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Data {
|
||||||
|
pub(super) fn new(db: &Arc<Database>) -> Self {
|
||||||
|
Self {
|
||||||
|
passwordresettoken_info: db["passwordresettoken_info"].clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Associate a reset token with its info in the database.
|
||||||
|
pub(super) fn save_token(&self, token: &str, info: &ResetTokenInfo) {
|
||||||
|
self.passwordresettoken_info.raw_put(token, Json(info));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lookup the info for a reset token.
|
||||||
|
pub(super) async fn lookup_token_info(&self, token: &str) -> Option<ResetTokenInfo> {
|
||||||
|
self.passwordresettoken_info
|
||||||
|
.get(token)
|
||||||
|
.await
|
||||||
|
.deserialized()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a user's existing reset token, if any.
|
||||||
|
pub(super) async fn find_token_for_user(
|
||||||
|
&self,
|
||||||
|
user: &UserId,
|
||||||
|
) -> Option<(String, ResetTokenInfo)> {
|
||||||
|
self.passwordresettoken_info
|
||||||
|
.stream::<'_, String, ResetTokenInfo>()
|
||||||
|
.expect_ok()
|
||||||
|
.ready_find(|(_, info)| info.user == user)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a reset token.
|
||||||
|
pub(super) fn remove_token(&self, token: &str) { self.passwordresettoken_info.remove(token); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
mod data;
|
||||||
|
|
||||||
|
use std::{sync::Arc, time::SystemTime};
|
||||||
|
|
||||||
|
use conduwuit::{Err, Result, utils};
|
||||||
|
use data::{Data, ResetTokenInfo};
|
||||||
|
use ruma::OwnedUserId;
|
||||||
|
|
||||||
|
use crate::{Dep, globals, users};
|
||||||
|
|
||||||
|
pub const PASSWORD_RESET_PATH: &str = "/_continuwuity/account/reset_password";
|
||||||
|
pub const RESET_TOKEN_QUERY_PARAM: &str = "token";
|
||||||
|
const RESET_TOKEN_LENGTH: usize = 32;
|
||||||
|
|
||||||
|
pub struct Service {
|
||||||
|
db: Data,
|
||||||
|
services: Services,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Services {
|
||||||
|
users: Dep<users::Service>,
|
||||||
|
globals: Dep<globals::Service>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ValidResetToken {
|
||||||
|
pub token: String,
|
||||||
|
pub info: ResetTokenInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::Service for Service {
|
||||||
|
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
|
||||||
|
Ok(Arc::new(Self {
|
||||||
|
db: Data::new(args.db),
|
||||||
|
services: Services {
|
||||||
|
users: args.depend::<users::Service>("users"),
|
||||||
|
globals: args.depend::<globals::Service>("globals"),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Service {
|
||||||
|
/// Generate a random string suitable to be used as a password reset token.
|
||||||
|
#[must_use]
|
||||||
|
pub fn generate_token_string() -> String { utils::random_string(RESET_TOKEN_LENGTH) }
|
||||||
|
|
||||||
|
/// Issue a password reset token for `user`, who must be a local user with
|
||||||
|
/// the `password` origin.
|
||||||
|
pub async fn issue_token(&self, user_id: OwnedUserId) -> Result<ValidResetToken> {
|
||||||
|
if !self.services.globals.user_is_local(&user_id) {
|
||||||
|
return Err!("Cannot issue a password reset token for remote user {user_id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if user_id == self.services.globals.server_user {
|
||||||
|
return Err!("Cannot issue a password reset token for the server user");
|
||||||
|
}
|
||||||
|
|
||||||
|
if self
|
||||||
|
.services
|
||||||
|
.users
|
||||||
|
.origin(&user_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "password".to_owned())
|
||||||
|
!= "password"
|
||||||
|
{
|
||||||
|
return Err!("Cannot issue a password reset token for non-internal user {user_id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.services.users.is_deactivated(&user_id).await? {
|
||||||
|
return Err!("Cannot issue a password reset token for deactivated user {user_id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((existing_token, _)) = self.db.find_token_for_user(&user_id).await {
|
||||||
|
self.db.remove_token(&existing_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = Self::generate_token_string();
|
||||||
|
let info = ResetTokenInfo {
|
||||||
|
user: user_id,
|
||||||
|
issued_at: SystemTime::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.db.save_token(&token, &info);
|
||||||
|
|
||||||
|
Ok(ValidResetToken { token, info })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if `token` represents a valid, non-expired password reset token.
|
||||||
|
pub async fn check_token(&self, token: &str) -> Option<ValidResetToken> {
|
||||||
|
self.db.lookup_token_info(token).await.and_then(|info| {
|
||||||
|
if info.is_valid() {
|
||||||
|
Some(ValidResetToken { token: token.to_owned(), info })
|
||||||
|
} else {
|
||||||
|
self.db.remove_token(token);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume the supplied valid token, using it to change its user's password
|
||||||
|
/// to `new_password`.
|
||||||
|
pub async fn consume_token(
|
||||||
|
&self,
|
||||||
|
ValidResetToken { token, info }: ValidResetToken,
|
||||||
|
new_password: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
if info.is_valid() {
|
||||||
|
self.db.remove_token(&token);
|
||||||
|
self.services
|
||||||
|
.users
|
||||||
|
.set_password(&info.user, Some(new_password))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,8 +13,6 @@ use ruma::OwnedUserId;
|
|||||||
|
|
||||||
use crate::{Dep, config, firstrun};
|
use crate::{Dep, config, firstrun};
|
||||||
|
|
||||||
const RANDOM_TOKEN_LENGTH: usize = 16;
|
|
||||||
|
|
||||||
pub struct Service {
|
pub struct Service {
|
||||||
db: Data,
|
db: Data,
|
||||||
services: Services,
|
services: Services,
|
||||||
@@ -103,9 +101,11 @@ impl crate::Service for Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Service {
|
impl Service {
|
||||||
|
const RANDOM_TOKEN_LENGTH: usize = 16;
|
||||||
|
|
||||||
/// Generate a random string suitable to be used as a registration token.
|
/// Generate a random string suitable to be used as a registration token.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn generate_token_string() -> String { utils::random_string(RANDOM_TOKEN_LENGTH) }
|
pub fn generate_token_string() -> String { utils::random_string(Self::RANDOM_TOKEN_LENGTH) }
|
||||||
|
|
||||||
/// Issue a new registration token and save it in the database.
|
/// Issue a new registration token and save it in the database.
|
||||||
pub fn issue_token(
|
pub fn issue_token(
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ async fn acquire_notary_result(&self, missing: &mut Batch, server_keys: ServerSi
|
|||||||
self.add_signing_keys(server_keys.clone()).await;
|
self.add_signing_keys(server_keys.clone()).await;
|
||||||
|
|
||||||
if let Some(key_ids) = missing.get_mut(server) {
|
if let Some(key_ids) = missing.get_mut(server) {
|
||||||
key_ids.retain(|key_id| key_exists(&server_keys, key_id));
|
key_ids.retain(|key_id| !key_exists(&server_keys, key_id));
|
||||||
if key_ids.is_empty() {
|
if key_ids.is_empty() {
|
||||||
missing.remove(server);
|
missing.remove(server);
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-8
@@ -1,7 +1,7 @@
|
|||||||
use std::{any::Any, collections::BTreeMap, sync::Arc};
|
use std::{any::Any, collections::BTreeMap, sync::Arc};
|
||||||
|
|
||||||
use conduwuit::{
|
use conduwuit::{
|
||||||
Result, Server, SyncRwLock, debug, debug_info, info, trace, utils::stream::IterStream,
|
Result, Server, SyncRwLock, debug, debug_info, error, info, trace, utils::stream::IterStream,
|
||||||
};
|
};
|
||||||
use database::Database;
|
use database::Database;
|
||||||
use futures::{Stream, StreamExt, TryStreamExt};
|
use futures::{Stream, StreamExt, TryStreamExt};
|
||||||
@@ -9,12 +9,12 @@ use tokio::sync::Mutex;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
account_data, admin, announcements, antispam, appservice, client, config, emergency,
|
account_data, admin, announcements, antispam, appservice, client, config, emergency,
|
||||||
federation, firstrun, globals, key_backups,
|
federation, firstrun, globals, key_backups, mailer,
|
||||||
manager::Manager,
|
manager::Manager,
|
||||||
media, moderation, presence, pusher, registration_tokens, resolver, rooms, sending,
|
media, moderation, password_reset, presence, pusher, registration_tokens, resolver, rooms,
|
||||||
server_keys,
|
sending, server_keys,
|
||||||
service::{self, Args, Map, Service},
|
service::{self, Args, Map, Service},
|
||||||
sync, transactions, uiaa, users,
|
sync, threepid, transactions, uiaa, users,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct Services {
|
pub struct Services {
|
||||||
@@ -27,6 +27,8 @@ pub struct Services {
|
|||||||
pub globals: Arc<globals::Service>,
|
pub globals: Arc<globals::Service>,
|
||||||
pub key_backups: Arc<key_backups::Service>,
|
pub key_backups: Arc<key_backups::Service>,
|
||||||
pub media: Arc<media::Service>,
|
pub media: Arc<media::Service>,
|
||||||
|
pub password_reset: Arc<password_reset::Service>,
|
||||||
|
pub mailer: Arc<mailer::Service>,
|
||||||
pub presence: Arc<presence::Service>,
|
pub presence: Arc<presence::Service>,
|
||||||
pub pusher: Arc<pusher::Service>,
|
pub pusher: Arc<pusher::Service>,
|
||||||
pub registration_tokens: Arc<registration_tokens::Service>,
|
pub registration_tokens: Arc<registration_tokens::Service>,
|
||||||
@@ -38,6 +40,7 @@ pub struct Services {
|
|||||||
pub server_keys: Arc<server_keys::Service>,
|
pub server_keys: Arc<server_keys::Service>,
|
||||||
pub sync: Arc<sync::Service>,
|
pub sync: Arc<sync::Service>,
|
||||||
pub transactions: Arc<transactions::Service>,
|
pub transactions: Arc<transactions::Service>,
|
||||||
|
pub threepid: Arc<threepid::Service>,
|
||||||
pub uiaa: Arc<uiaa::Service>,
|
pub uiaa: Arc<uiaa::Service>,
|
||||||
pub users: Arc<users::Service>,
|
pub users: Arc<users::Service>,
|
||||||
pub moderation: Arc<moderation::Service>,
|
pub moderation: Arc<moderation::Service>,
|
||||||
@@ -81,6 +84,8 @@ impl Services {
|
|||||||
globals: build!(globals::Service),
|
globals: build!(globals::Service),
|
||||||
key_backups: build!(key_backups::Service),
|
key_backups: build!(key_backups::Service),
|
||||||
media: build!(media::Service),
|
media: build!(media::Service),
|
||||||
|
password_reset: build!(password_reset::Service),
|
||||||
|
mailer: build!(mailer::Service),
|
||||||
presence: build!(presence::Service),
|
presence: build!(presence::Service),
|
||||||
pusher: build!(pusher::Service),
|
pusher: build!(pusher::Service),
|
||||||
registration_tokens: build!(registration_tokens::Service),
|
registration_tokens: build!(registration_tokens::Service),
|
||||||
@@ -110,6 +115,7 @@ impl Services {
|
|||||||
sending: build!(sending::Service),
|
sending: build!(sending::Service),
|
||||||
server_keys: build!(server_keys::Service),
|
server_keys: build!(server_keys::Service),
|
||||||
sync: build!(sync::Service),
|
sync: build!(sync::Service),
|
||||||
|
threepid: build!(threepid::Service),
|
||||||
transactions: build!(transactions::Service),
|
transactions: build!(transactions::Service),
|
||||||
uiaa: build!(uiaa::Service),
|
uiaa: build!(uiaa::Service),
|
||||||
users: build!(users::Service),
|
users: build!(users::Service),
|
||||||
@@ -125,10 +131,12 @@ impl Services {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start(self: &Arc<Self>) -> Result<Arc<Self>> {
|
pub async fn start(self: &Arc<Self>) -> Result<Arc<Self>> {
|
||||||
debug_info!("Starting services...");
|
info!("Starting services...");
|
||||||
|
|
||||||
self.admin.set_services(Some(Arc::clone(self)).as_ref());
|
self.admin.set_services(Some(Arc::clone(self)).as_ref());
|
||||||
super::migrations::migrations(self).await?;
|
super::migrations::migrations(self)
|
||||||
|
.await
|
||||||
|
.inspect_err(|e| error!("Migrations failed: {e}"))?;
|
||||||
self.manager
|
self.manager
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
@@ -147,7 +155,7 @@ impl Services {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug_info!("Services startup complete.");
|
info!("Services startup complete.");
|
||||||
|
|
||||||
Ok(Arc::clone(self))
|
Ok(Arc::clone(self))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{%- block content %}{% endblock %}
|
||||||
|
|
||||||
|
Message sent by Continuwuity {{ env!("CARGO_PKG_VERSION") }}. 🐈
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends "_base.txt" %}
|
||||||
|
|
||||||
|
{% block content -%}
|
||||||
|
Hello!
|
||||||
|
{% if let Some(user_id) = user_id -%}
|
||||||
|
Somebody, probably you, tried to associate this email address with the Matrix account {{ user_id }}.
|
||||||
|
{%- else -%}
|
||||||
|
Somebody, probably you, tried to associate this email address with a Matrix account on {{ server_name }}.
|
||||||
|
{%- endif %}
|
||||||
|
If that was you, and this is your email address, click this link to proceed:
|
||||||
|
{{ verification_link }}
|
||||||
|
Otherwise, you can ignore this email. The above link will expire in one hour.
|
||||||
|
{%- endblock %}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{% extends "_base.txt" %}
|
||||||
|
|
||||||
|
{% block content -%}
|
||||||
|
Hello!
|
||||||
|
|
||||||
|
Somebody, probably you, tried to create a Matrix account on {{ server_name }} using this email address.
|
||||||
|
Use the link below to proceed with creating your account:
|
||||||
|
{{ verification_link }}
|
||||||
|
If you are not trying to create an account, you can ignore this email. The above link will expire in one hour.
|
||||||
|
{%- endblock %}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends "_base.txt" %}
|
||||||
|
|
||||||
|
{% block content -%}
|
||||||
|
{%- if let Some(display_name) = display_name -%}
|
||||||
|
Hello {{ display_name }} ({{ user_id }}),
|
||||||
|
{%- else -%}
|
||||||
|
Hello {{ user_id }},
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
Somebody, probably you, tried to reset your Matrix account's password.
|
||||||
|
If you requested for your password to be reset, click this link to proceed:
|
||||||
|
{{ verification_link }}
|
||||||
|
Otherwise, you can ignore this email. The above link will expire in one hour.
|
||||||
|
{%- endblock %}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{% extends "_base.txt" %}
|
||||||
|
|
||||||
|
{% block content -%}
|
||||||
|
If you're seeing this, SMTP is configured correctly. :3
|
||||||
|
{%- endblock %}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
use std::{borrow::Cow, collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
|
use conduwuit::{Err, Error, Result, result::FlatOk};
|
||||||
|
use database::{Deserialized, Map};
|
||||||
|
use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter};
|
||||||
|
use lettre::{Address, message::Mailbox};
|
||||||
|
use nonzero_ext::nonzero;
|
||||||
|
use ruma::{
|
||||||
|
ClientSecret, OwnedClientSecret, OwnedSessionId, SessionId, api::client::error::ErrorKind,
|
||||||
|
};
|
||||||
|
|
||||||
|
mod session;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
Args, Dep, config,
|
||||||
|
mailer::{self, messages::MessageTemplate},
|
||||||
|
threepid::session::{ValidationSessions, ValidationState, ValidationToken},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct Service {
|
||||||
|
db: Data,
|
||||||
|
services: Services,
|
||||||
|
sessions: tokio::sync::Mutex<ValidationSessions>,
|
||||||
|
send_attempts: std::sync::Mutex<HashMap<(OwnedClientSecret, Address), usize>>,
|
||||||
|
ratelimiter: DefaultKeyedRateLimiter<Address>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Data {
|
||||||
|
localpart_email: Arc<Map>,
|
||||||
|
email_localpart: Arc<Map>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Services {
|
||||||
|
config: Dep<config::Service>,
|
||||||
|
mailer: Dep<mailer::Service>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::Service for Service {
|
||||||
|
fn build(args: Args<'_>) -> Result<Arc<Self>> {
|
||||||
|
Ok(Arc::new(Self {
|
||||||
|
db: Data {
|
||||||
|
email_localpart: args.db["email_localpart"].clone(),
|
||||||
|
localpart_email: args.db["localpart_email"].clone(),
|
||||||
|
},
|
||||||
|
services: Services {
|
||||||
|
config: args.depend("config"),
|
||||||
|
mailer: args.depend("mailer"),
|
||||||
|
},
|
||||||
|
sessions: tokio::sync::Mutex::default(),
|
||||||
|
send_attempts: std::sync::Mutex::default(),
|
||||||
|
ratelimiter: RateLimiter::keyed(Self::EMAIL_RATELIMIT),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Service {
|
||||||
|
// Each address gets two tickets to send an email, which refill at a rate of one
|
||||||
|
// per ten minutes. This allows two emails to be sent at once without waiting
|
||||||
|
// (in case the first one gets eaten), but requires a wait of at least ten
|
||||||
|
// minutes before sending another.
|
||||||
|
const EMAIL_RATELIMIT: Quota =
|
||||||
|
Quota::per_minute(nonzero!(10_u32)).allow_burst(nonzero!(2_u32));
|
||||||
|
const VALIDATION_URL_PATH: &str = "/_continuwuity/3pid/email/validate";
|
||||||
|
|
||||||
|
/// Send a validation message to an email address.
|
||||||
|
///
|
||||||
|
/// Returns the validation session ID on success.
|
||||||
|
#[allow(clippy::impl_trait_in_params)]
|
||||||
|
pub async fn send_validation_email<Template: MessageTemplate>(
|
||||||
|
&self,
|
||||||
|
recipient: Mailbox,
|
||||||
|
prepare_body: impl FnOnce(String) -> Template,
|
||||||
|
client_secret: &ClientSecret,
|
||||||
|
send_attempt: usize,
|
||||||
|
) -> Result<OwnedSessionId> {
|
||||||
|
let mailer = self.services.mailer.expect_mailer()?;
|
||||||
|
let mut sessions = self.sessions.lock().await;
|
||||||
|
|
||||||
|
let session = match sessions.get_session_by_client_secret(client_secret) {
|
||||||
|
// If a validation session already exists for this client secret, we can either
|
||||||
|
// reuse it with a new token or return early because it's already valid.
|
||||||
|
| Some(session) => {
|
||||||
|
match session.validation_state {
|
||||||
|
| ValidationState::Validated => {
|
||||||
|
// If the existing session is already valid, don't send an email.
|
||||||
|
return Ok(session.session_id.clone());
|
||||||
|
},
|
||||||
|
| ValidationState::Pending(ref mut token) => {
|
||||||
|
// Check ratelimiting for the target address.
|
||||||
|
if self.ratelimiter.check_key(&recipient.email).is_err() {
|
||||||
|
return Err(Error::BadRequest(
|
||||||
|
ErrorKind::LimitExceeded { retry_after: None },
|
||||||
|
"You're sending emails too fast, try again in a few minutes.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the send attempt for this session.
|
||||||
|
let mut send_attempts = self.send_attempts.lock().unwrap();
|
||||||
|
|
||||||
|
let last_send_attempt = send_attempts
|
||||||
|
.entry((session.client_secret.clone(), session.email.clone()))
|
||||||
|
.or_default();
|
||||||
|
|
||||||
|
if send_attempt <= *last_send_attempt {
|
||||||
|
// If the supplied send attempt isn't higher than the last
|
||||||
|
// one, don't send an email.
|
||||||
|
return Ok(session.session_id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save this send attempt.
|
||||||
|
*last_send_attempt = send_attempt;
|
||||||
|
drop(send_attempts);
|
||||||
|
|
||||||
|
// Create a new token for the existing session.
|
||||||
|
*token = ValidationToken::new_random();
|
||||||
|
|
||||||
|
session
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// If no session exists, create a new one.
|
||||||
|
| None => sessions.create_session(recipient.email.clone(), client_secret.to_owned()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clone this so it can outlive the lock we're holding on `sessions`
|
||||||
|
let session_id = session.session_id.clone();
|
||||||
|
|
||||||
|
let ValidationState::Pending(token) = &session.validation_state else {
|
||||||
|
unreachable!("session should be pending")
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut validation_url = self
|
||||||
|
.services
|
||||||
|
.config
|
||||||
|
.get_client_domain()
|
||||||
|
.join(Self::VALIDATION_URL_PATH)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
validation_url
|
||||||
|
.query_pairs_mut()
|
||||||
|
.append_pair("session", session_id.as_str())
|
||||||
|
.append_pair("token", &token.token);
|
||||||
|
|
||||||
|
// Once the validation URL is built, we don't need any data borrowed from
|
||||||
|
// `sessions` anymore and can release our lock
|
||||||
|
drop(sessions);
|
||||||
|
|
||||||
|
let message = prepare_body(validation_url.to_string());
|
||||||
|
|
||||||
|
mailer.send(recipient, message).await?;
|
||||||
|
|
||||||
|
Ok(session_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to mark a validation session as valid using a validation token.
|
||||||
|
pub async fn try_validate_session(
|
||||||
|
&self,
|
||||||
|
session_id: &SessionId,
|
||||||
|
supplied_token: &str,
|
||||||
|
) -> Result<(), Cow<'static, str>> {
|
||||||
|
let mut sessions = self.sessions.lock().await;
|
||||||
|
|
||||||
|
let Some(session) = sessions.get_session(session_id) else {
|
||||||
|
return Err("Validation session does not exist".into());
|
||||||
|
};
|
||||||
|
|
||||||
|
session.validation_state = match &session.validation_state {
|
||||||
|
| ValidationState::Validated => {
|
||||||
|
// If the session is already validated, do nothing.
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
},
|
||||||
|
| ValidationState::Pending(token) => {
|
||||||
|
// Otherwise check the token and mark the session as valid.
|
||||||
|
|
||||||
|
if *token != *supplied_token || !token.is_valid() {
|
||||||
|
return Err("Validation token is invalid or expired, please request a new \
|
||||||
|
one"
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidationState::Validated
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume a validated validation session, removing it from the database
|
||||||
|
/// and returning the newly validated email address.
|
||||||
|
pub async fn consume_valid_session(
|
||||||
|
&self,
|
||||||
|
session_id: &SessionId,
|
||||||
|
client_secret: &ClientSecret,
|
||||||
|
) -> Result<Address, Cow<'static, str>> {
|
||||||
|
let mut sessions = self.sessions.lock().await;
|
||||||
|
|
||||||
|
let Some(session) = sessions.get_session(session_id) else {
|
||||||
|
return Err("Validation session does not exist".into());
|
||||||
|
};
|
||||||
|
|
||||||
|
if session.client_secret == client_secret
|
||||||
|
&& matches!(session.validation_state, ValidationState::Validated)
|
||||||
|
{
|
||||||
|
let session = sessions.remove_session(session_id);
|
||||||
|
|
||||||
|
Ok(session.email)
|
||||||
|
} else {
|
||||||
|
Err("This email address has not been validated. Did you use the link that was sent \
|
||||||
|
to you?"
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Associate a localpart with an email address.
|
||||||
|
pub async fn associate_localpart_email(
|
||||||
|
&self,
|
||||||
|
localpart: &str,
|
||||||
|
email: &Address,
|
||||||
|
) -> Result<()> {
|
||||||
|
match self.get_localpart_for_email(email).await {
|
||||||
|
| Some(existing_localpart) if existing_localpart != localpart => {
|
||||||
|
// Another account is already using the supplied email.
|
||||||
|
|
||||||
|
Err!(Request(ThreepidInUse("This email address is already in use.")))
|
||||||
|
},
|
||||||
|
| Some(_) => {
|
||||||
|
// The supplied localpart is already associated with the supplied email,
|
||||||
|
// no changes are necessary.
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
| None => {
|
||||||
|
// The supplied email is not already in use.
|
||||||
|
|
||||||
|
let email: &str = email.as_ref();
|
||||||
|
self.db.localpart_email.insert(localpart, email);
|
||||||
|
self.db.email_localpart.insert(email, localpart);
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Given a localpart, remove its corresponding email address.
|
||||||
|
///
|
||||||
|
/// [`Self::get_localpart_for_email`] may be used if only the email is
|
||||||
|
/// known.
|
||||||
|
pub async fn disassociate_localpart_email(&self, localpart: &str) -> Option<Address> {
|
||||||
|
let email = self.get_email_for_localpart(localpart).await?;
|
||||||
|
|
||||||
|
self.db.localpart_email.remove(localpart);
|
||||||
|
self.db
|
||||||
|
.email_localpart
|
||||||
|
.remove(<Address as AsRef<str>>::as_ref(&email));
|
||||||
|
|
||||||
|
Some(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the email associated with a localpart, if one exists.
|
||||||
|
pub async fn get_email_for_localpart(&self, localpart: &str) -> Option<Address> {
|
||||||
|
self.db
|
||||||
|
.localpart_email
|
||||||
|
.get(localpart)
|
||||||
|
.await
|
||||||
|
.deserialized::<String>()
|
||||||
|
.ok()
|
||||||
|
.map(TryInto::try_into)
|
||||||
|
.flat_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the localpart associated with an email, if one exists.
|
||||||
|
pub async fn get_localpart_for_email(&self, email: &Address) -> Option<String> {
|
||||||
|
self.db
|
||||||
|
.email_localpart
|
||||||
|
.get(<Address as AsRef<str>>::as_ref(email))
|
||||||
|
.await
|
||||||
|
.deserialized()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
time::{Duration, SystemTime},
|
||||||
|
};
|
||||||
|
|
||||||
|
use conduwuit::utils;
|
||||||
|
use lettre::Address;
|
||||||
|
use ruma::{ClientSecret, OwnedClientSecret, OwnedSessionId, SessionId};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct ValidationSessions {
|
||||||
|
sessions: HashMap<OwnedSessionId, ValidationSession>,
|
||||||
|
client_secrets: HashMap<OwnedClientSecret, OwnedSessionId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pending or completed email validation session.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct ValidationSession {
|
||||||
|
/// The session's ID
|
||||||
|
pub session_id: OwnedSessionId,
|
||||||
|
/// The client's supplied client secret
|
||||||
|
pub client_secret: OwnedClientSecret,
|
||||||
|
/// The email address which is being validated
|
||||||
|
pub email: Address,
|
||||||
|
/// The session's validation state
|
||||||
|
pub validation_state: ValidationState,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The state of an email validation session.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) enum ValidationState {
|
||||||
|
/// The session is waiting for this validation token to be provided
|
||||||
|
Pending(ValidationToken),
|
||||||
|
/// The session has been validated
|
||||||
|
Validated,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(crate) struct ValidationToken {
|
||||||
|
pub token: String,
|
||||||
|
pub issued_at: SystemTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ValidationToken {
|
||||||
|
// one hour
|
||||||
|
const MAX_TOKEN_AGE: Duration = Duration::from_secs(60 * 60);
|
||||||
|
const RANDOM_TOKEN_LENGTH: usize = 16;
|
||||||
|
|
||||||
|
pub(super) fn new_random() -> Self {
|
||||||
|
Self {
|
||||||
|
token: utils::random_string(Self::RANDOM_TOKEN_LENGTH),
|
||||||
|
issued_at: SystemTime::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_valid(&self) -> bool {
|
||||||
|
let now = SystemTime::now();
|
||||||
|
|
||||||
|
now.duration_since(self.issued_at)
|
||||||
|
.is_ok_and(|duration| duration < Self::MAX_TOKEN_AGE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq<str> for ValidationToken {
|
||||||
|
fn eq(&self, other: &str) -> bool { self.token == other }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ValidationSessions {
|
||||||
|
const RANDOM_SID_LENGTH: usize = 16;
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub(super) fn generate_session_id() -> OwnedSessionId {
|
||||||
|
OwnedSessionId::parse(utils::random_string(Self::RANDOM_SID_LENGTH)).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn create_session(
|
||||||
|
&mut self,
|
||||||
|
email: Address,
|
||||||
|
client_secret: OwnedClientSecret,
|
||||||
|
) -> &mut ValidationSession {
|
||||||
|
let session = ValidationSession {
|
||||||
|
session_id: Self::generate_session_id(),
|
||||||
|
client_secret,
|
||||||
|
email,
|
||||||
|
validation_state: ValidationState::Pending(ValidationToken::new_random()),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.client_secrets
|
||||||
|
.insert(session.client_secret.clone(), session.session_id.clone());
|
||||||
|
self.sessions
|
||||||
|
.entry(session.session_id.clone())
|
||||||
|
.insert_entry(session)
|
||||||
|
.into_mut()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn get_session(
|
||||||
|
&mut self,
|
||||||
|
session_id: &SessionId,
|
||||||
|
) -> Option<&mut ValidationSession> {
|
||||||
|
self.sessions.get_mut(session_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn get_session_by_client_secret(
|
||||||
|
&mut self,
|
||||||
|
client_secret: &ClientSecret,
|
||||||
|
) -> Option<&mut ValidationSession> {
|
||||||
|
let session_id = self.client_secrets.get(client_secret)?;
|
||||||
|
let session = self
|
||||||
|
.sessions
|
||||||
|
.get_mut(session_id)
|
||||||
|
.expect("session should exist with session id");
|
||||||
|
|
||||||
|
Some(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn remove_session(&mut self, session_id: &SessionId) -> ValidationSession {
|
||||||
|
let session = self
|
||||||
|
.sessions
|
||||||
|
.remove(session_id)
|
||||||
|
.expect("session ID should exist");
|
||||||
|
|
||||||
|
self.client_secrets
|
||||||
|
.remove(&session.client_secret)
|
||||||
|
.expect("session should have an associated client secret");
|
||||||
|
|
||||||
|
session
|
||||||
|
}
|
||||||
|
}
|
||||||
+398
-282
@@ -1,24 +1,29 @@
|
|||||||
use std::{collections::BTreeMap, sync::Arc};
|
use std::{
|
||||||
|
borrow::Cow,
|
||||||
use conduwuit::{
|
collections::{HashMap, HashSet, hash_map::Entry},
|
||||||
Err, Error, Result, SyncRwLock, err, error, implement, utils,
|
sync::Arc,
|
||||||
utils::{hash, string::EMPTY},
|
|
||||||
};
|
};
|
||||||
use database::{Deserialized, Json, Map};
|
|
||||||
|
use conduwuit::{Err, Error, Result, error, utils, utils::hash};
|
||||||
|
use lettre::Address;
|
||||||
use ruma::{
|
use ruma::{
|
||||||
CanonicalJsonValue, DeviceId, OwnedDeviceId, OwnedUserId, UserId,
|
UserId,
|
||||||
api::client::{
|
api::client::{
|
||||||
error::{ErrorKind, StandardErrorBody},
|
error::{ErrorKind, StandardErrorBody},
|
||||||
uiaa::{AuthData, AuthType, Password, UiaaInfo, UserIdentifier},
|
uiaa::{
|
||||||
|
AuthData, AuthFlow, AuthType, EmailIdentity, Password, ReCaptcha, RegistrationToken,
|
||||||
|
ThirdpartyIdCredentials, UiaaInfo, UserIdentifier,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
use serde_json::value::RawValue;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use crate::{Dep, config, globals, registration_tokens, users};
|
use crate::{Dep, config, globals, registration_tokens, threepid, users};
|
||||||
|
|
||||||
pub struct Service {
|
pub struct Service {
|
||||||
userdevicesessionid_uiaarequest: SyncRwLock<RequestMap>,
|
|
||||||
db: Data,
|
|
||||||
services: Services,
|
services: Services,
|
||||||
|
uiaa_sessions: Mutex<HashMap<String, UiaaSession>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Services {
|
struct Services {
|
||||||
@@ -26,214 +31,191 @@ struct Services {
|
|||||||
users: Dep<users::Service>,
|
users: Dep<users::Service>,
|
||||||
config: Dep<config::Service>,
|
config: Dep<config::Service>,
|
||||||
registration_tokens: Dep<registration_tokens::Service>,
|
registration_tokens: Dep<registration_tokens::Service>,
|
||||||
|
threepid: Dep<threepid::Service>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Data {
|
|
||||||
userdevicesessionid_uiaainfo: Arc<Map>,
|
|
||||||
}
|
|
||||||
|
|
||||||
type RequestMap = BTreeMap<RequestKey, CanonicalJsonValue>;
|
|
||||||
type RequestKey = (OwnedUserId, OwnedDeviceId, String);
|
|
||||||
|
|
||||||
pub const SESSION_ID_LENGTH: usize = 32;
|
|
||||||
|
|
||||||
impl crate::Service for Service {
|
impl crate::Service for Service {
|
||||||
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
|
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
|
||||||
Ok(Arc::new(Self {
|
Ok(Arc::new(Self {
|
||||||
userdevicesessionid_uiaarequest: SyncRwLock::new(RequestMap::new()),
|
|
||||||
db: Data {
|
|
||||||
userdevicesessionid_uiaainfo: args.db["userdevicesessionid_uiaainfo"].clone(),
|
|
||||||
},
|
|
||||||
services: Services {
|
services: Services {
|
||||||
globals: args.depend::<globals::Service>("globals"),
|
globals: args.depend::<globals::Service>("globals"),
|
||||||
users: args.depend::<users::Service>("users"),
|
users: args.depend::<users::Service>("users"),
|
||||||
config: args.depend::<config::Service>("config"),
|
config: args.depend::<config::Service>("config"),
|
||||||
registration_tokens: args
|
registration_tokens: args
|
||||||
.depend::<registration_tokens::Service>("registration_tokens"),
|
.depend::<registration_tokens::Service>("registration_tokens"),
|
||||||
|
threepid: args.depend::<threepid::Service>("threepid"),
|
||||||
},
|
},
|
||||||
|
uiaa_sessions: Mutex::new(HashMap::new()),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
|
fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a new Uiaa session. Make sure the session token is unique.
|
struct UiaaSession {
|
||||||
#[implement(Service)]
|
info: UiaaInfo,
|
||||||
pub fn create(
|
identity: Identity,
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
device_id: &DeviceId,
|
|
||||||
uiaainfo: &UiaaInfo,
|
|
||||||
json_body: &CanonicalJsonValue,
|
|
||||||
) {
|
|
||||||
// TODO: better session error handling (why is uiaainfo.session optional in
|
|
||||||
// ruma?)
|
|
||||||
self.set_uiaa_request(
|
|
||||||
user_id,
|
|
||||||
device_id,
|
|
||||||
uiaainfo.session.as_ref().expect("session should be set"),
|
|
||||||
json_body,
|
|
||||||
);
|
|
||||||
|
|
||||||
self.update_uiaa_session(
|
|
||||||
user_id,
|
|
||||||
device_id,
|
|
||||||
uiaainfo.session.as_ref().expect("session should be set"),
|
|
||||||
Some(uiaainfo),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[implement(Service)]
|
/// Information about the authenticated user's identity.
|
||||||
#[allow(clippy::useless_let_if_seq)]
|
///
|
||||||
pub async fn try_auth(
|
/// A field of this struct will only be Some if the user completed
|
||||||
&self,
|
/// a stage which provided that information. If multiple stages provide
|
||||||
user_id: &UserId,
|
/// the same field, authentication will fail if they do not all provide
|
||||||
device_id: &DeviceId,
|
/// _identical_ values for that field.
|
||||||
auth: &AuthData,
|
#[derive(Default, Clone)]
|
||||||
uiaainfo: &UiaaInfo,
|
pub struct Identity {
|
||||||
) -> Result<(bool, UiaaInfo)> {
|
/// The authenticated user's user ID, if it could be determined.
|
||||||
let mut uiaainfo = if let Some(session) = auth.session() {
|
///
|
||||||
self.get_uiaa_session(user_id, device_id, session).await?
|
/// This will be Some if:
|
||||||
} else {
|
/// - The user completed a m.login.password stage
|
||||||
uiaainfo.clone()
|
/// - The user completed a m.login.email.identity stage, and their email has
|
||||||
};
|
/// an associated user ID
|
||||||
|
pub localpart: Option<String>,
|
||||||
|
|
||||||
if uiaainfo.session.is_none() {
|
/// The authenticated user's email address, if it could be determined.
|
||||||
uiaainfo.session = Some(utils::random_string(SESSION_ID_LENGTH));
|
///
|
||||||
|
/// This will be Some if:
|
||||||
|
/// - The user completed a m.login.email.identity stage
|
||||||
|
/// - The user completed a m.login.password stage, and their user ID has an
|
||||||
|
/// associated email
|
||||||
|
pub email: Option<Address>,
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! identity_update_fn {
|
||||||
|
(fn $method:ident($field:ident : $type:ty)else $error:literal) => {
|
||||||
|
fn $method(&mut self, $field: $type) -> Result<(), StandardErrorBody> {
|
||||||
|
if self.$field.is_none() {
|
||||||
|
self.$field = Some($field);
|
||||||
|
Ok(())
|
||||||
|
} else if self.$field == Some($field) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(StandardErrorBody {
|
||||||
|
kind: ErrorKind::InvalidParam,
|
||||||
|
message: $error.to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Identity {
|
||||||
|
identity_update_fn!(fn try_set_localpart(localpart: String) else "User ID mismatch");
|
||||||
|
|
||||||
|
identity_update_fn!(fn try_set_email(email: Address) else "Email mismatch");
|
||||||
|
|
||||||
|
/// Create an Identity with the localpart of the provided user ID
|
||||||
|
/// and all other fields set to None.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_user_id(user_id: &UserId) -> Self {
|
||||||
|
Self {
|
||||||
|
localpart: Some(user_id.localpart().to_owned()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Service {
|
||||||
|
const SESSION_ID_LENGTH: usize = 32;
|
||||||
|
|
||||||
|
/// Perform the full UIAA authentication sequence for a route given its
|
||||||
|
/// authentication data.
|
||||||
|
pub async fn authenticate(
|
||||||
|
&self,
|
||||||
|
auth: &Option<AuthData>,
|
||||||
|
flows: Vec<AuthFlow>,
|
||||||
|
params: Box<RawValue>,
|
||||||
|
identity: Option<Identity>,
|
||||||
|
) -> Result<Identity> {
|
||||||
|
match auth.as_ref() {
|
||||||
|
| None => {
|
||||||
|
let info = self.create_session(flows, params, identity).await;
|
||||||
|
|
||||||
|
Err(Error::Uiaa(info))
|
||||||
|
},
|
||||||
|
| Some(auth) => {
|
||||||
|
let session: Cow<'_, str> = match auth.session() {
|
||||||
|
| Some(session) => session.into(),
|
||||||
|
| None => {
|
||||||
|
// Clients are allowed to send UIAA requests with an auth dict and no
|
||||||
|
// session if they want to start the UIAA exchange with existing
|
||||||
|
// authentication data. If that happens, we create a new session
|
||||||
|
// here.
|
||||||
|
self.create_session(flows, params, identity)
|
||||||
|
.await
|
||||||
|
.session
|
||||||
|
.unwrap()
|
||||||
|
.into()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.continue_session(auth, &session).await? {
|
||||||
|
| Ok(identity) => Ok(identity),
|
||||||
|
| Err(info) => Err(Error::Uiaa(info)),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match auth {
|
/// A helper to perform UIAA authentication with just a password stage.
|
||||||
// Find out what the user completed
|
#[inline]
|
||||||
| AuthData::Password(Password {
|
pub async fn authenticate_password(
|
||||||
identifier,
|
&self,
|
||||||
password,
|
auth: &Option<AuthData>,
|
||||||
#[cfg(feature = "element_hacks")]
|
identity: Option<Identity>,
|
||||||
user,
|
) -> Result<Identity> {
|
||||||
..
|
self.authenticate(
|
||||||
}) => {
|
auth,
|
||||||
#[cfg(feature = "element_hacks")]
|
vec![AuthFlow::new(vec![AuthType::Password])],
|
||||||
let username = if let Some(UserIdentifier::UserIdOrLocalpart(username)) = identifier {
|
Box::default(),
|
||||||
username
|
identity,
|
||||||
} else if let Some(username) = user {
|
)
|
||||||
username
|
.await
|
||||||
} else {
|
}
|
||||||
return Err(Error::BadRequest(
|
|
||||||
ErrorKind::Unrecognized,
|
|
||||||
"Identifier type not recognized.",
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(not(feature = "element_hacks"))]
|
/// Create a new UIAA session with a random session ID.
|
||||||
let Some(UserIdentifier::UserIdOrLocalpart(username)) = identifier else {
|
///
|
||||||
return Err(Error::BadRequest(
|
/// If information about the user's identity is already known, it may be
|
||||||
ErrorKind::Unrecognized,
|
/// supplied with the `identity` parameter. Authentication will fail if
|
||||||
"Identifier type not recognized.",
|
/// flows provide different values for known identity information.
|
||||||
));
|
///
|
||||||
};
|
/// Returns the info of the newly created session.
|
||||||
|
async fn create_session(
|
||||||
|
&self,
|
||||||
|
flows: Vec<AuthFlow>,
|
||||||
|
params: Box<RawValue>,
|
||||||
|
identity: Option<Identity>,
|
||||||
|
) -> UiaaInfo {
|
||||||
|
let mut uiaa_sessions = self.uiaa_sessions.lock().await;
|
||||||
|
|
||||||
let user_id_from_username = UserId::parse_with_server_name(
|
let session_id = utils::random_string(Self::SESSION_ID_LENGTH);
|
||||||
username.clone(),
|
let mut info = UiaaInfo::new(flows, params);
|
||||||
self.services.globals.server_name(),
|
info.session = Some(session_id.clone());
|
||||||
)
|
|
||||||
.map_err(|_| Error::BadRequest(ErrorKind::InvalidParam, "User ID is invalid."))?;
|
|
||||||
|
|
||||||
// Check if the access token being used matches the credentials used for UIAA
|
uiaa_sessions.insert(session_id, UiaaSession {
|
||||||
if user_id.localpart() != user_id_from_username.localpart() {
|
info: info.clone(),
|
||||||
return Err!(Request(Forbidden("User ID and access token mismatch.")));
|
identity: identity.unwrap_or_default(),
|
||||||
}
|
});
|
||||||
let user_id = user_id_from_username;
|
|
||||||
|
|
||||||
// Check if password is correct
|
info
|
||||||
let mut password_verified = false;
|
}
|
||||||
|
|
||||||
// First try local password hash verification
|
/// Proceed with UIAA authentication given a client's authorization data.
|
||||||
if let Ok(hash) = self.services.users.password_hash(&user_id).await {
|
async fn continue_session(
|
||||||
password_verified = hash::verify_password(password, &hash).is_ok();
|
&self,
|
||||||
}
|
auth: &AuthData,
|
||||||
|
session: &str,
|
||||||
|
) -> Result<Result<Identity, UiaaInfo>> {
|
||||||
|
// Hold this lock for the entire function to make sure that, if try_auth()
|
||||||
|
// is called concurrently with the same session, only one call will succeed
|
||||||
|
let mut uiaa_sessions = self.uiaa_sessions.lock().await;
|
||||||
|
|
||||||
// If local password verification failed, try LDAP authentication
|
let Entry::Occupied(mut session) = uiaa_sessions.entry(session.to_owned()) else {
|
||||||
#[cfg(feature = "ldap")]
|
return Err!(Request(InvalidParam("Invalid session")));
|
||||||
if !password_verified && self.services.config.ldap.enable {
|
};
|
||||||
// Search for user in LDAP to get their DN
|
|
||||||
if let Ok(dns) = self.services.users.search_ldap(&user_id).await {
|
|
||||||
if let Some((user_dn, _is_admin)) = dns.first() {
|
|
||||||
// Try to authenticate with LDAP
|
|
||||||
password_verified = self
|
|
||||||
.services
|
|
||||||
.users
|
|
||||||
.auth_ldap(user_dn, password)
|
|
||||||
.await
|
|
||||||
.is_ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !password_verified {
|
if let &AuthData::FallbackAcknowledgement(_) = auth {
|
||||||
uiaainfo.auth_error = Some(StandardErrorBody {
|
|
||||||
kind: ErrorKind::forbidden(),
|
|
||||||
message: "Invalid username or password.".to_owned(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return Ok((false, uiaainfo));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Password was correct! Let's add it to `completed`
|
|
||||||
uiaainfo.completed.push(AuthType::Password);
|
|
||||||
},
|
|
||||||
| AuthData::ReCaptcha(r) => {
|
|
||||||
if self.services.config.recaptcha_private_site_key.is_none() {
|
|
||||||
return Err!(Request(Forbidden("ReCaptcha is not configured.")));
|
|
||||||
}
|
|
||||||
match recaptcha_verify::verify(
|
|
||||||
self.services
|
|
||||||
.config
|
|
||||||
.recaptcha_private_site_key
|
|
||||||
.as_ref()
|
|
||||||
.unwrap(),
|
|
||||||
r.response.as_str(),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
| Ok(()) => {
|
|
||||||
uiaainfo.completed.push(AuthType::ReCaptcha);
|
|
||||||
},
|
|
||||||
| Err(e) => {
|
|
||||||
error!("ReCaptcha verification failed: {e:?}");
|
|
||||||
uiaainfo.auth_error = Some(StandardErrorBody {
|
|
||||||
kind: ErrorKind::forbidden(),
|
|
||||||
message: "ReCaptcha verification failed.".to_owned(),
|
|
||||||
});
|
|
||||||
return Ok((false, uiaainfo));
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
| AuthData::RegistrationToken(t) => {
|
|
||||||
let token = t.token.trim().to_owned();
|
|
||||||
|
|
||||||
if let Some(valid_token) = self
|
|
||||||
.services
|
|
||||||
.registration_tokens
|
|
||||||
.validate_token(token)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
self.services
|
|
||||||
.registration_tokens
|
|
||||||
.mark_token_as_used(valid_token);
|
|
||||||
|
|
||||||
uiaainfo.completed.push(AuthType::RegistrationToken);
|
|
||||||
} else {
|
|
||||||
uiaainfo.auth_error = Some(StandardErrorBody {
|
|
||||||
kind: ErrorKind::forbidden(),
|
|
||||||
message: "Invalid registration token.".to_owned(),
|
|
||||||
});
|
|
||||||
return Ok((false, uiaainfo));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
| AuthData::Dummy(_) => {
|
|
||||||
uiaainfo.completed.push(AuthType::Dummy);
|
|
||||||
},
|
|
||||||
| AuthData::FallbackAcknowledgement(_) => {
|
|
||||||
// The client is checking if authentication has succeeded out-of-band. This is
|
// The client is checking if authentication has succeeded out-of-band. This is
|
||||||
// possible if the client is using "fallback auth" (see spec section
|
// 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
|
// 4.9.1.4), which we don't support (and probably never will, because it's a
|
||||||
@@ -241,109 +223,243 @@ pub async fn try_auth(
|
|||||||
|
|
||||||
// Return early to tell the client that no, authentication did not succeed while
|
// Return early to tell the client that no, authentication did not succeed while
|
||||||
// it wasn't looking.
|
// it wasn't looking.
|
||||||
return Ok((false, uiaainfo));
|
return Ok(Err(session.get().info.clone()));
|
||||||
},
|
}
|
||||||
| k => error!("type not supported: {:?}", k),
|
|
||||||
}
|
let completed = {
|
||||||
|
let UiaaSession { info, identity } = session.get_mut();
|
||||||
// Check if a flow now succeeds
|
|
||||||
let mut completed = false;
|
let auth_type = auth.auth_type().expect("auth type should be set");
|
||||||
'flows: for flow in &mut uiaainfo.flows {
|
|
||||||
for stage in &flow.stages {
|
let flow_stages: Vec<HashSet<_>> = info
|
||||||
if !uiaainfo.completed.contains(stage) {
|
.flows
|
||||||
continue 'flows;
|
.iter()
|
||||||
}
|
.map(|flow| {
|
||||||
|
flow.stages
|
||||||
|
.iter()
|
||||||
|
.map(AuthType::as_str)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut completed_stages: HashSet<_> = info
|
||||||
|
.completed
|
||||||
|
.iter()
|
||||||
|
.map(AuthType::as_str)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Don't allow stages which aren't in any flows
|
||||||
|
if !flow_stages
|
||||||
|
.iter()
|
||||||
|
.any(|stages| stages.contains(auth_type.as_str()))
|
||||||
|
{
|
||||||
|
return Err!(Request(InvalidParam("No flows include the supplied stage")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the provided stage hasn't already been completed, check it for completion
|
||||||
|
if !completed_stages.contains(auth_type.as_str()) {
|
||||||
|
match self.check_stage(auth, identity.clone()).await {
|
||||||
|
| Ok((completed_stage, updated_identity)) => {
|
||||||
|
info.auth_error = None;
|
||||||
|
completed_stages.insert(completed_stage.to_string());
|
||||||
|
info.completed.push(completed_stage);
|
||||||
|
*identity = updated_identity;
|
||||||
|
},
|
||||||
|
| Err(error) => {
|
||||||
|
info.auth_error = Some(error);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UIAA is completed if all stages in any flow are completed
|
||||||
|
flow_stages
|
||||||
|
.iter()
|
||||||
|
.any(|stages| completed_stages.is_superset(stages))
|
||||||
|
};
|
||||||
|
|
||||||
|
if completed {
|
||||||
|
// This session is complete, remove it and return success
|
||||||
|
let (_, UiaaSession { identity, .. }) = session.remove_entry();
|
||||||
|
|
||||||
|
Ok(Ok(identity))
|
||||||
|
} else {
|
||||||
|
// The client needs to try again, return the updated session
|
||||||
|
Ok(Err(session.get().info.clone()))
|
||||||
}
|
}
|
||||||
// We didn't break, so this flow succeeded!
|
|
||||||
completed = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !completed {
|
/// Check if the provided authentication data is valid.
|
||||||
self.update_uiaa_session(
|
///
|
||||||
user_id,
|
/// Returns the completed stage's type on success and error information on
|
||||||
device_id,
|
/// failure.
|
||||||
uiaainfo.session.as_ref().expect("session is always set"),
|
async fn check_stage(
|
||||||
Some(&uiaainfo),
|
&self,
|
||||||
);
|
auth: &AuthData,
|
||||||
|
mut identity: Identity,
|
||||||
|
) -> Result<(AuthType, Identity), StandardErrorBody> {
|
||||||
|
// Note: This function takes ownership of `identity` because mutations to the
|
||||||
|
// identity must not be applied unless checking the stage succeeds. The
|
||||||
|
// updated identity is returned as part of the Ok value, and
|
||||||
|
// `continue_session` handles saving it to `uiaa_sessions`.
|
||||||
|
//
|
||||||
|
// This also means it's fine to mutate `identity` at any point in this function,
|
||||||
|
// because those mutations won't be saved unless the function returns Ok.
|
||||||
|
|
||||||
return Ok((false, uiaainfo));
|
match auth {
|
||||||
}
|
| AuthData::Dummy(_) => Ok(AuthType::Dummy),
|
||||||
|
| AuthData::EmailIdentity(EmailIdentity {
|
||||||
|
thirdparty_id_creds: ThirdpartyIdCredentials { client_secret, sid, .. },
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
match self
|
||||||
|
.services
|
||||||
|
.threepid
|
||||||
|
.consume_valid_session(sid, client_secret)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
| Ok(email) => {
|
||||||
|
if let Some(localpart) =
|
||||||
|
self.services.threepid.get_localpart_for_email(&email).await
|
||||||
|
{
|
||||||
|
identity.try_set_localpart(localpart)?;
|
||||||
|
}
|
||||||
|
|
||||||
// UIAA was successful! Remove this session and return true
|
identity.try_set_email(email)?;
|
||||||
self.update_uiaa_session(
|
|
||||||
user_id,
|
|
||||||
device_id,
|
|
||||||
uiaainfo.session.as_ref().expect("session is always set"),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok((true, uiaainfo))
|
Ok(AuthType::EmailIdentity)
|
||||||
}
|
},
|
||||||
|
| Err(message) => Err(StandardErrorBody {
|
||||||
|
kind: ErrorKind::ThreepidAuthFailed,
|
||||||
|
message: message.into_owned(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
#[allow(clippy::useless_let_if_seq)]
|
||||||
|
| AuthData::Password(Password { identifier, password, .. }) => {
|
||||||
|
let user_id_or_localpart = match identifier {
|
||||||
|
| Some(UserIdentifier::UserIdOrLocalpart(username)) => username.to_owned(),
|
||||||
|
| Some(UserIdentifier::Email { address }) => {
|
||||||
|
let Ok(email) = Address::try_from(address.to_owned()) else {
|
||||||
|
return Err(StandardErrorBody {
|
||||||
|
kind: ErrorKind::InvalidParam,
|
||||||
|
message: "Email is malformed".to_owned(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
#[implement(Service)]
|
if let Some(localpart) =
|
||||||
fn set_uiaa_request(
|
self.services.threepid.get_localpart_for_email(&email).await
|
||||||
&self,
|
{
|
||||||
user_id: &UserId,
|
identity.try_set_email(email)?;
|
||||||
device_id: &DeviceId,
|
|
||||||
session: &str,
|
|
||||||
request: &CanonicalJsonValue,
|
|
||||||
) {
|
|
||||||
let key = (user_id.to_owned(), device_id.to_owned(), session.to_owned());
|
|
||||||
self.userdevicesessionid_uiaarequest
|
|
||||||
.write()
|
|
||||||
.insert(key, request.to_owned());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[implement(Service)]
|
localpart
|
||||||
pub fn get_uiaa_request(
|
} else {
|
||||||
&self,
|
return Err(StandardErrorBody {
|
||||||
user_id: &UserId,
|
kind: ErrorKind::forbidden(),
|
||||||
device_id: Option<&DeviceId>,
|
message: "Invalid identifier or password".to_owned(),
|
||||||
session: &str,
|
});
|
||||||
) -> Option<CanonicalJsonValue> {
|
}
|
||||||
let key = (
|
},
|
||||||
user_id.to_owned(),
|
| _ =>
|
||||||
device_id.unwrap_or_else(|| EMPTY.into()).to_owned(),
|
return Err(StandardErrorBody {
|
||||||
session.to_owned(),
|
kind: ErrorKind::Unrecognized,
|
||||||
);
|
message: "Identifier type not recognized".to_owned(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
self.userdevicesessionid_uiaarequest
|
let Ok(user_id) = UserId::parse_with_server_name(
|
||||||
.read()
|
user_id_or_localpart,
|
||||||
.get(&key)
|
self.services.globals.server_name(),
|
||||||
.cloned()
|
) else {
|
||||||
}
|
return Err(StandardErrorBody {
|
||||||
|
kind: ErrorKind::InvalidParam,
|
||||||
|
message: "User ID is malformed".to_owned(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
#[implement(Service)]
|
// Check if password is correct
|
||||||
fn update_uiaa_session(
|
let mut password_verified = false;
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
device_id: &DeviceId,
|
|
||||||
session: &str,
|
|
||||||
uiaainfo: Option<&UiaaInfo>,
|
|
||||||
) {
|
|
||||||
let key = (user_id, device_id, session);
|
|
||||||
|
|
||||||
if let Some(uiaainfo) = uiaainfo {
|
// First try local password hash verification
|
||||||
self.db
|
if let Ok(hash) = self.services.users.password_hash(&user_id).await {
|
||||||
.userdevicesessionid_uiaainfo
|
password_verified = hash::verify_password(password, &hash).is_ok();
|
||||||
.put(key, Json(uiaainfo));
|
}
|
||||||
} else {
|
|
||||||
self.db.userdevicesessionid_uiaainfo.del(key);
|
// If local password verification failed, try LDAP authentication
|
||||||
|
#[cfg(feature = "ldap")]
|
||||||
|
if !password_verified && self.services.config.ldap.enable {
|
||||||
|
// Search for user in LDAP to get their DN
|
||||||
|
if let Ok(dns) = self.services.users.search_ldap(&user_id).await {
|
||||||
|
if let Some((user_dn, _is_admin)) = dns.first() {
|
||||||
|
// Try to authenticate with LDAP
|
||||||
|
password_verified = self
|
||||||
|
.services
|
||||||
|
.users
|
||||||
|
.auth_ldap(user_dn, password)
|
||||||
|
.await
|
||||||
|
.is_ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if password_verified {
|
||||||
|
identity.try_set_localpart(user_id.localpart().to_owned())?;
|
||||||
|
|
||||||
|
Ok(AuthType::Password)
|
||||||
|
} else {
|
||||||
|
Err(StandardErrorBody {
|
||||||
|
kind: ErrorKind::forbidden(),
|
||||||
|
message: "Invalid identifier or password".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
| AuthData::ReCaptcha(ReCaptcha { response, .. }) => {
|
||||||
|
let Some(ref private_site_key) = self.services.config.recaptcha_private_site_key
|
||||||
|
else {
|
||||||
|
return Err(StandardErrorBody {
|
||||||
|
kind: ErrorKind::forbidden(),
|
||||||
|
message: "ReCaptcha is not configured".to_owned(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
match recaptcha_verify::verify_v3(private_site_key, response, None).await {
|
||||||
|
| Ok(()) => Ok(AuthType::ReCaptcha),
|
||||||
|
| Err(e) => {
|
||||||
|
error!("ReCaptcha verification failed: {e:?}");
|
||||||
|
Err(StandardErrorBody {
|
||||||
|
kind: ErrorKind::forbidden(),
|
||||||
|
message: "ReCaptcha verification failed".to_owned(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
| AuthData::RegistrationToken(RegistrationToken { token, .. }) => {
|
||||||
|
let token = token.trim().to_owned();
|
||||||
|
|
||||||
|
if let Some(valid_token) = self
|
||||||
|
.services
|
||||||
|
.registration_tokens
|
||||||
|
.validate_token(token)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
self.services
|
||||||
|
.registration_tokens
|
||||||
|
.mark_token_as_used(valid_token);
|
||||||
|
|
||||||
|
Ok(AuthType::RegistrationToken)
|
||||||
|
} else {
|
||||||
|
Err(StandardErrorBody {
|
||||||
|
kind: ErrorKind::forbidden(),
|
||||||
|
message: "Invalid registration token".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
| _ => Err(StandardErrorBody {
|
||||||
|
kind: ErrorKind::Unrecognized,
|
||||||
|
message: "Unsupported stage type".into(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
.map(|auth_type| (auth_type, identity))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[implement(Service)]
|
|
||||||
async fn get_uiaa_session(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
device_id: &DeviceId,
|
|
||||||
session: &str,
|
|
||||||
) -> Result<UiaaInfo> {
|
|
||||||
let key = (user_id, device_id, session);
|
|
||||||
self.db
|
|
||||||
.userdevicesessionid_uiaainfo
|
|
||||||
.qry(&key)
|
|
||||||
.await
|
|
||||||
.deserialized()
|
|
||||||
.map_err(|_| err!(Request(Forbidden("UIAA session does not exist."))))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,12 +20,25 @@ crate-type = [
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
conduwuit-build-metadata.workspace = true
|
conduwuit-build-metadata.workspace = true
|
||||||
conduwuit-service.workspace = true
|
conduwuit-service.workspace = true
|
||||||
|
conduwuit-core.workspace = true
|
||||||
|
async-trait.workspace = true
|
||||||
askama.workspace = true
|
askama.workspace = true
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
|
axum-extra.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
|
ruma.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
tower-http.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
memory-serve = "2.1.0"
|
||||||
|
validator = { version = "0.20.0", features = ["derive"] }
|
||||||
|
tower-sec-fetch = { version = "0.1.2", features = ["tracing"] }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
memory-serve = "2.1.0"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[general]
|
||||||
|
dirs = ["pages/templates"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
fn main() { memory_serve::load_directory("./pages/resources"); }
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
:root {
|
|
||||||
color-scheme: light;
|
|
||||||
--font-stack: sans-serif;
|
|
||||||
|
|
||||||
--background-color: #fff;
|
|
||||||
--text-color: #000;
|
|
||||||
|
|
||||||
--bg: oklch(0.76 0.0854 317.27);
|
|
||||||
--panel-bg: oklch(0.91 0.042 317.27);
|
|
||||||
|
|
||||||
--name-lightness: 0.45;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
color-scheme: dark;
|
|
||||||
--text-color: #fff;
|
|
||||||
--bg: oklch(0.15 0.042 317.27);
|
|
||||||
--panel-bg: oklch(0.24 0.03 317.27);
|
|
||||||
|
|
||||||
--name-lightness: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
--c1: oklch(0.44 0.177 353.06);
|
|
||||||
--c2: oklch(0.59 0.158 150.88);
|
|
||||||
|
|
||||||
--normal-font-size: 1rem;
|
|
||||||
--small-font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
color: var(--text-color);
|
|
||||||
font-family: var(--font-stack);
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
background-color: var(--bg);
|
|
||||||
background-image: linear-gradient(
|
|
||||||
70deg,
|
|
||||||
oklch(from var(--bg) l + 0.2 c h),
|
|
||||||
oklch(from var(--bg) l - 0.2 c h)
|
|
||||||
);
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel {
|
|
||||||
width: min(clamp(24rem, 12rem + 40vw, 48rem), calc(100vw - 3rem));
|
|
||||||
border-radius: 15px;
|
|
||||||
background-color: var(--panel-bg);
|
|
||||||
padding-inline: 1.5rem;
|
|
||||||
padding-block: 1rem;
|
|
||||||
box-shadow: 0 0.25em 0.375em hsla(0, 0%, 0%, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 24rem) {
|
|
||||||
.panel {
|
|
||||||
padding-inline: 0.25rem;
|
|
||||||
width: calc(100vw - 0.5rem);
|
|
||||||
border-radius: 0;
|
|
||||||
margin-block-start: 0.2rem;
|
|
||||||
}
|
|
||||||
main {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
padding-inline: 0.25rem;
|
|
||||||
height: max(fit-content, 2rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-name {
|
|
||||||
text-decoration: none;
|
|
||||||
background: linear-gradient(
|
|
||||||
130deg,
|
|
||||||
oklch(from var(--c1) var(--name-lightness) c h),
|
|
||||||
oklch(from var(--c2) var(--name-lightness) c h)
|
|
||||||
);
|
|
||||||
background-clip: text;
|
|
||||||
color: transparent;
|
|
||||||
filter: brightness(1.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
b {
|
|
||||||
color: oklch(from var(--c2) var(--name-lightness) c h);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
width: 100%;
|
|
||||||
height: 64px;
|
|
||||||
}
|
|
||||||
+85
-57
@@ -1,86 +1,114 @@
|
|||||||
|
use std::any::Any;
|
||||||
|
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
extract::State,
|
extract::rejection::{FormRejection, QueryRejection},
|
||||||
http::{StatusCode, header},
|
http::{HeaderValue, StatusCode, header},
|
||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
routing::get,
|
|
||||||
};
|
};
|
||||||
use conduwuit_build_metadata::{GIT_REMOTE_COMMIT_URL, GIT_REMOTE_WEB_URL, version_tag};
|
|
||||||
use conduwuit_service::state;
|
use conduwuit_service::state;
|
||||||
|
use tower_http::{catch_panic::CatchPanicLayer, set_header::SetResponseHeaderLayer};
|
||||||
|
use tower_sec_fetch::SecFetchLayer;
|
||||||
|
|
||||||
pub fn build() -> Router<state::State> {
|
use crate::pages::TemplateContext;
|
||||||
Router::<state::State>::new()
|
|
||||||
.route("/", get(index_handler))
|
|
||||||
.route("/_continuwuity/logo.svg", get(logo_handler))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn index_handler(
|
mod pages;
|
||||||
State(services): State<state::State>,
|
|
||||||
) -> Result<impl IntoResponse, WebError> {
|
|
||||||
#[derive(Debug, Template)]
|
|
||||||
#[template(path = "index.html.j2")]
|
|
||||||
struct Index<'a> {
|
|
||||||
nonce: &'a str,
|
|
||||||
server_name: &'a str,
|
|
||||||
first_run: bool,
|
|
||||||
}
|
|
||||||
let nonce = rand::random::<u64>().to_string();
|
|
||||||
|
|
||||||
let template = Index {
|
type State = state::State;
|
||||||
nonce: &nonce,
|
|
||||||
server_name: services.config.server_name.as_str(),
|
|
||||||
first_run: services.firstrun.is_first_run(),
|
|
||||||
};
|
|
||||||
Ok((
|
|
||||||
[(
|
|
||||||
header::CONTENT_SECURITY_POLICY,
|
|
||||||
format!("default-src 'nonce-{nonce}'; img-src 'self';"),
|
|
||||||
)],
|
|
||||||
Html(template.render()?),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn logo_handler() -> impl IntoResponse {
|
const CATASTROPHIC_FAILURE: &str = "cat-astrophic failure! we couldn't even render the error template. \
|
||||||
(
|
please contact the team @ https://continuwuity.org";
|
||||||
[(header::CONTENT_TYPE, "image/svg+xml")],
|
|
||||||
include_str!("templates/logo.svg").to_owned(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
enum WebError {
|
enum WebError {
|
||||||
|
#[error("Failed to validate form body: {0}")]
|
||||||
|
ValidationError(#[from] validator::ValidationErrors),
|
||||||
|
#[error("{0}")]
|
||||||
|
QueryRejection(#[from] QueryRejection),
|
||||||
|
#[error("{0}")]
|
||||||
|
FormRejection(#[from] FormRejection),
|
||||||
|
#[error("{0}")]
|
||||||
|
BadRequest(String),
|
||||||
|
|
||||||
|
#[error("This page does not exist.")]
|
||||||
|
NotFound,
|
||||||
|
|
||||||
#[error("Failed to render template: {0}")]
|
#[error("Failed to render template: {0}")]
|
||||||
Render(#[from] askama::Error),
|
Render(#[from] askama::Error),
|
||||||
|
#[error("{0}")]
|
||||||
|
InternalError(#[from] conduwuit_core::Error),
|
||||||
|
#[error("Request handler panicked! {0}")]
|
||||||
|
Panic(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for WebError {
|
impl IntoResponse for WebError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
#[derive(Debug, Template)]
|
#[derive(Debug, Template)]
|
||||||
#[template(path = "error.html.j2")]
|
#[template(path = "error.html.j2")]
|
||||||
struct Error<'a> {
|
struct Error {
|
||||||
nonce: &'a str,
|
error: WebError,
|
||||||
err: WebError,
|
status: StatusCode,
|
||||||
|
context: TemplateContext,
|
||||||
}
|
}
|
||||||
|
|
||||||
let nonce = rand::random::<u64>().to_string();
|
|
||||||
|
|
||||||
let status = match &self {
|
let status = match &self {
|
||||||
| Self::Render(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
| Self::ValidationError(_)
|
||||||
|
| Self::BadRequest(_)
|
||||||
|
| Self::QueryRejection(_)
|
||||||
|
| Self::FormRejection(_) => StatusCode::BAD_REQUEST,
|
||||||
|
| Self::NotFound => StatusCode::NOT_FOUND,
|
||||||
|
| _ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
};
|
};
|
||||||
let tmpl = Error { nonce: &nonce, err: self };
|
|
||||||
if let Ok(body) = tmpl.render() {
|
let template = Error {
|
||||||
(
|
error: self,
|
||||||
status,
|
status,
|
||||||
[(
|
context: TemplateContext {
|
||||||
header::CONTENT_SECURITY_POLICY,
|
// Statically set false to prevent error pages from being indexed.
|
||||||
format!("default-src 'none' 'nonce-{nonce}';"),
|
allow_indexing: false,
|
||||||
)],
|
},
|
||||||
Html(body),
|
};
|
||||||
)
|
|
||||||
.into_response()
|
if let Ok(body) = template.render() {
|
||||||
|
(status, Html(body)).into_response()
|
||||||
} else {
|
} else {
|
||||||
(status, "Something went wrong").into_response()
|
(status, CATASTROPHIC_FAILURE).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn build() -> Router<state::State> {
|
||||||
|
#[allow(clippy::wildcard_imports)]
|
||||||
|
use pages::*;
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.merge(index::build())
|
||||||
|
.nest(
|
||||||
|
"/_continuwuity/",
|
||||||
|
Router::new()
|
||||||
|
.merge(resources::build())
|
||||||
|
.merge(password_reset::build())
|
||||||
|
.merge(debug::build())
|
||||||
|
.merge(threepid::build())
|
||||||
|
.fallback(async || WebError::NotFound),
|
||||||
|
)
|
||||||
|
.layer(CatchPanicLayer::custom(|panic: Box<dyn Any + Send + 'static>| {
|
||||||
|
let details = if let Some(s) = panic.downcast_ref::<String>() {
|
||||||
|
s.clone()
|
||||||
|
} else if let Some(s) = panic.downcast_ref::<&str>() {
|
||||||
|
(*s).to_owned()
|
||||||
|
} else {
|
||||||
|
"(opaque panic payload)".to_owned()
|
||||||
|
};
|
||||||
|
|
||||||
|
WebError::Panic(details).into_response()
|
||||||
|
}))
|
||||||
|
.layer(SetResponseHeaderLayer::if_not_present(
|
||||||
|
header::CONTENT_SECURITY_POLICY,
|
||||||
|
HeaderValue::from_static("default-src 'self'; img-src 'self' data:;"),
|
||||||
|
))
|
||||||
|
.layer(SecFetchLayer::new(|policy| {
|
||||||
|
policy.allow_safe_methods().reject_missing_metadata();
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
use askama::{Template, filters::HtmlSafe};
|
||||||
|
use validator::ValidationErrors;
|
||||||
|
|
||||||
|
/// A reusable form component with field validation.
|
||||||
|
#[derive(Debug, Template)]
|
||||||
|
#[template(path = "_components/form.html.j2", print = "code")]
|
||||||
|
pub(crate) struct Form<'a> {
|
||||||
|
pub inputs: Vec<FormInput<'a>>,
|
||||||
|
pub validation_errors: Option<ValidationErrors>,
|
||||||
|
pub submit_label: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HtmlSafe for Form<'_> {}
|
||||||
|
|
||||||
|
/// An input element in a form component.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub(crate) struct FormInput<'a> {
|
||||||
|
/// The field name of the input.
|
||||||
|
pub id: &'static str,
|
||||||
|
/// The `type` property of the input.
|
||||||
|
pub input_type: &'a str,
|
||||||
|
/// The contents of the input's label.
|
||||||
|
pub label: &'a str,
|
||||||
|
/// Whether the input is required. Defaults to `true`.
|
||||||
|
pub required: bool,
|
||||||
|
/// The autocomplete mode for the input. Defaults to `on`.
|
||||||
|
pub autocomplete: &'a str,
|
||||||
|
|
||||||
|
// This is a hack to make the form! macro's support for client-only fields
|
||||||
|
// work properly. Client-only fields are specified in the macro without a type and aren't
|
||||||
|
// included in the POST body or as a field in the generated struct.
|
||||||
|
// To keep the field from being included in the POST body, its `name` property needs not to
|
||||||
|
// be set in the template. Because of limitations of macro_rules!'s repetition feature, this
|
||||||
|
// field needs to exist to allow the template to check if the field is client-only.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub type_name: Option<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FormInput<'_> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
id: "",
|
||||||
|
input_type: "text",
|
||||||
|
label: "",
|
||||||
|
required: true,
|
||||||
|
autocomplete: "",
|
||||||
|
|
||||||
|
type_name: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a deserializable struct which may be turned into a [`Form`]
|
||||||
|
/// for inclusion in another template.
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! form {
|
||||||
|
(
|
||||||
|
$(#[$struct_meta:meta])*
|
||||||
|
struct $struct_name:ident {
|
||||||
|
$(
|
||||||
|
$(#[$field_meta:meta])*
|
||||||
|
$name:ident$(: $type:ty)? where { $($prop:ident: $value:expr),* }
|
||||||
|
),*
|
||||||
|
|
||||||
|
submit: $submit_label:expr
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
#[derive(Debug, serde::Deserialize, validator::Validate)]
|
||||||
|
$(#[$struct_meta])*
|
||||||
|
struct $struct_name {
|
||||||
|
$(
|
||||||
|
$(#[$field_meta])*
|
||||||
|
$(pub $name: $type,)?
|
||||||
|
)*
|
||||||
|
}
|
||||||
|
|
||||||
|
impl $struct_name {
|
||||||
|
/// Generate a [`Form`] which matches the shape of this struct.
|
||||||
|
#[allow(clippy::needless_update)]
|
||||||
|
fn build(validation_errors: Option<validator::ValidationErrors>) -> $crate::pages::components::form::Form<'static> {
|
||||||
|
$crate::pages::components::form::Form {
|
||||||
|
inputs: vec![
|
||||||
|
$(
|
||||||
|
$crate::pages::components::form::FormInput {
|
||||||
|
id: stringify!($name),
|
||||||
|
$(type_name: Some(stringify!($type)),)?
|
||||||
|
$($prop: $value),*,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)*
|
||||||
|
],
|
||||||
|
validation_errors,
|
||||||
|
submit_label: $submit_label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
use askama::{Template, filters::HtmlSafe};
|
||||||
|
use base64::Engine;
|
||||||
|
use conduwuit_core::result::FlatOk;
|
||||||
|
use conduwuit_service::Services;
|
||||||
|
use ruma::UserId;
|
||||||
|
|
||||||
|
pub(super) mod form;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(super) enum AvatarType<'a> {
|
||||||
|
Initial(char),
|
||||||
|
Image(&'a str),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Template)]
|
||||||
|
#[template(path = "_components/avatar.html.j2")]
|
||||||
|
pub(super) struct Avatar<'a> {
|
||||||
|
pub(super) avatar_type: AvatarType<'a>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HtmlSafe for Avatar<'_> {}
|
||||||
|
|
||||||
|
#[derive(Debug, Template)]
|
||||||
|
#[template(path = "_components/user_card.html.j2")]
|
||||||
|
pub(super) struct UserCard<'a> {
|
||||||
|
pub user_id: &'a UserId,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub avatar_src: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HtmlSafe for UserCard<'_> {}
|
||||||
|
|
||||||
|
impl<'a> UserCard<'a> {
|
||||||
|
pub(super) async fn for_local_user(services: &Services, user_id: &'a UserId) -> Self {
|
||||||
|
let display_name = services.users.displayname(user_id).await.ok();
|
||||||
|
|
||||||
|
let avatar_src = async {
|
||||||
|
let avatar_url = services.users.avatar_url(user_id).await.ok()?;
|
||||||
|
let avatar_mxc = avatar_url.parts().ok()?;
|
||||||
|
let file = services.media.get(&avatar_mxc).await.flat_ok()?;
|
||||||
|
|
||||||
|
Some(format!(
|
||||||
|
"data:{};base64,{}",
|
||||||
|
file.content_type
|
||||||
|
.unwrap_or_else(|| "application/octet-stream".to_owned()),
|
||||||
|
file.content
|
||||||
|
.map(|content| base64::prelude::BASE64_STANDARD.encode(content))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Self { user_id, display_name, avatar_src }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn avatar(&'a self) -> Avatar<'a> {
|
||||||
|
let avatar_type = if let Some(ref avatar_src) = self.avatar_src {
|
||||||
|
AvatarType::Image(avatar_src)
|
||||||
|
} else if let Some(initial) = self
|
||||||
|
.display_name
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|display_name| display_name.chars().next())
|
||||||
|
{
|
||||||
|
AvatarType::Initial(initial)
|
||||||
|
} else {
|
||||||
|
AvatarType::Initial(self.user_id.localpart().chars().next().unwrap())
|
||||||
|
};
|
||||||
|
|
||||||
|
Avatar { avatar_type }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
use std::convert::Infallible;
|
||||||
|
|
||||||
|
use axum::{Router, routing::get};
|
||||||
|
use conduwuit_core::Error;
|
||||||
|
|
||||||
|
use crate::WebError;
|
||||||
|
|
||||||
|
pub(crate) fn build() -> Router<crate::State> {
|
||||||
|
Router::new()
|
||||||
|
.route("/_debug/panic", get(async || -> Infallible { panic!("Guru meditation error") }))
|
||||||
|
.route(
|
||||||
|
"/_debug/error",
|
||||||
|
get(async || -> WebError {
|
||||||
|
Error::Err(std::borrow::Cow::Borrowed("Guru meditation error")).into()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
use axum::{Router, extract::State, response::IntoResponse, routing::get};
|
||||||
|
|
||||||
|
use crate::{WebError, template};
|
||||||
|
|
||||||
|
pub(crate) fn build() -> Router<crate::State> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(index))
|
||||||
|
.route("/_continuwuity/", get(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn index(State(services): State<crate::State>) -> Result<impl IntoResponse, WebError> {
|
||||||
|
template! {
|
||||||
|
struct Index<'a> use "index.html.j2" {
|
||||||
|
server_name: &'a str,
|
||||||
|
first_run: bool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Index::new(
|
||||||
|
&services,
|
||||||
|
services.globals.server_name().as_str(),
|
||||||
|
services.firstrun.is_first_run(),
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
mod components;
|
||||||
|
pub(super) mod debug;
|
||||||
|
pub(super) mod index;
|
||||||
|
pub(super) mod password_reset;
|
||||||
|
pub(super) mod resources;
|
||||||
|
pub(super) mod threepid;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct TemplateContext {
|
||||||
|
pub allow_indexing: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&crate::State> for TemplateContext {
|
||||||
|
fn from(state: &crate::State) -> Self {
|
||||||
|
Self {
|
||||||
|
allow_indexing: state.config.allow_web_indexing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! template {
|
||||||
|
(
|
||||||
|
struct $name:ident $(<$lifetime:lifetime>)? use $path:literal {
|
||||||
|
$($field_name:ident: $field_type:ty),*
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
#[derive(Debug, askama::Template)]
|
||||||
|
#[template(path = $path)]
|
||||||
|
struct $name$(<$lifetime>)? {
|
||||||
|
context: $crate::pages::TemplateContext,
|
||||||
|
$($field_name: $field_type,)*
|
||||||
|
}
|
||||||
|
|
||||||
|
impl$(<$lifetime>)? $name$(<$lifetime>)? {
|
||||||
|
fn new(state: &$crate::State, $($field_name: $field_type,)*) -> Self {
|
||||||
|
Self {
|
||||||
|
context: state.into(),
|
||||||
|
$($field_name,)*
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(single_use_lifetimes)]
|
||||||
|
impl$(<$lifetime>)? axum::response::IntoResponse for $name$(<$lifetime>)? {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
use askama::Template;
|
||||||
|
|
||||||
|
match self.render() {
|
||||||
|
Ok(rendered) => axum::response::Html(rendered).into_response(),
|
||||||
|
Err(err) => $crate::WebError::from(err).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
extract::{
|
||||||
|
Query, State,
|
||||||
|
rejection::{FormRejection, QueryRejection},
|
||||||
|
},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::get,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
WebError, form,
|
||||||
|
pages::components::{UserCard, form::Form},
|
||||||
|
template,
|
||||||
|
};
|
||||||
|
|
||||||
|
const INVALID_TOKEN_ERROR: &str = "Invalid reset token. Your reset link may have expired.";
|
||||||
|
|
||||||
|
template! {
|
||||||
|
struct PasswordReset<'a> use "password_reset.html.j2" {
|
||||||
|
user_card: UserCard<'a>,
|
||||||
|
body: PasswordResetBody
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum PasswordResetBody {
|
||||||
|
Form(Form<'static>),
|
||||||
|
Success,
|
||||||
|
}
|
||||||
|
|
||||||
|
form! {
|
||||||
|
struct PasswordResetForm {
|
||||||
|
#[validate(length(min = 1, message = "Password cannot be empty"))]
|
||||||
|
new_password: String where {
|
||||||
|
input_type: "password",
|
||||||
|
label: "New password",
|
||||||
|
autocomplete: "new-password"
|
||||||
|
},
|
||||||
|
|
||||||
|
#[validate(must_match(other = "new_password", message = "Passwords must match"))]
|
||||||
|
confirm_new_password: String where {
|
||||||
|
input_type: "password",
|
||||||
|
label: "Confirm new password",
|
||||||
|
autocomplete: "new-password"
|
||||||
|
}
|
||||||
|
|
||||||
|
submit: "Reset Password"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build() -> Router<crate::State> {
|
||||||
|
Router::new()
|
||||||
|
.route("/account/reset_password", get(get_password_reset).post(post_password_reset))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PasswordResetQuery {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn password_reset_form(
|
||||||
|
services: crate::State,
|
||||||
|
query: PasswordResetQuery,
|
||||||
|
reset_form: Form<'static>,
|
||||||
|
) -> Result<impl IntoResponse, WebError> {
|
||||||
|
let Some(token) = services.password_reset.check_token(&query.token).await else {
|
||||||
|
return Err(WebError::BadRequest(INVALID_TOKEN_ERROR.to_owned()));
|
||||||
|
};
|
||||||
|
|
||||||
|
let user_card = UserCard::for_local_user(&services, &token.info.user).await;
|
||||||
|
|
||||||
|
Ok(PasswordReset::new(&services, user_card, PasswordResetBody::Form(reset_form))
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_password_reset(
|
||||||
|
State(services): State<crate::State>,
|
||||||
|
query: Result<Query<PasswordResetQuery>, QueryRejection>,
|
||||||
|
) -> Result<impl IntoResponse, WebError> {
|
||||||
|
let Query(query) = query?;
|
||||||
|
|
||||||
|
password_reset_form(services, query, PasswordResetForm::build(None)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_password_reset(
|
||||||
|
State(services): State<crate::State>,
|
||||||
|
query: Result<Query<PasswordResetQuery>, QueryRejection>,
|
||||||
|
form: Result<axum::Form<PasswordResetForm>, FormRejection>,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let Query(query) = query?;
|
||||||
|
let axum::Form(form) = form?;
|
||||||
|
|
||||||
|
match form.validate() {
|
||||||
|
| Ok(()) => {
|
||||||
|
let Some(token) = services.password_reset.check_token(&query.token).await else {
|
||||||
|
return Err(WebError::BadRequest(INVALID_TOKEN_ERROR.to_owned()));
|
||||||
|
};
|
||||||
|
let user_id = token.info.user.clone();
|
||||||
|
|
||||||
|
services
|
||||||
|
.password_reset
|
||||||
|
.consume_token(token, &form.new_password)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let user_card = UserCard::for_local_user(&services, &user_id).await;
|
||||||
|
Ok(PasswordReset::new(&services, user_card, PasswordResetBody::Success)
|
||||||
|
.into_response())
|
||||||
|
},
|
||||||
|
| Err(err) => Ok((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
password_reset_form(services, query, PasswordResetForm::build(Some(err))).await,
|
||||||
|
)
|
||||||
|
.into_response()),
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user