Compare commits

..

102 Commits

Author SHA1 Message Date
Renovate Bot
ab10d37f68 chore(deps): update node-patch-updates to v2.0.8 2026-04-01 05:03:35 +00:00
timedout
0cc1e4685c style: Make main green again 2026-03-31 18:07:44 +01:00
ginger
3d2915093c Update LICENSE 2026-03-31 02:26:22 +00:00
Ginger
e1c54f4dec fix: Don't allow UIAA stages to be completed if no flow includes them 2026-03-31 02:20:59 +00:00
ginger
0c9fa3b7e5 feat: Add a notice about email to the first-run banner 2026-03-31 02:20:59 +00:00
Ginger
a95b488e6a chore: Update admin command docs 2026-03-31 02:20:59 +00:00
Ginger
4f8833e937 fix: Update connection_uri docs 2026-03-31 02:20:59 +00:00
Ginger
f32599e030 feat: Supply more informative error message if email is disabled 2026-03-31 02:20:59 +00:00
Ginger
b6f0b41d3d feat: Ratelimit sending threepid validation emails 2026-03-31 02:20:59 +00:00
Ginger
d5675b85cf fix: Release session lock before sending threepid validation email 2026-03-31 02:20:59 +00:00
Ginger
951b5abe19 refactor: Remove UiaaStatus enum 2026-03-31 02:20:59 +00:00
Ginger
a325ad16f1 feat: Fall back to email when registering a user who didn't provide a username 2026-03-31 02:20:59 +00:00
Ginger
f93a1cc506 fix: Don't bail out on email association failures when registering a new user 2026-03-31 02:20:59 +00:00
Ginger
6e8dbcbfab refactor: Remove workarounds for matrix-appservice-irc 2026-03-31 02:20:59 +00:00
ginger
97458207e5 chore: Update news fragment 2026-03-31 02:20:59 +00:00
Ginger
ab8929e2fa chore: Fix typo 2026-03-31 02:20:59 +00:00
Ginger
166d7d0f63 fix: Remove associated email on account deactivation 2026-03-31 02:20:59 +00:00
Ginger
20a6f0c6fb chore: News fragment 2026-03-31 02:20:59 +00:00
Ginger
3885e43b5d feat: Add support for 3pid management 2026-03-31 02:20:59 +00:00
Ginger
ef7ad6082c feat: Add support for registering a new account with an email address 2026-03-31 02:20:59 +00:00
Ginger
717d319708 feat: Add support for logging in with an email address 2026-03-31 02:20:59 +00:00
Ginger
0b04757bef feat: Add support for password resets via email 2026-03-31 02:20:59 +00:00
Ginger
f2b7dd6519 feat: Add a webpage for threepid validation links 2026-03-31 02:20:59 +00:00
Ginger
9d06208a7a feat: Store threepid validation sessions in memory instead of the database 2026-03-31 02:20:59 +00:00
Ginger
955da3a74f feat: Add admin commands for managing users' email addresses 2026-03-31 02:20:59 +00:00
Ginger
7e79a544cf refactor: Split account routes into multiple files 2026-03-31 02:20:59 +00:00
Ginger
f5db4d17d6 feat: Refactor UIAA service, add support for email stage 2026-03-31 02:20:59 +00:00
Ginger
54fd1d313f feat: Implement threepid service 2026-03-31 02:20:59 +00:00
Ginger
bb7fd9efc1 feat: Implement mailer service for sending emails 2026-03-31 02:20:59 +00:00
Jade Ellis
aa79072411 docs: Revert duplicate link 2026-03-29 19:34:56 +01:00
Jade Ellis
8b72c5eb11 docs: Fix email link 2026-03-29 19:25:24 +01:00
Jade Ellis
e5cfc503d8 docs: Delete unused book.toml 2026-03-29 19:21:02 +01:00
Jade Ellis
07d5081008 docs: Apply feedback 2026-03-29 19:20:05 +01:00
Jade Ellis
dba7f47972 docs: Link MatrixRTC room 2026-03-29 19:15:42 +01:00
Jade Ellis
0a2d4e1cb2 docs: Replace Contributor Covenant with community guidelines 2026-03-29 19:15:42 +01:00
Jade Ellis
f45857acd4 docs: Update community guidelines 2026-03-29 19:15:41 +01:00
norm
9209b847f6 docs: Mention systemd's ReadWritePaths setting for the backup dir
The systemd unit file uses `ProtectSystem=strict`, which makes almost
every directory read-only. This can cause backups to not work, even if
the directory is granted the correct permissions and ownership to the
`conduwuit` user.

The `ReadWritePaths` setting lets you specify which directories are
exempt from being made read-only by `ProtectSystem=strict`.
2026-03-27 19:25:26 +00:00
Jade Ellis
cf9c2c23b6 chore: Upgrade git dependencies 2026-03-27 18:39:43 +00:00
Jade Ellis
1bd161a306 fix(deps): Update to rocksdb v10.10.1, jemalloc 0.6.1
Re-adds revert to try and fix rocksdb repair deadlock
2026-03-27 18:39:43 +00:00
Renovate Bot
0a0206e866 chore(deps): update node-patch-updates to v2.0.7 2026-03-27 13:31:35 +00:00
Henry-Hiles
e6f31d7d4f fix(renovate): Fix name of extends of renovate.json to use full name for pinGitHubActionDigests 2026-03-26 21:45:11 -04:00
timedout
f0c3fdfe3a fix: Well-known read errors no longer crash resolver flow
Reviewed-By: Jade Ellis <jade@ellis.link>
2026-03-27 00:54:17 +00:00
Jade
3c3314b498 deps: Pin actions
In the wake of all the compromises so far this week, this seems like a good idea.
2026-03-27 00:46:06 +00:00
Niklas Wojtkowiak
8e7846c644 fix(alias): preserve room alias enumeration on delete 2026-03-26 19:23:24 +00:00
Jade Ellis
3ebaba920f ci: Minor improvements 2026-03-25 17:32:28 +00:00
Jade Ellis
19e620c8c6 ci: Automatically comment on pull requests missing changelog entries 2026-03-25 17:32:28 +00:00
Henry-Hiles
300b6d81e7 feat(nix): add NPM to devshell 2026-03-25 12:55:49 +00:00
PerformativeJade
ed81dfc6cd fix: Thumbnail fetching error handling 2026-03-24 20:14:55 +00:00
Jade Ellis
2ffafc17d2 style: Unmeow 2026-03-24 19:48:37 +00:00
Jade Ellis
8589563a2f meow 2026-03-24 19:46:14 +00:00
Henry-Hiles
27d806e961 fix(docs): make contributing.mdx a symlink 2026-03-24 11:18:54 -04:00
stratself
7aa02a1cd9 fix(docs): Remove prefligit reference 2026-03-24 13:20:56 +00:00
stratself
fc342f5401 docs: move all contrib docs to central source at CONTRIBUTING.md
* remove rarely-used docs/contributing.mdx page and redirect links to
  docs/development/contributing.mdx
* softlink docs/development/contributing.mdx to CONTRIBUTING.md
* add back section of towncrier to CONTRIBUTING.md
* use indirect hyperlinks for all URLs in CONTRIBUTING.md
2026-03-24 13:20:56 +00:00
stratself
ef089c1800 docs(livekit): Put livekit+coturn port clash notice in a tip box
* reworded first part of external TURN integration
* add restart/recreate instructions to apply final TURNs changes
2026-03-24 13:20:13 +00:00
stratself
279c505af9 docs(livekit): Further enhance compose instructions + examples 2026-03-24 13:20:13 +00:00
stratself
f9058ee062 docs: Add instructions from #1440 to Livekit workarounds
* still keep the link to the issue on forgejo
* also fixed a word in the Calls overview page
2026-03-24 13:20:13 +00:00
stratself
6c856bd1a4 chore: Write news fragment for PR 2026-03-24 13:20:13 +00:00
stratself
4dbda8692c fix(docs): Other small improvements in clarity and consistent wordings 2026-03-24 13:20:13 +00:00
stratself
075914d8e8 fix(docs): Use correct var for nonfed server in livekit t00ting 2026-03-24 13:20:13 +00:00
stratself
a2a644194b fix(docs): Remove trailing whitespace 2026-03-24 13:20:13 +00:00
stratself
093ef742c3 docs(livekit): various mini-clarifications and edits
* specify that the added ports belong to livekit's container in
  TURN section, and remind firewall rules for them
* prioritize the network_mode: host workaround
* add docker livelogs instructions
* use bash for code blocks instead of console
* some other small fixes
2026-03-24 13:20:13 +00:00
stratself
010daf079d fix(docs): use docker run instead of exec for a livekit troubleshooting command 2026-03-24 13:20:13 +00:00
stratself
58c4f5d5b5 fix(docs): further apply fixes from feedback for livekit documentation 2026-03-24 13:20:13 +00:00
ginger
c78a72bbef chore: Trim trailing whitespace
Signed-off-by: Ellis Git <forgejo@mail.ellis.link>
2026-03-24 13:20:13 +00:00
stratself
7e8f1ffd63 fix(docs): little nits for livekit's troubleshooting section 2026-03-24 13:20:13 +00:00
stratself
3d0b886ab8 fix(docs): apply clarity fixes for livekit testing from feedbacks
* clearer wording and ordering on client token versus openid token
* provide outputs for curl examples
2026-03-24 13:20:13 +00:00
stratself
2e7bfea240 docs(livekit): new troubleshooting section and other small changes
* add link to matrix-rtc room
* include livekit key-secret pair examples for clarity with livekit.yaml
* troubleshooting: add common EC errors and docker networking subsections
* fix a merge conflict issue
2026-03-24 13:20:13 +00:00
stratself
b9456c1130 docs: add caveat for deployment with non-federated instances 2026-03-24 13:20:13 +00:00
stratself
3ce6e909dd docs: apply changes from feedback
turn all the things into LiveKit
2026-03-24 13:20:13 +00:00
stratself
3b4b401a51 docs: add livekit testing instructions against new /get_token endpoint 2026-03-24 13:20:13 +00:00
stratself
260b88975d docs: replace personal links and small fixes in docs for Livekit TURN 2026-03-24 13:20:13 +00:00
stratself
be8e3772c1 docs: rework Related Documentation section for livekit page
* separate links into categories in order of importance: guides, specs, source codes
* add short description to included community guides
* add Element Call, lk-jwt-service, and the livekit MSCs too
2026-03-24 13:20:13 +00:00
stratself
8b91db2918 docs: add caveat for deployment with non-federated instances 2026-03-24 13:20:13 +00:00
stratself
34758c52cc docs: apply changes from feedback
turn all the things into LiveKit
2026-03-24 13:20:13 +00:00
stratself
8b8c015dcc docs: add livekit testing instructions against new /get_token endpoint 2026-03-24 13:20:13 +00:00
stratself
9afe5f6bed docs: add caveat for deployment with non-federated instances 2026-03-24 13:20:13 +00:00
stratself
fe03b3b8b7 docs: apply changes from feedback
turn all the things into LiveKit
2026-03-24 13:20:13 +00:00
stratself
a04ef6d686 docs: add livekit testing instructions against new /get_token endpoint 2026-03-24 13:20:13 +00:00
stratself
fd807ff1f6 docs: specify both inbuilt + external options for livekit TURN in calls page 2026-03-24 13:20:13 +00:00
stratself
b0632dde41 docs: replace personal links and small fixes in docs for Livekit TURN 2026-03-24 13:20:13 +00:00
stratself
cc3a8a1d40 docs: move Livekit's inbuilt TURN guide to top
The purpose is to simplify new deployments, which are more likely
to use Livekit-only calls. This also makes docs flow a bit better
2026-03-24 13:20:13 +00:00
stratself
30a540d8bc docs: rework Related Documentation section for livekit page
* separate links into categories in order of importance: guides, specs, source codes
* add short description to included community guides
* add Element Call, lk-jwt-service, and the livekit MSCs too
2026-03-24 13:20:13 +00:00
stratself
6d0832a6ee docs: replaces all instances of matrix-rtc to livekit to match rest of page 2026-03-24 13:20:13 +00:00
Renovate Bot
119aa6476d chore(deps): update docker/setup-qemu-action action to v4 2026-03-24 13:12:12 +00:00
Jonathan Sutton
b9854662f3 fix(room_member): Strip join_authorized_via_users_server (#1542)
Realized code for fix did in fact require a check for
`join_authorized_via_users_server` before stripping. Otherwise,
waste processing power, most of the time.

Signed-off-by: Jonathan Sutton <jonathansutton91@proton.me>
2026-03-24 13:11:25 +00:00
Jonathan Sutton
dab50b1ec3 fix(room_member): Strip join_authorized_via_users_server (#1542)
Fixed test.

Signed-off-by: Jonathan Sutton <jonathansutton91@proton.me>
2026-03-24 13:11:25 +00:00
Jonathan Sutton
0338539221 fix(room_member): Strip join_authorized_via_users_server (#1542)
Added test.

Signed-off-by: Jonathan Sutton <jonathansutton91@proton.me>
2026-03-24 13:11:25 +00:00
Jonathan Sutton
e94e614498 fix(room_member): Strip join_authorized_via_users_server (#1542)
Removed extra clone() and made membership_content mutable, to change
contents and reserialize to json.

Signed-off-by: Jonathan Sutton <jonathansutton91@proton.me>
2026-03-24 13:11:25 +00:00
Jonathan Sutton
098e8a0b92 fix(room_member): Strip join_authorized_via_users_server (#1542)
Added news fragment.

Signed-off-by: Jonathan Sutton <jonathansutton91@proton.me>
2026-03-24 13:11:25 +00:00
Jonathan Sutton
1c3890476a fix(room_member): Strip join_authorized_via_users_server (#1542)
Actually implemented fix. Modified json if user was already a member.

Signed-off-by: Jonathan Sutton <jonathansutton91@proton.me>
2026-03-24 13:11:25 +00:00
Jonathan Sutton
8ef6f02ee9 fix(room_member): Strip join_authorized_via_users_server (#1542)
Some clients were sending join_authorized_via_users_server when they
were already in the room, to change nicknames. This caused an undesirable
error, so a check for if they were already in the room was moved and
changed to strip from metadata before attempting to process metadata.

Signed-off-by: Jonathan Sutton <jonathansutton91@proton.me>
2026-03-24 13:11:25 +00:00
Renovate Bot
11020df89d chore(deps): update node-patch-updates to v2.0.6 2026-03-24 13:10:39 +00:00
Renovate Bot
47e3738807 chore(deps): update dependency cargo-bins/cargo-binstall to v1.17.8 2026-03-24 13:08:48 +00:00
Renovate Bot
8afb19757e chore(deps): update dependency typescript to v6 2026-03-24 05:02:11 +00:00
31a05b9c
de3dfb2bea style: format 2026-03-23 20:54:10 +00:00
31a05b9c
bbb2615f2c fix: request errror: error sending request 2026-03-23 19:27:18 +00:00
coolGi
af1b4de231 fix: Typo in the domain for the announcment schema 2026-03-22 21:34:55 +13:00
timedout
677c407755 chore: Bump ruwuma
# Conflicts:
#	Cargo.lock
#	Cargo.toml
2026-03-21 16:24:05 +00:00
renovate
e3ae714248 chore(Nix): Updated flake hashes 2026-03-20 18:55:28 +00:00
Jade Ellis
fb9a2aa4d6 chore: Upgrade Rust to 1.92 2026-03-20 18:27:59 +00:00
coolGi
5164822090 chore: Update ruwuma 2026-03-21 06:13:45 +13:00
Jade Ellis
6b013bcf60 chore: Update funding links 2026-03-19 12:45:12 +00:00
82 changed files with 3527 additions and 2182 deletions

View File

@@ -75,7 +75,7 @@ runs:
- name: Set up QEMU
if: ${{ env.BUILDKIT_ENDPOINT == '' }}
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Login to builtin registry
if: ${{ env.BUILTIN_REGISTRY_ENABLED == 'true' }}

View File

@@ -0,0 +1,103 @@
name: Check Changelog
on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review]
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
issues: write
jobs:
check-changelog:
name: Check for changelog
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
persist-credentials: false
sparse-checkout: .
- name: Check for changelog entry
id: check_files
run: |
git fetch origin ${GITHUB_BASE_REF}
# Check for Added (A) or Modified (M) files in changelog.d
CHANGELOG_CHANGES=$(git diff --name-status origin/${GITHUB_BASE_REF} HEAD -- changelog.d/)
SRC_CHANGES=$(git diff --name-status origin/${GITHUB_BASE_REF} HEAD -- src/)
echo "Changes in changelog.d/:"
echo "$CHANGELOG_CHANGES"
echo "Changes in src/:"
echo "$SRC_CHANGES"
if echo "$CHANGELOG_CHANGES" | grep -q "^[AM]"; then
echo "has_changelog=true" >> $GITHUB_OUTPUT
else
echo "has_changelog=false" >> $GITHUB_OUTPUT
fi
if [ -n "$SRC_CHANGES" ]; then
echo "src_changed=true" >> $GITHUB_OUTPUT
else
echo "src_changed=false" >> $GITHUB_OUTPUT
fi
- name: Manage PR Comment
uses: https://github.com/actions/github-script@v8
env:
HAS_CHANGELOG: ${{ steps.check_files.outputs.has_changelog }}
SRC_CHANGED: ${{ steps.check_files.outputs.src_changed }}
with:
script: |
const hasChangelog = process.env.HAS_CHANGELOG === 'true';
const srcChanged = process.env.SRC_CHANGED === 'true';
const commentSignature = '<!-- changelog-check-action -->';
const commentBody = `${commentSignature}\nPlease add a changelog fragment to \`changelog.d/\` describing your changes.`;
const { data: currentUser } = await github.rest.users.getAuthenticated();
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.find(comment =>
comment.user.id === currentUser.id &&
comment.body.includes(commentSignature)
);
const shouldWarn = srcChanged && !hasChangelog;
if (!shouldWarn) {
if (botComment) {
console.log('Changelog found or not required. Deleting existing warning comment.');
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
});
}
} else {
if (!botComment) {
console.log('Changelog missing and required. Creating warning comment.');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: commentBody,
});
}
}

4
.github/FUNDING.yml vendored
View File

@@ -1,4 +1,4 @@
github: [JadedBlueEyes, nexy7574, gingershaped]
custom:
- https://ko-fi.com/nexy7574
- https://ko-fi.com/JadedBlueEyes
- https://timedout.uk/donate.html
- https://jade.ellis.link/sponsors

View File

@@ -1,131 +1 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of
any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement over Matrix at [#continuwuity:continuwuity.org](https://matrix.to/#/#continuwuity:continuwuity.org?via=continuwuity.org&via=ellis.link&via=explodie.org&via=matrix.org) or email at <tom@tcpip.uk>, <jade@continuwuity.org> and <nex@continuwuity.org> respectively.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
Contributors are expected to follow the [Continuwuity Community Guidelines](continuwuity.org/community/guidelines).

View File

@@ -36,7 +36,7 @@ # Run all checks
prek --all-files
```
Alternatively, you can use [pre-commit](https://pre-commit.com/):
Alternatively, you can use [pre-commit][pre-commit]:
```bash
# Requires python
@@ -52,6 +52,8 @@ # Run all checks manually
These same checks are run in CI via the prek-checks workflow to ensure consistency. These must pass before the PR is merged.
[pre-commit]: https://pre-commit.com/
### Running tests locally
Tests, compilation, and linting can be run with standard Cargo commands:
@@ -109,7 +111,7 @@ ### Writing documentation
### Commit Messages
Continuwuity follows the [Conventional Commits](https://www.conventionalcommits.org/) specification for commit messages. This provides a standardized format that makes the commit history more readable and enables automated tools to generate changelogs.
Continuwuity follows the [Conventional Commits][conventional-commits] specification for commit messages. This provides a standardized format that makes the commit history more readable and enables automated tools to generate changelogs.
The basic structure is:
@@ -168,6 +170,7 @@ ### Creating pull requests
their contributions accepted. This includes users who have been banned from
continuwuity Matrix rooms for Code of Conduct violations.
[conventional-commits]: https://www.conventionalcommits.org/
[issues]: https://forgejo.ellis.link/continuwuation/continuwuity/issues
[continuwuity-matrix]: https://matrix.to/#/#continuwuity:continuwuity.org?via=continuwuity.org&via=ellis.link&via=explodie.org&via=matrix.org
[complement]: https://github.com/matrix-org/complement/
@@ -175,3 +178,32 @@ ### Creating pull requests
[nodejs-download]: https://nodejs.org/en/download
[rspress]: https://rspress.rs/
[documentation.yml]: https://forgejo.ellis.link/continuwuation/continuwuity/src/branch/main/.forgejo/workflows/documentation.yml
#### Writing news fragments
In order to make writing our changelogs easier, we make use of [Towncrier]. Towncrier builds changelogs based on
"news fragments", which are little markdown files in the `changelog.d/` directory that describe individual changes.
When you make a pull request that changes functionality, fixes a bug, or adds documentation, please add a news fragment
describing your change. The file name *MUST* be in the format of `{pull_request_number}.{type}`, where `{type}` is one
of the following:
- `feature` - for new features
- `bugfix` - for bug fixes
- `doc` - for documentation changes
- `misc` - for other changes that don't fit the above categories
For example:
```bash
$ echo "Fixed the quantum flux stabiliser. Contributed by @alice." > changelog.d/42.bugfix
```
(Note: If you want to credit yourself, you should reference your forgejo handle, however links to other platforms are also acceptable.)
When the next release is made, Towncrier will automatically include your news fragment in the changelog.
You can read more about writing news fragments in the [Towncrier tutorial][tt].
[Towncrier]: https://towncrier.readthedocs.io/
[tt]: https://towncrier.readthedocs.io/en/stable/tutorial.html#creating-news-fragments

261
Cargo.lock generated
View File

@@ -72,6 +72,12 @@ dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -92,6 +98,15 @@ dependencies = [
"unicode-width 0.2.2",
]
[[package]]
name = "ansi-width"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219e3ce6f2611d83b51ec2098a12702112c29e57203a6b0a0929b2cddb486608"
dependencies = [
"unicode-width 0.1.14",
]
[[package]]
name = "anstyle"
version = "1.0.14"
@@ -104,6 +119,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "ar_archive_writer"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
dependencies = [
"object",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
@@ -271,8 +295,8 @@ checksum = "5f093eed78becd229346bf859eec0aa4dd7ddde0757287b2b4107a1f09c80002"
[[package]]
name = "async-channel"
version = "2.3.1"
source = "git+https://forgejo.ellis.link/continuwuation/async-channel?rev=92e5e74063bf2a3b10414bcc8a0d68b235644280#92e5e74063bf2a3b10414bcc8a0d68b235644280"
version = "2.5.0"
source = "git+https://forgejo.ellis.link/continuwuation/async-channel?rev=e990f0006b68dc9bace7a3c95fc90b5c4e44948d#e990f0006b68dc9bace7a3c95fc90b5c4e44948d"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
@@ -811,6 +835,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "chumsky"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9"
dependencies = [
"hashbrown 0.14.5",
"stacker",
]
[[package]]
name = "clang-sys"
version = "1.8.1"
@@ -948,6 +982,7 @@ dependencies = [
"conduwuit_service",
"const-str",
"futures",
"lettre",
"log",
"ruma",
"serde-saphyr",
@@ -977,6 +1012,7 @@ dependencies = [
"hyper",
"ipaddress",
"itertools 0.14.0",
"lettre",
"log",
"rand 0.10.0",
"reqwest",
@@ -1022,8 +1058,10 @@ dependencies = [
"hardened_malloc-rs",
"http",
"http-body-util",
"hyper-util",
"ipaddress",
"itertools 0.14.0",
"lettre",
"libc",
"libloading 0.9.0",
"lock_api",
@@ -1134,15 +1172,18 @@ dependencies = [
"const-str",
"either",
"futures",
"governor",
"hickory-resolver",
"http",
"image",
"ipaddress",
"itertools 0.14.0",
"ldap3",
"lettre",
"log",
"loole",
"lru-cache",
"nonzero_ext",
"rand 0.10.0",
"recaptcha-verify",
"regex",
@@ -1250,7 +1291,7 @@ dependencies = [
[[package]]
name = "continuwuity-admin-api"
version = "0.1.0"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=bb12ed288a31a23aa11b10ba0fad22b7f985eb88#bb12ed288a31a23aa11b10ba0fad22b7f985eb88"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=a97b91adcc012ef04991d823b8b5a79c6686ae48#a97b91adcc012ef04991d823b8b5a79c6686ae48"
dependencies = [
"ruma-common",
"serde",
@@ -1313,8 +1354,8 @@ dependencies = [
[[package]]
name = "core_affinity"
version = "0.8.1"
source = "git+https://forgejo.ellis.link/continuwuation/core_affinity_rs?rev=9c8e51510c35077df888ee72a36b4b05637147da#9c8e51510c35077df888ee72a36b4b05637147da"
version = "0.8.3"
source = "git+https://forgejo.ellis.link/continuwuation/core_affinity_rs?rev=7c7a9dea35382743a63837cdd1d977efdb8f1b8a#7c7a9dea35382743a63837cdd1d977efdb8f1b8a"
dependencies = [
"libc",
"num_cpus",
@@ -1685,7 +1726,7 @@ dependencies = [
[[package]]
name = "draupnir-antispam"
version = "0.1.0"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=bb12ed288a31a23aa11b10ba0fad22b7f985eb88#bb12ed288a31a23aa11b10ba0fad22b7f985eb88"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=a97b91adcc012ef04991d823b8b5a79c6686ae48#a97b91adcc012ef04991d823b8b5a79c6686ae48"
dependencies = [
"ruma-common",
"serde",
@@ -1747,6 +1788,22 @@ dependencies = [
"serde",
]
[[package]]
name = "email-encoding"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
dependencies = [
"base64 0.22.1",
"memchr",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -1815,10 +1872,9 @@ dependencies = [
[[package]]
name = "event-listener"
version = "5.3.1"
source = "git+https://forgejo.ellis.link/continuwuation/event-listener?rev=fe4aebeeaae435af60087ddd56b573a2e0be671d#fe4aebeeaae435af60087ddd56b573a2e0be671d"
version = "5.4.1"
source = "git+https://forgejo.ellis.link/continuwuation/event-listener?rev=b2c19bcaf5a0a69c38c034e417bda04a9b991529#b2c19bcaf5a0a69c38c034e417bda04a9b991529"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
@@ -1949,6 +2005,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -2065,6 +2127,12 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-timer"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
[[package]]
name = "futures-util"
version = "0.3.32"
@@ -2155,6 +2223,25 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "governor"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
dependencies = [
"cfg-if",
"futures-sink",
"futures-timer",
"futures-util",
"hashbrown 0.16.1",
"nonzero_ext",
"parking_lot",
"portable-atomic",
"smallvec",
"spinning_top",
"web-time",
]
[[package]]
name = "h2"
version = "0.4.13"
@@ -2219,13 +2306,23 @@ version = "0.1.2+12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "647deb1583b14d160f85f3ff626f20b6edd366e3852c9843b06077388f794cb6"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
"foldhash 0.1.5",
]
[[package]]
@@ -2233,6 +2330,11 @@ name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
]
[[package]]
name = "hdrhistogram"
@@ -2487,13 +2589,12 @@ dependencies = [
[[package]]
name = "hyper-util"
version = "0.1.17"
source = "git+https://forgejo.ellis.link/continuwuation/hyper-util?rev=5886d5292bf704c246206ad72d010d674a7b77d0#5886d5292bf704c246206ad72d010d674a7b77d0"
version = "0.1.20"
source = "git+https://forgejo.ellis.link/continuwuation/hyper-util?rev=09fcd3bf4656c81a8ad573bee410ab2b57f60b86#09fcd3bf4656c81a8ad573bee410ab2b57f60b86"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http",
"http-body",
@@ -2891,6 +2992,37 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]]
name = "lettre"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f"
dependencies = [
"async-trait",
"base64 0.22.1",
"chumsky",
"email-encoding",
"email_address",
"fastrand",
"futures-io",
"futures-util",
"hostname",
"httpdate",
"idna",
"mime",
"nom 8.0.0",
"percent-encoding",
"quoted_printable",
"rustls",
"rustls-native-certs",
"serde",
"socket2 0.6.3",
"tokio",
"tokio-rustls",
"tracing",
"url",
]
[[package]]
name = "libc"
version = "0.2.183"
@@ -3109,7 +3241,7 @@ dependencies = [
[[package]]
name = "meowlnir-antispam"
version = "0.1.0"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=bb12ed288a31a23aa11b10ba0fad22b7f985eb88#bb12ed288a31a23aa11b10ba0fad22b7f985eb88"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=a97b91adcc012ef04991d823b8b5a79c6686ae48#a97b91adcc012ef04991d823b8b5a79c6686ae48"
dependencies = [
"ruma-common",
"serde",
@@ -3273,6 +3405,12 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
[[package]]
name = "nonzero_ext"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]]
name = "noop_proc_macro"
version = "0.3.0"
@@ -4015,6 +4153,16 @@ dependencies = [
"prost",
]
[[package]]
name = "psm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8"
dependencies = [
"ar_archive_writer",
"cc",
]
[[package]]
name = "pulldown-cmark"
version = "0.13.1"
@@ -4119,6 +4267,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "quoted_printable"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73"
[[package]]
name = "r-efi"
version = "5.3.0"
@@ -4391,7 +4545,7 @@ dependencies = [
[[package]]
name = "ruma"
version = "0.10.1"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=bb12ed288a31a23aa11b10ba0fad22b7f985eb88#bb12ed288a31a23aa11b10ba0fad22b7f985eb88"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=a97b91adcc012ef04991d823b8b5a79c6686ae48#a97b91adcc012ef04991d823b8b5a79c6686ae48"
dependencies = [
"assign",
"continuwuity-admin-api",
@@ -4414,7 +4568,7 @@ dependencies = [
[[package]]
name = "ruma-appservice-api"
version = "0.10.0"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=bb12ed288a31a23aa11b10ba0fad22b7f985eb88#bb12ed288a31a23aa11b10ba0fad22b7f985eb88"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=a97b91adcc012ef04991d823b8b5a79c6686ae48#a97b91adcc012ef04991d823b8b5a79c6686ae48"
dependencies = [
"js_int",
"ruma-common",
@@ -4426,7 +4580,7 @@ dependencies = [
[[package]]
name = "ruma-client-api"
version = "0.18.0"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=bb12ed288a31a23aa11b10ba0fad22b7f985eb88#bb12ed288a31a23aa11b10ba0fad22b7f985eb88"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=a97b91adcc012ef04991d823b8b5a79c6686ae48#a97b91adcc012ef04991d823b8b5a79c6686ae48"
dependencies = [
"as_variant",
"assign",
@@ -4449,7 +4603,7 @@ dependencies = [
[[package]]
name = "ruma-common"
version = "0.13.0"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=bb12ed288a31a23aa11b10ba0fad22b7f985eb88#bb12ed288a31a23aa11b10ba0fad22b7f985eb88"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=a97b91adcc012ef04991d823b8b5a79c6686ae48#a97b91adcc012ef04991d823b8b5a79c6686ae48"
dependencies = [
"as_variant",
"base64 0.22.1",
@@ -4481,7 +4635,7 @@ dependencies = [
[[package]]
name = "ruma-events"
version = "0.28.1"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=bb12ed288a31a23aa11b10ba0fad22b7f985eb88#bb12ed288a31a23aa11b10ba0fad22b7f985eb88"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=a97b91adcc012ef04991d823b8b5a79c6686ae48#a97b91adcc012ef04991d823b8b5a79c6686ae48"
dependencies = [
"as_variant",
"indexmap",
@@ -4506,7 +4660,7 @@ dependencies = [
[[package]]
name = "ruma-federation-api"
version = "0.9.0"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=bb12ed288a31a23aa11b10ba0fad22b7f985eb88#bb12ed288a31a23aa11b10ba0fad22b7f985eb88"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=a97b91adcc012ef04991d823b8b5a79c6686ae48#a97b91adcc012ef04991d823b8b5a79c6686ae48"
dependencies = [
"bytes",
"headers",
@@ -4528,7 +4682,7 @@ dependencies = [
[[package]]
name = "ruma-identifiers-validation"
version = "0.9.5"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=bb12ed288a31a23aa11b10ba0fad22b7f985eb88#bb12ed288a31a23aa11b10ba0fad22b7f985eb88"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=a97b91adcc012ef04991d823b8b5a79c6686ae48#a97b91adcc012ef04991d823b8b5a79c6686ae48"
dependencies = [
"js_int",
"thiserror 2.0.18",
@@ -4537,7 +4691,7 @@ dependencies = [
[[package]]
name = "ruma-identity-service-api"
version = "0.9.0"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=bb12ed288a31a23aa11b10ba0fad22b7f985eb88#bb12ed288a31a23aa11b10ba0fad22b7f985eb88"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=a97b91adcc012ef04991d823b8b5a79c6686ae48#a97b91adcc012ef04991d823b8b5a79c6686ae48"
dependencies = [
"js_int",
"ruma-common",
@@ -4547,7 +4701,7 @@ dependencies = [
[[package]]
name = "ruma-macros"
version = "0.13.0"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=bb12ed288a31a23aa11b10ba0fad22b7f985eb88#bb12ed288a31a23aa11b10ba0fad22b7f985eb88"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=a97b91adcc012ef04991d823b8b5a79c6686ae48#a97b91adcc012ef04991d823b8b5a79c6686ae48"
dependencies = [
"cfg-if",
"proc-macro-crate",
@@ -4562,7 +4716,7 @@ dependencies = [
[[package]]
name = "ruma-push-gateway-api"
version = "0.9.0"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=bb12ed288a31a23aa11b10ba0fad22b7f985eb88#bb12ed288a31a23aa11b10ba0fad22b7f985eb88"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=a97b91adcc012ef04991d823b8b5a79c6686ae48#a97b91adcc012ef04991d823b8b5a79c6686ae48"
dependencies = [
"js_int",
"ruma-common",
@@ -4574,7 +4728,7 @@ dependencies = [
[[package]]
name = "ruma-signatures"
version = "0.15.0"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=bb12ed288a31a23aa11b10ba0fad22b7f985eb88#bb12ed288a31a23aa11b10ba0fad22b7f985eb88"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=a97b91adcc012ef04991d823b8b5a79c6686ae48#a97b91adcc012ef04991d823b8b5a79c6686ae48"
dependencies = [
"base64 0.22.1",
"ed25519-dalek",
@@ -4590,8 +4744,8 @@ dependencies = [
[[package]]
name = "rust-librocksdb-sys"
version = "0.39.0+10.5.1"
source = "git+https://forgejo.ellis.link/continuwuation/rust-rocksdb-zaidoon1?rev=61d9d23872197e9ace4a477f2617d5c9f50ecb23#61d9d23872197e9ace4a477f2617d5c9f50ecb23"
version = "0.42.0+10.10.1"
source = "git+https://forgejo.ellis.link/continuwuation/rust-rocksdb-zaidoon1?rev=31fb8f772c7afcdc0061ab6a40cfa3a1be2fccd9#31fb8f772c7afcdc0061ab6a40cfa3a1be2fccd9"
dependencies = [
"bindgen",
"bzip2-sys",
@@ -4607,8 +4761,8 @@ dependencies = [
[[package]]
name = "rust-rocksdb"
version = "0.43.0"
source = "git+https://forgejo.ellis.link/continuwuation/rust-rocksdb-zaidoon1?rev=61d9d23872197e9ace4a477f2617d5c9f50ecb23#61d9d23872197e9ace4a477f2617d5c9f50ecb23"
version = "0.46.0"
source = "git+https://forgejo.ellis.link/continuwuation/rust-rocksdb-zaidoon1?rev=31fb8f772c7afcdc0061ab6a40cfa3a1be2fccd9#31fb8f772c7afcdc0061ab6a40cfa3a1be2fccd9"
dependencies = [
"libc",
"parking_lot",
@@ -4725,16 +4879,16 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rustyline-async"
version = "0.4.6"
source = "git+https://forgejo.ellis.link/continuwuation/rustyline-async?rev=e9f01cf8c6605483cb80b3b0309b400940493d7f#e9f01cf8c6605483cb80b3b0309b400940493d7f"
version = "0.4.9"
source = "git+https://forgejo.ellis.link/continuwuation/rustyline-async?rev=b13aca2cc08d5f78303746cd192d9a03d73e768e#b13aca2cc08d5f78303746cd192d9a03d73e768e"
dependencies = [
"ansi-width",
"crossterm",
"futures-util",
"pin-project",
"thingbuf",
"thiserror 2.0.18",
"unicode-segmentation",
"unicode-width 0.2.2",
]
[[package]]
@@ -5247,6 +5401,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "spinning_top"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
dependencies = [
"lock_api",
]
[[package]]
name = "spki"
version = "0.7.3"
@@ -5263,6 +5426,19 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stacker"
version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013"
dependencies = [
"cc",
"cfg-if",
"libc",
"psm",
"windows-sys 0.59.0",
]
[[package]]
name = "strict"
version = "0.2.0"
@@ -5454,8 +5630,8 @@ dependencies = [
[[package]]
name = "tikv-jemalloc-ctl"
version = "0.6.0"
source = "git+https://forgejo.ellis.link/continuwuation/jemallocator?rev=82af58d6a13ddd5dcdc7d4e91eae3b63292995b8#82af58d6a13ddd5dcdc7d4e91eae3b63292995b8"
version = "0.6.1"
source = "git+https://forgejo.ellis.link/continuwuation/jemallocator?rev=df86ff89d4b1e223b9f7d2dd2fbb7f202da7f554#df86ff89d4b1e223b9f7d2dd2fbb7f202da7f554"
dependencies = [
"libc",
"paste",
@@ -5464,8 +5640,8 @@ dependencies = [
[[package]]
name = "tikv-jemalloc-sys"
version = "0.6.0+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7"
source = "git+https://forgejo.ellis.link/continuwuation/jemallocator?rev=82af58d6a13ddd5dcdc7d4e91eae3b63292995b8#82af58d6a13ddd5dcdc7d4e91eae3b63292995b8"
version = "0.6.1+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7"
source = "git+https://forgejo.ellis.link/continuwuation/jemallocator?rev=df86ff89d4b1e223b9f7d2dd2fbb7f202da7f554#df86ff89d4b1e223b9f7d2dd2fbb7f202da7f554"
dependencies = [
"cc",
"libc",
@@ -5473,8 +5649,8 @@ dependencies = [
[[package]]
name = "tikv-jemallocator"
version = "0.6.0"
source = "git+https://forgejo.ellis.link/continuwuation/jemallocator?rev=82af58d6a13ddd5dcdc7d4e91eae3b63292995b8#82af58d6a13ddd5dcdc7d4e91eae3b63292995b8"
version = "0.6.1"
source = "git+https://forgejo.ellis.link/continuwuation/jemallocator?rev=df86ff89d4b1e223b9f7d2dd2fbb7f202da7f554#df86ff89d4b1e223b9f7d2dd2fbb7f202da7f554"
dependencies = [
"libc",
"tikv-jemalloc-sys",
@@ -6398,6 +6574,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"

View File

@@ -278,7 +278,7 @@ features = [
]
[workspace.dependencies.hyper-util]
version = "=0.1.17"
version = "=0.1.20"
default-features = false
features = [
"server-auto",
@@ -332,7 +332,7 @@ version = "0.4.0"
# used for MPMC channels
[workspace.dependencies.async-channel]
version = "2.3.1"
version = "2.5.0"
[workspace.dependencies.async-trait]
version = "0.1.88"
@@ -344,7 +344,7 @@ version = "0.1.2"
[workspace.dependencies.ruma]
git = "https://forgejo.ellis.link/continuwuation/ruwuma"
#branch = "conduwuit-changes"
rev = "bb12ed288a31a23aa11b10ba0fad22b7f985eb88"
rev = "a97b91adcc012ef04991d823b8b5a79c6686ae48"
features = [
"compat",
"rand",
@@ -388,7 +388,7 @@ features = [
[workspace.dependencies.rust-rocksdb]
git = "https://forgejo.ellis.link/continuwuation/rust-rocksdb-zaidoon1"
rev = "61d9d23872197e9ace4a477f2617d5c9f50ecb23"
rev = "31fb8f772c7afcdc0061ab6a40cfa3a1be2fccd9"
default-features = false
features = [
"multi-threaded-cf",
@@ -451,7 +451,7 @@ version = "0.46.0"
# jemalloc usage
[workspace.dependencies.tikv-jemalloc-sys]
git = "https://forgejo.ellis.link/continuwuation/jemallocator"
rev = "82af58d6a13ddd5dcdc7d4e91eae3b63292995b8"
rev = "df86ff89d4b1e223b9f7d2dd2fbb7f202da7f554"
default-features = false
features = [
"background_threads_runtime_support",
@@ -459,7 +459,7 @@ features = [
]
[workspace.dependencies.tikv-jemallocator]
git = "https://forgejo.ellis.link/continuwuation/jemallocator"
rev = "82af58d6a13ddd5dcdc7d4e91eae3b63292995b8"
rev = "df86ff89d4b1e223b9f7d2dd2fbb7f202da7f554"
default-features = false
features = [
"background_threads_runtime_support",
@@ -467,7 +467,7 @@ features = [
]
[workspace.dependencies.tikv-jemalloc-ctl]
git = "https://forgejo.ellis.link/continuwuation/jemallocator"
rev = "82af58d6a13ddd5dcdc7d4e91eae3b63292995b8"
rev = "df86ff89d4b1e223b9f7d2dd2fbb7f202da7f554"
default-features = false
features = ["use_std"]
@@ -493,7 +493,7 @@ features = [
]
[workspace.dependencies.rustyline-async]
version = "0.4.3"
version = "0.4.9"
default-features = false
[workspace.dependencies.termimad]
@@ -526,7 +526,7 @@ version = "0.4.13"
version = "2.0"
[workspace.dependencies.core_affinity]
version = "0.8.1"
version = "0.8.3"
[workspace.dependencies.libc]
version = "0.2"
@@ -550,15 +550,25 @@ version = "0.12.0"
default-features = false
features = ["sync", "tls-rustls", "rustls-provider"]
[workspace.dependencies.resolv-conf]
version = "0.7.5"
[workspace.dependencies.yansi]
version = "1.0.1"
[workspace.dependencies.askama]
version = "0.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
#
@@ -571,25 +581,25 @@ version = "0.15.0"
# adds event for CTRL+\: https://forgejo.ellis.link/continuwuation/rustyline-async/src/branch/main/.patchy/0001-add-event-for-ctrl.patch
[patch.crates-io.rustyline-async]
git = "https://forgejo.ellis.link/continuwuation/rustyline-async"
rev = "e9f01cf8c6605483cb80b3b0309b400940493d7f"
rev = "b13aca2cc08d5f78303746cd192d9a03d73e768e"
# adds LIFO queue scheduling; this should be updated with PR progress.
[patch.crates-io.event-listener]
git = "https://forgejo.ellis.link/continuwuation/event-listener"
rev = "fe4aebeeaae435af60087ddd56b573a2e0be671d"
rev = "b2c19bcaf5a0a69c38c034e417bda04a9b991529"
[patch.crates-io.async-channel]
git = "https://forgejo.ellis.link/continuwuation/async-channel"
rev = "92e5e74063bf2a3b10414bcc8a0d68b235644280"
rev = "e990f0006b68dc9bace7a3c95fc90b5c4e44948d"
# adds affinity masks for selecting more than one core at a time
[patch.crates-io.core_affinity]
git = "https://forgejo.ellis.link/continuwuation/core_affinity_rs"
rev = "9c8e51510c35077df888ee72a36b4b05637147da"
rev = "7c7a9dea35382743a63837cdd1d977efdb8f1b8a"
# reverts hyperium#148 conflicting with our delicate federation resolver hooks
[patch.crates-io.hyper-util]
git = "https://forgejo.ellis.link/continuwuation/hyper-util"
rev = "5886d5292bf704c246206ad72d010d674a7b77d0"
rev = "09fcd3bf4656c81a8ad573bee410ab2b57f60b86"
#
# Our crates
@@ -919,7 +929,6 @@ fn_to_numeric_cast_any = "warn"
format_push_string = "warn"
get_unwrap = "warn"
impl_trait_in_params = "warn"
let_underscore_untyped = "warn"
lossy_float_literal = "warn"
mem_forget = "warn"
missing_assert_message = "warn"

View File

@@ -1,4 +1,3 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
@@ -187,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2023 June
Copyright 2023 Continuwuity Team and contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

View File

@@ -1,23 +0,0 @@
[book]
title = "continuwuity"
description = "continuwuity is a community continuation of the conduwuit Matrix homeserver, written in Rust."
language = "en"
authors = ["The continuwuity Community"]
text-direction = "ltr"
src = "docs"
[build]
build-dir = "public"
create-missing = true
extra-watch-dirs = ["debian", "docs"]
[rust]
edition = "2024"
[output.html]
edit-url-template = "https://forgejo.ellis.link/continuwuation/continuwuity/src/branch/main/{path}"
git-repository-url = "https://forgejo.ellis.link/continuwuation/continuwuity"
git-repository-icon = "fa-git-alt"
[output.html.search]
limit-results = 15

View File

@@ -0,0 +1 @@
Added support for associating email addresses with accounts, requiring email addresses for registration, and resetting passwords via email. Contributed by @ginger

View File

@@ -0,0 +1 @@
Fixed room alias deletion so removing one local alias no longer removes other aliases from room alias listings.

1
changelog.d/1429.doc Normal file
View File

@@ -0,0 +1 @@
Added Testing and Troubleshooting instructions for Livekit documentation. Contributed by @stratself.

View File

@@ -0,0 +1 @@
Stripped `join_authorised_via_users_server` from json if user is already in room (@partha:cxy.run)

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

@@ -0,0 +1 @@
Fixed internal server errors for fetching thumbnails. Contributed by @PerformativeJade

View File

@@ -95,6 +95,10 @@
# engine API. To use this, set a database backup path that continuwuity
# can write to.
#
# If you are using systemd, you will need to add the path to
# ReadWritePaths in the service file, preferably via a drop-in file
# through `systemctl edit`.
#
# For more information, see:
# https://continuwuity.org/maintenance.html#backups
#
@@ -2037,3 +2041,41 @@
# web->synapseHTTPAntispam->authorization
#
#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

View File

@@ -10,7 +10,7 @@ RUN rm -f /etc/apt/apt.conf.d/docker-clean
# Match Rustc version as close as possible
# rustc -vV
ARG LLVM_VERSION=20
ARG LLVM_VERSION=21
# ENV RUSTUP_TOOLCHAIN=${RUST_VERSION}
# Install repo tools
@@ -48,7 +48,7 @@ EOF
# Developer tool versions
# renovate: datasource=github-releases depName=cargo-bins/cargo-binstall
ENV BINSTALL_VERSION=1.17.7
ENV BINSTALL_VERSION=1.17.8
# renovate: datasource=github-releases depName=psastras/sbom-rs
ENV CARGO_SBOM_VERSION=0.9.1
# renovate: datasource=crate depName=lddtree

View File

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

View File

@@ -10,4 +10,4 @@ # Calls
For either one to work correctly, you have to do some additional setup.
- For legacy calls to work, you need to set up a TURN/STUN server. [Read the TURN guide for tips on how to set up coturn](./calls/turn.mdx)
- For MatrixRTC / Element Call to work, you have to set up the LiveKit backend (foci). LiveKit also uses TURN/STUN to increase reliability, so you might want to configure your TURN server first. [Read the LiveKit guide](./calls/livekit.mdx)
- For MatrixRTC / Element Call to work, you have to set up the LiveKit backend (foci). LiveKit also uses TURN/STUN to increase reliability - you can set up its built-in TURN server, or integrate with an existing one. [Read the LiveKit guide](./calls/livekit.mdx)

View File

@@ -4,6 +4,10 @@ # Matrix RTC/Element Call Setup
This guide assumes that you are using docker compose for deployment. LiveKit only provides Docker images.
:::
:::tip
You can find help setting up MatrixRTC in our dedicated room - [#matrixrtc:continuwuity.org](https://matrix.to/#/%23matrixrtc%3Acontinuwuity.org)
:::
## Instructions
### 1. Domain
@@ -14,17 +18,21 @@ ### 1. Domain
### 2. Services
Using LiveKit with Matrix requires two services - Livekit itself, and a service (`lk-jwt-service`) that grants Matrix users permission to connect to it.
Using LiveKit with Matrix requires two services - LiveKit itself, and a service (`lk-jwt-service`) that grants Matrix users permission to connect to it.
You must generate a key and secret to allow the Matrix service to authenticate with LiveKit. `LK_MATRIX_KEY` should be around 20 random characters, and `LK_MATRIX_SECRET` should be around 64. Remember to replace these with the actual values!
:::tip Generating the secrets
LiveKit provides a utility to generate secure random keys
```bash
docker run --rm livekit/livekit-server:latest generate-keys
~$ docker run --rm livekit/livekit-server:latest generate-keys
API Key: APIUxUnMnSkuFWV
API Secret: t93ZVjPeoEdyx7Wbet3kG4L3NGZIZVEFvqe0UuiVc22A
```
:::
Create a `docker-compose.yml` file as following:
```yaml
services:
lk-jwt-service:
@@ -32,10 +40,11 @@ ### 2. Services
container_name: lk-jwt-service
environment:
- LIVEKIT_JWT_BIND=:8081
- LIVEKIT_URL=wss://livekit.example.com
- LIVEKIT_KEY=LK_MATRIX_KEY
- LIVEKIT_SECRET=LK_MATRIX_SECRET
- LIVEKIT_FULL_ACCESS_HOMESERVERS=example.com
- LIVEKIT_URL=wss://livekit.example.com # your LiveKit domain
- LIVEKIT_FULL_ACCESS_HOMESERVERS=example.com # your server_name
# Replace these with the generated values as above
- LIVEKIT_KEY=LK_MATRIX_KEY # APIUxUnMnSkuFWV
- LIVEKIT_SECRET=LK_MATRIX_SECRET # t93ZVjPeoEdyx7Wbet3kG4L3NGZIZVEFvqe0UuiVc22A
restart: unless-stopped
ports:
- "8081:8081"
@@ -70,6 +79,8 @@ # - "50100-50200:50100-50200/udp"
enable_loopback_candidate: false
keys:
LK_MATRIX_KEY: LK_MATRIX_SECRET
# replace these with your key-secret pair. Example:
# APIUxUnMnSkuFWV: t93ZVjPeoEdyx7Wbet3kG4L3NGZIZVEFvqe0UuiVc22A
```
#### Firewall hints
@@ -95,7 +106,7 @@ ### 4. Configure your Reverse Proxy
Reverse proxies can be configured in many different ways - so we can't provide a step by step for this.
By default, all routes should be forwarded to Livekit with the exception of the following path prefixes, which should be forwarded to the JWT/Authentication service:
All paths should be forwarded to LiveKit by default, with the exception of the following path prefixes, which should be forwarded to the JWT/Authentication service:
- `/sfu/get`
- `/healthz`
@@ -104,7 +115,7 @@ ### 4. Configure your Reverse Proxy
<details>
<summary>Example caddy config</summary>
```
matrix-rtc.example.com {
livekit.example.com {
# for lk-jwt-service
@lk-jwt-service path /sfu/get* /healthz* /get_token*
@@ -122,7 +133,7 @@ ### 4. Configure your Reverse Proxy
<summary>Example nginx config</summary>
```
server {
server_name matrix-rtc.example.com;
server_name livekit.example.com;
# for lk-jwt-service
location ~ ^/(sfu/get|healthz|get_token) {
@@ -133,7 +144,7 @@ ### 4. Configure your Reverse Proxy
proxy_buffering off;
}
# for livekit
# for LiveKit
location / {
proxy_pass http://127.0.0.1:7880$request_uri;
proxy_set_header X-Forwarded-For $remote_addr;
@@ -173,44 +184,11 @@ ### 6. Start Everything
Start up the services using your usual method - for example `docker compose up -d`.
## Additional Configuration
## Additional TURN configuration
### TURN Integration
### Using LiveKit's built-in TURN server
If you've already set up coturn, there may be a port clash between the two services. To fix this, make sure the `min-port` and `max-port` for coturn so it doesn't overlap with LiveKit's range:
```ini
min-port=50201
max-port=65535
```
To improve LiveKit's reliability, you can configure it to use your coturn server.
Generate a long random secret for LiveKit, and add it to your coturn config under the `static-auth-secret` option. You can add as many secrets as you want - so set a different one for each thing using your TURN server.
Then configure livekit, making sure to replace `COTURN_SECRET`:
```yaml
# livekit.yaml
rtc:
turn_servers:
- host: coturn.ellis.link
port: 3478
protocol: tcp
secret: "COTURN_SECRET"
- host: coturn.ellis.link
port: 5349
protocol: tls # Only if you've set up TLS in your coturn
secret: "COTURN_SECRET"
- host: coturn.ellis.link
port: 3478
protocol: udp
secret: "COTURN_SECRET"
```
## LiveKit's built in TURN server
Livekit includes a built in TURN server which can be used in place of an external option. This TURN server will only work with Livekit, so you can't use it for legacy Matrix calling - or anything else.
LiveKit includes a built-in TURN server which can be used in place of an external option. This TURN server will only work with LiveKit, so you can't use it for legacy Matrix calling or anything else.
If you don't want to set up a separate TURN server, you can enable this with the following changes:
@@ -221,20 +199,175 @@ ### add this to livekit.yaml ###
udp_port: 3478
relay_range_start: 50300
relay_range_end: 50400
domain: matrix-rtc.example.com
domain: livekit.example.com
```
```yaml
### Add these to docker-compose ###
- "3478:3478/udp"
- "50300-50400:50300-50400/udp"
### add these to livekit's docker-compose ###
ports:
- "3478:3478/udp"
- "50300-50400:50300-50400/udp"
### if you're using `network_mode: host`, you can skip this part
```
### Related Documentation
Recreate the LiveKit container (with `docker-compose up -d livekit`) to apply these changes. Remember to allow the new `3478/udp` and `50100:50200/udp` ports through your firewall.
- [LiveKit GitHub](https://github.com/livekit/livekit)
- [LiveKit Connection Tester](https://livekit.io/connection-test) - use with the token returned by `/sfu/get` or `/get_token`
- [MatrixRTC proposal](https://half-shot.github.io/msc-crafter/#msc/4143)
- [Synapse documentation](https://github.com/element-hq/element-call/blob/livekit/docs/self-hosting.md)
- [Community guide](https://tomfos.tr/matrix/livekit/)
- [Community guide](https://blog.kimiblock.top/2024/12/24/hosting-element-call/)
### Integration with an external TURN server
If you've already [set up coturn](./turn), you can configure Livekit to use it.
:::tip Avoid port clashes between the two services
Before continuing, make sure coturn's `min-port` and `max-port` do not overlap with LiveKit's port range:
```ini
# in your coturn.conf
min-port=50201
max-port=65535
```
:::
Generate a long random secret for LiveKit, and add it to your coturn config under the `static-auth-secret` option. You can add as many secrets as you want, so set a different one for LiveKit to use.
Then configure LiveKit, making sure to replace `COTURN_SECRET` with the one you generated:
```yaml
# livekit.yaml
rtc:
turn_servers:
- host: coturn.example.com
port: 3478
protocol: udp
secret: "COTURN_SECRET"
- host: coturn.example.com
port: 3478
protocol: tcp
secret: "COTURN_SECRET"
- host: coturn.example.com
port: 5349
protocol: tls # Only if you have already set up TLS in your coturn
secret: "COTURN_SECRET"
```
Restart LiveKit and coturn to apply these changes.
## Testing
To test that LiveKit is successfully integrated with Continuwuity, you will need to replicate its [Token Exchange Flow](https://github.com/element-hq/lk-jwt-service#%EF%B8%8F-how-it-works--token-exchange-flow).
First, you will need an access token for your current login session. These can be found in your client's settings or obtained via [this website](https://timedout.uk/mxtoken.html).
Then, using that token, request another OpenID token for use with the lk-jwt-service:
```bash
~$ curl -X POST -H "Authorization: Bearer <session-access-token>" \
https://matrix.example.com/_matrix/client/v3/user/@user:example.com/openid/request_token
{"access_token":"<openid_access_token>","token_type":"Bearer","matrix_server_name":"example.com","expires_in":3600}
```
Next, create a `payload.json` file with the following content:
<details>
<summary>`payload.json`</summary>
```json
{
"room_id": "abc",
"slot_id": "xyz",
"openid_token": {
"matrix_server_name": "example.com",
"access_token": "<openid_access_token>",
"token_type": "Bearer"
},
"member": {
"id": "xyz",
"claimed_device_id": "DEVICEID",
"claimed_user_id": "@user:example.com"
}
}
```
Replace `matrix_server_name` and `claimed_user_id` with your information, and `<openid_access_token>` with the one you got from the previous step. Other values can be left as-is.
</details>
You can then send this payload to the lk-jwt-service:
```bash
~$ curl -X POST -d @payload.json https://livekit.example.com/get_token
{"url":"wss://livekit.example.com","jwt":"a_really_really_long_string"}
```
The lk-jwt-service will, after checking against Continuwuity, answer with a `jwt` token to create a LiveKit media room. Use this token to test at the [LiveKit Connection Tester](https://livekit.io/connection-test). If everything works there, then you have set up LiveKit successfully!
## Troubleshooting
To debug any issues, you can place a call or redo the Testing instructions, and check the container logs for any specific errors. Use `docker-compose logs --follow` to follow them in real-time.
### Common errors in Element Call UI
- `MISSING_MATRIX_RTC_FOCUS`: LiveKit is missing from Continuwuity's config file
- "Waiting for media" popup always showing: a LiveKit URL has been configured in Continuwuity, but your client cannot connect to it for some reason
### Docker loopback networking issues
Some distros do not allow Docker containers to connect to its host's public IP by default. This would cause `lk-jwt-service` to fail connecting to `livekit` or `continuwuity` on the same host. As a result, you would see connection refused/connection timeouts log entries in the JWT service, even when `LIVEKIT_URL` has been configured correctly.
To alleviate this, you can try one of the following workarounds:
- Use `network_mode: host` for the `lk-jwt-service` container (instead of the default bridge networking).
- Add an `extra_hosts` file mapping livekit's (and continuwuity's) domain name to a localhost address:
```diff
# in docker-compose.yaml
services:
lk-jwt-service:
...
+ extra_hosts:
+ - "livekit.example.com:127.0.0.1"
+ - "matrix.example.com:127.0.0.1"
```
- (**untested, use at your own risk**) Implement an iptables workaround as shown [here](https://forums.docker.com/t/unable-to-connect-to-host-service-from-inside-docker-container/145749/6).
After implementing the changes and restarting your compose, you can test whether the connection works by cURLing from a sidecar container:
```bash
~$ docker run --rm --net container:lk-jwt-service docker.io/curlimages/curl https://livekit.example.com
OK
```
### Workaround for non-federating servers
When deploying on servers with federation disabled (`allow_federation = false`), LiveKit will fail as it can't fetch the required [OpenID endpoint](https://spec.matrix.org/v1.17/server-server-api/#get_matrixfederationv1openiduserinfo) via federation paths.
As a workaround, you can enable federation, but forbid all remote servers via the following config parameters:
```toml
### in your continuwuity.toml file ###
allow_federation = true
forbidden_remote_server_names = [".*"]
```
Subscribe to issue [!1440](https://forgejo.ellis.link/continuwuation/continuwuity/issues/1440) for future updates on this matter.
## Related Documentation
Guides:
- [Element Call self-hosting documentation](https://github.com/element-hq/element-call/blob/livekit/docs/self-hosting.md)
- [Community guide with overview of LiveKit's mechanisms](https://tomfos.tr/matrix/livekit/)
- [Community guide using systemd](https://blog.kimiblock.top/2024/12/24/hosting-element-call/)
Specifications:
- [MatrixRTC proposal](https://github.com/matrix-org/matrix-spec-proposals/pull/4143)
- [LiveKit proposal](https://github.com/matrix-org/matrix-spec-proposals/pull/4195)
Source code:
- [Element Call](https://github.com/element-hq/element-call)
- [lk-jwt-service](https://github.com/element-hq/lk-jwt-service)
- [LiveKit server](https://github.com/livekit/livekit)

View File

@@ -1,17 +1,12 @@
# Continuwuity Community Guidelines
Welcome to the Continuwuity commuwunity! We're excited to have you here. Continuwuity is a
continuation of the conduwuit homeserver, which in turn is a hard-fork of the Conduit homeserver,
aimed at making Matrix more accessible and inclusive for everyone.
Welcome to the Continuwuity commuwunity! We're excited to have you here.
This space is dedicated to fostering a positive, supportive, and welcoming environment for everyone.
These guidelines apply to all Continuwuity spaces, including our Matrix rooms and any other
community channels that reference them. We've written these guidelines to help us all create an
environment where everyone feels safe and respected.
Our project aims to make Matrix more accessible and inclusive for everyone. To that end, we are dedicated to fostering a positive, supportive, safe and welcoming environment for our community.
For code and contribution guidelines, please refer to the
[Contributor's Covenant](https://forgejo.ellis.link/continuwuation/continuwuity/src/branch/main/CODE_OF_CONDUCT.md).
Below are additional guidelines specific to the Continuwuity community.
These guidelines apply to all Continuwuity spaces, including our Matrix rooms and code forge.
Our community spaces are intended for individuals aged 16 or over, because we expect maturity and respect from our community members.
## Our Values and Expected Behaviors
@@ -29,17 +24,21 @@ ## Our Values and Expected Behaviors
3. **Communicate Clearly and Kindly**: Our community includes neurodivergent individuals and those
who may not appreciate sarcasm or subtlety. Communicate clearly and kindly. Avoid ambiguity and
ensure your messages can be easily understood by all. Avoid placing the burden of education on
ensure your messages can be easily understood by all.
4. **Be Considerate and Proactive**: Not everyone has the same time, resource and experience to spare.
Don't expect others to give up their time and labour for you; be thankful for what you have already been given.
Avoid placing the burden of education on
marginalized groups; please make an effort to look into your questions before asking others for
detailed explanations.
4. **Be Open to Improving Inclusivity**: Actively participate in making our community more inclusive.
5. **Be Engaged and Open-Minded**: Actively participate in making our community more inclusive.
Report behaviour that contradicts these guidelines (see Reporting and Enforcement below) and be
open to constructive feedback aimed at improving our community. Understand that discussing
negative experiences can be emotionally taxing; focus on the message, not the tone.
5. **Commit to Our Values**: Building an inclusive community requires ongoing effort from everyone.
Recognise that addressing bias and discrimination is a continuous process that needs commitment
6. **Commit to Our Values**: Building an inclusive community requires ongoing effort from everyone.
Recognise that creating a welcoming and open community is a continuous process that needs commitment
and action from all members.
## Unacceptable Behaviors
@@ -72,36 +71,6 @@ ## Unacceptable Behaviors
This is not an exhaustive list. Any behaviour that makes others feel unsafe or unwelcome may be
subject to enforcement action.
## Matrix Community
These Community Guidelines apply to the entire
[Continuwuity Matrix Space](https://matrix.to/#/#space:continuwuity.org?via=continuwuity.org&via=ellis.link&via=explodie.org&via=matrix.org) and its rooms, including:
### [#continuwuity:continuwuity.org](https://matrix.to/#/#continuwuity:continuwuity.org?via=continuwuity.org&via=ellis.link&via=explodie.org&via=matrix.org)
This room is for support and discussions about Continuwuity. Ask questions, share insights, and help
each other out while adhering to these guidelines.
We ask that this room remain focused on the Continuwuity software specifically: the team are
typically happy to engage in conversations about related subjects in the off-topic room.
### [#offtopic:continuwuity.org](https://matrix.to/#/#offtopic:continuwuity.org?via=continuwuity.org&via=ellis.link&via=explodie.org&via=matrix.org)
For off-topic community conversations about any subject. While this room allows for a wide range of
topics, the same guidelines apply. Please keep discussions respectful and inclusive, and avoid
divisive or stressful subjects like specific country/world politics unless handled with exceptional
care and respect for diverse viewpoints.
General topics, such as world events, are welcome as long as they follow the guidelines. If a member
of the team asks for the conversation to end, please respect their decision.
### [#dev:continuwuity.org](https://matrix.to/#/#dev:continuwuity.org?via=continuwuity.org&via=ellis.link&via=explodie.org&via=matrix.org)
This room is dedicated to discussing active development of Continuwuity, including ongoing issues or
code development. Collaboration here must follow these guidelines, and please consider raising
[an issue](https://forgejo.ellis.link/continuwuation/continuwuity/issues) on the repository to help
track progress.
## Reporting and Enforcement
We take these Community Guidelines seriously to protect our community members. If you witness or
@@ -114,6 +83,7 @@ ## Reporting and Enforcement
will immediately alert all available moderators.
* **Direct Message:** If you're not comfortable raising the issue publicly, please send a direct
message (DM) to one of the room moderators.
* **Email**: Please email Jade and/or Nex at `jade@continuwuity.org` and `nex@continuwuity.org` respectively, or email `team@continuwuity.org`.
Reports will be handled with discretion. We will investigate promptly and thoroughly.

View File

@@ -1 +0,0 @@
../CONTRIBUTING.md

View File

@@ -1,203 +0,0 @@
# Contributing guide
This page is about contributing to Continuwuity. The
[development](./index.mdx) and [code style guide](./code_style.mdx) pages may be of interest for you as well.
If you would like to work on an [issue][issues] that is not assigned, preferably
ask in the Matrix room first at [#continuwuity:continuwuity.org][continuwuity-matrix],
and comment on it.
### Code Style
Please review and follow the [code style guide](./code_style) for formatting, linting, naming conventions, and other code standards.
### Pre-commit Checks
Continuwuity uses pre-commit hooks to enforce various coding standards and catch common issues before they're committed. These checks include:
- Code formatting and linting
- Typo detection (both in code and commit messages)
- Checking for large files
- Ensuring proper line endings and no trailing whitespace
- Validating YAML, JSON, and TOML files
- Checking for merge conflicts
You can run these checks locally by installing [prefligit](https://github.com/j178/prefligit):
```bash
# Requires UV: https://docs.astral.sh/uv/getting-started/installation/
# Mac/linux: curl -LsSf https://astral.sh/uv/install.sh | sh
# 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
prefligit install
# Run all checks
prefligit --all-files
```
Alternatively, you can use [pre-commit](https://pre-commit.com/):
```bash
# Requires python
# Install pre-commit
pip install pre-commit
# Install the hooks
pre-commit install
# Run all checks manually
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.
### Running tests locally
Tests, compilation, and linting can be run with standard Cargo commands:
```bash
# Run tests
cargo test
# Check compilation
cargo check --workspace --features full
# Run lints
cargo clippy --workspace --features full
# Auto-fix: cargo clippy --workspace --features full --fix --allow-staged;
# Format code (must use nightly)
cargo +nightly fmt
```
### Matrix tests
Continuwuity uses [Complement][complement] for Matrix protocol compliance testing. Complement tests are run manually by developers, and documentation on how to run these tests locally is currently being developed.
If your changes are done to fix Matrix tests, please note that in your pull request. If more Complement tests start failing from your changes, please review the logs and determine if they're intended or not.
[Sytest][sytest] is currently unsupported.
### Writing documentation
Continuwuity's website uses [`mdbook`][mdbook] and is deployed via CI using Cloudflare Pages
in the [`documentation.yml`][documentation.yml] workflow file. All documentation is in the `docs/`
directory at the top level.
To build the documentation locally:
1. Install mdbook if you don't have it already:
```bash
cargo install mdbook # or cargo binstall, or another method
```
2. Build the documentation:
```bash
mdbook build
```
The output of the mdbook generation is in `public/`. You can open the HTML files directly in your browser without needing a web server.
### Commit Messages
Continuwuity follows the [Conventional Commits](https://www.conventionalcommits.org/) specification for commit messages. This provides a standardized format that makes the commit history more readable and enables automated tools to generate changelogs.
The basic structure is:
```
<type>[(optional scope)]: <description>
[optional body]
[optional footer(s)]
```
The allowed types for commits are:
- `fix`: Bug fixes
- `feat`: New features
- `docs`: Documentation changes
- `style`: Changes that don't affect the meaning of the code (formatting, etc.)
- `refactor`: Code changes that neither fix bugs nor add features
- `perf`: Performance improvements
- `test`: Adding or fixing tests
- `build`: Changes to the build system or dependencies
- `ci`: Changes to CI configuration
- `chore`: Other changes that don't modify source or test files
Examples:
```
feat: add user authentication
fix(database): resolve connection pooling issue
docs: update installation instructions
```
The project uses the `committed` hook to validate commit messages in pre-commit. This ensures all commits follow the conventional format.
### Creating pull requests
Please try to keep contributions to the Forgejo Instance. While the mirrors of continuwuity
allow for pull/merge requests, there is no guarantee the maintainers will see them in a timely
manner. Additionally, please mark WIP or unfinished or incomplete PRs as drafts.
This prevents us from having to ping once in a while to double check the status
of it, especially when the CI completed successfully and everything so it
*looks* done.
Before submitting a pull request, please ensure:
1. Your code passes all CI checks (formatting, linting, typo detection, etc.). Run pre-commit for this.
2. Your code follows the [code style guide](./code_style)
3. Your commit messages follow the conventional commits format
4. Tests are added for new functionality
5. Documentation is updated if needed
6. You have written a [news fragment](#writing-news-fragments) for your changes
Direct all PRs/MRs to the `main` branch.
By sending a pull request or patch, you are agreeing that your changes are
allowed to be licenced under the Apache-2.0 licence and all of your conduct is
in line with the Contributor's Covenant, and continuwuity's Code of Conduct.
Contribution by users who violate either of these code of conducts may not have
their contributions accepted. This includes users who have been banned from
continuwuity Matrix rooms for Code of Conduct violations.
[issues]: https://forgejo.ellis.link/continuwuation/continuwuity/issues
[continuwuity-matrix]: https://matrix.to/#/#continuwuity:continuwuity.org?via=continuwuity.org&via=ellis.link&via=explodie.org&via=matrix.org
[complement]: https://github.com/matrix-org/complement/
[sytest]: https://github.com/matrix-org/sytest/
[mdbook]: https://rust-lang.github.io/mdBook/
[documentation.yml]: https://forgejo.ellis.link/continuwuation/continuwuity/src/branch/main/.forgejo/workflows/documentation.yml
#### Writing news fragments
In order to make writing our changelogs easier, we make use of [Towncrier]. Towncrier builds changelogs based on
"news fragments", which are little markdown files in the `changelog.d/` directory that describe individual changes.
When you make a pull request that changes functionality, fixes a bug, or adds documentation, please add a news fragment
describing your change. The file name *MUST* be in the format of `{pull_request_number}.{type}`, where `{type}` is one
of the following:
- `feature` - for new features
- `bugfix` - for bug fixes
- `doc` - for documentation changes
- `misc` - for other changes that don't fit the above categories
For example:
```bash
$ echo "Fixed the quantum flux stabiliser. Contributed by @alice." > changelog.d/42.bugfix
```
(Note: If you want to credit yourself, you should reference your forgejo handle, however links to other platforms are also acceptable.)
When the next release is made, Towncrier will automatically include your news fragment in the changelog.
You can read more about writing news fragments in the [Towncrier tutorial][tt].
[Towncrier]: https://towncrier.readthedocs.io/
[tt]: https://towncrier.readthedocs.io/en/stable/tutorial.html#creating-news-fragments

View File

@@ -0,0 +1 @@
../../CONTRIBUTING.md

View File

@@ -1,6 +1,6 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"$id": "https://continwuity.org/schema/announcements.schema.json",
"$id": "https://continuwuity.org/schema/announcements.schema.json",
"type": "object",
"properties": {
"announcements": {

View File

@@ -130,6 +130,10 @@ ## `!admin debug database-files`
List database files
## `!admin debug send-test-email`
Send a test email to the invoking admin's email address
## `!admin debug tester`
Developer test stubs

View File

@@ -12,6 +12,24 @@ ## `!admin users reset-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`
Deactivate a user

View File

@@ -15,7 +15,7 @@
file = inputs.self + "/rust-toolchain.toml";
# See also `rust-toolchain.toml`
sha256 = "sha256-SJwZ8g0zF2WrKDVmHrVG3pD2RGoQeo24MEXnNx5FyuI=";
sha256 = "sha256-sqSWJDUxc+zaz1nBWMAJKTAGBuGWP25GCftIOlCEAtA=";
};
in
{

View File

@@ -17,6 +17,7 @@
# basic nix shell containing all things necessary to build continuwuity in all flavors manually (on x86_64-linux)
devShells.default = uwulib.build.craneLib.devShell {
packages = [
pkgs.nodejs
pkgs.pkg-config
pkgs.liburing
pkgs.rust-jemalloc-sys-unprefixed

218
package-lock.json generated
View File

@@ -12,13 +12,13 @@
"@rspress/core": "^2.0.0",
"@rspress/plugin-client-redirects": "^2.0.0",
"@rspress/plugin-sitemap": "^2.0.0",
"typescript": "^5.9.3"
"typescript": "^6.0.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -28,9 +28,9 @@
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -106,27 +106,31 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz",
"integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.5.0",
"@emnapi/runtime": "^1.5.0",
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@rsbuild/core": {
"version": "2.0.0-beta.6",
"resolved": "https://registry.npmjs.org/@rsbuild/core/-/core-2.0.0-beta.6.tgz",
"integrity": "sha512-DUBhUzvzj6xlGUAHTTipFskSuZmVEuTX7lGU+ToPuo8n3bsQrWn/UBOEQAd45g66k7QfXadoZ/v7eodQErpvGQ==",
"version": "2.0.0-beta.11",
"resolved": "https://registry.npmjs.org/@rsbuild/core/-/core-2.0.0-beta.11.tgz",
"integrity": "sha512-IBbQx7SrnSpD7j2p2qyq3qDxoqmG4E6lcflTpbBitX6iUrzpVRQbP4rktXZ2iuY7ph9+FtUK/SVAVA+Ocm3Nig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rspack/core": "2.0.0-beta.3",
"@swc/helpers": "^0.5.19"
"@rspack/core": "2.0.0-beta.9",
"@swc/helpers": "^0.5.20"
},
"bin": {
"rsbuild": "bin/rsbuild.js"
@@ -163,28 +167,28 @@
}
},
"node_modules/@rspack/binding": {
"version": "2.0.0-beta.3",
"resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-2.0.0-beta.3.tgz",
"integrity": "sha512-GSj+d8AlLs1oElhYq32vIN/eAsxWG9jy0EiNgSxWTt5Gdamv87kcvsV4jwfWIjlltdnBIJgey2RnU+hDZlTAvw==",
"version": "2.0.0-beta.9",
"resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-2.0.0-beta.9.tgz",
"integrity": "sha512-QgkOvzl6BJc4Vg5eaY9r7MkHNfXvVZPgTIeYkdBEOYPowdyCLhlG9vH7QltqLKP9KDNel70YIeMyUrpTqez01w==",
"dev": true,
"license": "MIT",
"optionalDependencies": {
"@rspack/binding-darwin-arm64": "2.0.0-beta.3",
"@rspack/binding-darwin-x64": "2.0.0-beta.3",
"@rspack/binding-linux-arm64-gnu": "2.0.0-beta.3",
"@rspack/binding-linux-arm64-musl": "2.0.0-beta.3",
"@rspack/binding-linux-x64-gnu": "2.0.0-beta.3",
"@rspack/binding-linux-x64-musl": "2.0.0-beta.3",
"@rspack/binding-wasm32-wasi": "2.0.0-beta.3",
"@rspack/binding-win32-arm64-msvc": "2.0.0-beta.3",
"@rspack/binding-win32-ia32-msvc": "2.0.0-beta.3",
"@rspack/binding-win32-x64-msvc": "2.0.0-beta.3"
"@rspack/binding-darwin-arm64": "2.0.0-beta.9",
"@rspack/binding-darwin-x64": "2.0.0-beta.9",
"@rspack/binding-linux-arm64-gnu": "2.0.0-beta.9",
"@rspack/binding-linux-arm64-musl": "2.0.0-beta.9",
"@rspack/binding-linux-x64-gnu": "2.0.0-beta.9",
"@rspack/binding-linux-x64-musl": "2.0.0-beta.9",
"@rspack/binding-wasm32-wasi": "2.0.0-beta.9",
"@rspack/binding-win32-arm64-msvc": "2.0.0-beta.9",
"@rspack/binding-win32-ia32-msvc": "2.0.0-beta.9",
"@rspack/binding-win32-x64-msvc": "2.0.0-beta.9"
}
},
"node_modules/@rspack/binding-darwin-arm64": {
"version": "2.0.0-beta.3",
"resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-2.0.0-beta.3.tgz",
"integrity": "sha512-QebSomLWlCbFsC0sfDuGqLJtkgyrnr38vrCepWukaAXIY4ANy5QB49LDKdLpVv6bKlC95MpnW37NvSNWY5GMYA==",
"version": "2.0.0-beta.9",
"resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-2.0.0-beta.9.tgz",
"integrity": "sha512-9Aao24b+lrVGG25itl2c7e6HK6eNH5J5ao1Uq5UoSwSJZOxRPuY+QlHIvE2tyt833Ly9qcT1J7os2AIUNlF6Vw==",
"cpu": [
"arm64"
],
@@ -196,9 +200,9 @@
]
},
"node_modules/@rspack/binding-darwin-x64": {
"version": "2.0.0-beta.3",
"resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-2.0.0-beta.3.tgz",
"integrity": "sha512-EysmBq+sz+Ph0bu0gXpU1uuZG9gXgjqY+w3MJel+ieTFyQO3L/R56V32McgssMbheJbYcviDDn7Tz4D+lTvdJA==",
"version": "2.0.0-beta.9",
"resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-2.0.0-beta.9.tgz",
"integrity": "sha512-sP6gusMsxm3W4aHpRsmVaBQU09n1p/1+XpLHT/gZy6nJ7Wy3nqfNKNoybNBORwCuFcGUon6cVRcieN9AEm6iJA==",
"cpu": [
"x64"
],
@@ -210,13 +214,16 @@
]
},
"node_modules/@rspack/binding-linux-arm64-gnu": {
"version": "2.0.0-beta.3",
"resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-2.0.0-beta.3.tgz",
"integrity": "sha512-iFPj4TQZKewnqWPfTbyk3F8QCBI/Edv7TVSRIPBHRnCM0lvYZl/8IZlUzXSamLvrtDpouF0nUzht/fktoWOhAg==",
"version": "2.0.0-beta.9",
"resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-2.0.0-beta.9.tgz",
"integrity": "sha512-k2DPN3B2qaz4L/h/R+l7rbDk/lLwbR/sayfsHZ8sLdZ3f6pvaSI9ejrsFv0nU4OmKCQsz4zYuoKTVFPtDfbGjA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -224,13 +231,16 @@
]
},
"node_modules/@rspack/binding-linux-arm64-musl": {
"version": "2.0.0-beta.3",
"resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-2.0.0-beta.3.tgz",
"integrity": "sha512-355mygfCNb0eF/y4HgtJcd0i9csNTG4Z15PCCplIkSAKJpFpkORM2xJb50BqsbhVafYl6AHoBlGWAo9iIzUb/w==",
"version": "2.0.0-beta.9",
"resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-2.0.0-beta.9.tgz",
"integrity": "sha512-7+XwAsqhfc2rIHMc9mY6RMBTP76RRqmUm1UjidqYdJl5hYBa5apffjeZfJYgAhVbSwKB/tUffzPpEffGUuc5kw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -238,13 +248,16 @@
]
},
"node_modules/@rspack/binding-linux-x64-gnu": {
"version": "2.0.0-beta.3",
"resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-2.0.0-beta.3.tgz",
"integrity": "sha512-U8a+bcP/tkMyiwiO9XfeRYYO20YPGiZNxWWt7FEsdmRuRAl6M+EmWaJllJFQtKH+GG8IN93pNoVPMvARjLoJOQ==",
"version": "2.0.0-beta.9",
"resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-2.0.0-beta.9.tgz",
"integrity": "sha512-z/EOUKEq5rq4sYsVSFL9uzdPtTPVA82x3gsRJlDTfEcruZZI7Y6JKUkpDYkC0LivXqyOnoOz8slAFd2/dByRtA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -252,13 +265,16 @@
]
},
"node_modules/@rspack/binding-linux-x64-musl": {
"version": "2.0.0-beta.3",
"resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-2.0.0-beta.3.tgz",
"integrity": "sha512-g81rqkaqDFRTID2VrHBYeM+xZe8yWov7IcryTrl9RGXXr61s+6Tu/mWyM378PuHOCyMNu7G3blVaSjLvKauG6Q==",
"version": "2.0.0-beta.9",
"resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-2.0.0-beta.9.tgz",
"integrity": "sha512-LVIXrqtAOy/DowIB04jyUyYy+5kHtZNJ0W5EJd39OwY/9gGvhgAEVvSWu7JrRAvKW1kQsV7GnRT5ninbDrRw1A==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -266,9 +282,9 @@
]
},
"node_modules/@rspack/binding-wasm32-wasi": {
"version": "2.0.0-beta.3",
"resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-2.0.0-beta.3.tgz",
"integrity": "sha512-tzGd8H2oj5F3oR/Hxp+J68zVU/nG+9ndH2KK3/RieVjNAiVNHCR0/ZU9D47s6fnmvWOqAQ1qO8gnVoVLopC4YA==",
"version": "2.0.0-beta.9",
"resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-2.0.0-beta.9.tgz",
"integrity": "sha512-Vl7aDAt7DCqtZ/RJd8hLFjQqufX+efL/XZG3qADsagl/SspH1ItJ7N6X1S8o50eKoshy27Jr7mQYZEdufX9qhQ==",
"cpu": [
"wasm32"
],
@@ -276,13 +292,13 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "1.0.7"
"@napi-rs/wasm-runtime": "1.1.1"
}
},
"node_modules/@rspack/binding-win32-arm64-msvc": {
"version": "2.0.0-beta.3",
"resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-2.0.0-beta.3.tgz",
"integrity": "sha512-TZZRSWa34sm5WyoQHwnyBjLJ4w3fcWRYA9ybYjSVWjUU6tVGdMiHiZp+WexUpIETvChLXU1JENNmBg/U7wvZEA==",
"version": "2.0.0-beta.9",
"resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-2.0.0-beta.9.tgz",
"integrity": "sha512-g4Fc3JjfibuHt5ltoV64eK0bs6NKlh8kgHA8Go3ETwEGO6OBck877e+5CqPtjTH8c1/KQPbnCoccGR1OScoZGg==",
"cpu": [
"arm64"
],
@@ -294,9 +310,9 @@
]
},
"node_modules/@rspack/binding-win32-ia32-msvc": {
"version": "2.0.0-beta.3",
"resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-2.0.0-beta.3.tgz",
"integrity": "sha512-VFnfdbJhyl6gNW1VzTyd1ZrHCboHPR7vrOalEsulQRqVNbtDkjm1sqLHtDcLmhTEv0a9r4lli8uubWDwmel8KQ==",
"version": "2.0.0-beta.9",
"resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-2.0.0-beta.9.tgz",
"integrity": "sha512-Oii4HpCEH3CBDKSXcS6EVlV9nGYVKAV/uBLSsuZ0RNdEG0i+OHvEiicqHAwuIYZNlH4Ea/Vwc+Dl5PM2twCZ4Q==",
"cpu": [
"ia32"
],
@@ -308,9 +324,9 @@
]
},
"node_modules/@rspack/binding-win32-x64-msvc": {
"version": "2.0.0-beta.3",
"resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-2.0.0-beta.3.tgz",
"integrity": "sha512-rwZ6Y3b3oqPj+ZDPPRxr3136HUPKDSlPQa4v7bBOPLDlrFDFOynMIEqDUUi5+8lPaUQ8WWR0aJK4cgcTTT0Siw==",
"version": "2.0.0-beta.9",
"resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-2.0.0-beta.9.tgz",
"integrity": "sha512-7UFjyy7QMtWvf1CBEVQkHL6bJBKaVY9yq9+Qxb7ggtxvpBbkoYykdsrhMTvr/f5TBjBqHmyeb0/oYXqo5pWFBQ==",
"cpu": [
"x64"
],
@@ -322,13 +338,13 @@
]
},
"node_modules/@rspack/core": {
"version": "2.0.0-beta.3",
"resolved": "https://registry.npmjs.org/@rspack/core/-/core-2.0.0-beta.3.tgz",
"integrity": "sha512-VuLteRIesuyFFTXZaciUY0lwDZiwMc7JcpE8guvjArztDhtpVvlaOcLlVBp/Yza8c/Tk8Dxwe1ARzFL7xG1/0w==",
"version": "2.0.0-beta.9",
"resolved": "https://registry.npmjs.org/@rspack/core/-/core-2.0.0-beta.9.tgz",
"integrity": "sha512-4sN3f72l4cj8n/dSCdWn6FkSjfHiDxHWrO1Kmqd0Bk0MmgyW+ldHitsSWPETCAxjTJGXY34r5sou5sYzb0DRww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rspack/binding": "2.0.0-beta.3"
"@rspack/binding": "2.0.0-beta.9"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
@@ -367,20 +383,20 @@
}
},
"node_modules/@rspress/core": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@rspress/core/-/core-2.0.5.tgz",
"integrity": "sha512-2ezGmANmIrWmhsUrvlRb9Df4xsun1BDgEertDc890aQqtKcNrbu+TBRsOoO+E/N6ioavun7JGGe1wWjvxubCHw==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@rspress/core/-/core-2.0.8.tgz",
"integrity": "sha512-MDkpm6fO0+NoW+Lx0KVL/n9DSRGQcoggeXY+EtlC+ySqF9VxQk4hu87fQhD8q2ikMOd7lbVsWmKspd3rIFD88g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mdx-js/mdx": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@rsbuild/core": "2.0.0-beta.6",
"@rsbuild/plugin-react": "~1.4.5",
"@rspress/shared": "2.0.5",
"@shikijs/rehype": "^4.0.1",
"@rsbuild/core": "2.0.0-beta.11",
"@rsbuild/plugin-react": "~1.4.6",
"@rspress/shared": "2.0.8",
"@shikijs/rehype": "^4.0.2",
"@types/unist": "^3.0.3",
"@unhead/react": "^2.1.9",
"@unhead/react": "^2.1.12",
"body-scroll-lock": "4.0.0-beta.0",
"cac": "^7.0.0",
"chokidar": "^3.6.0",
@@ -401,7 +417,7 @@
"react-lazy-with-preload": "^2.2.1",
"react-reconciler": "0.33.0",
"react-render-to-markdown": "19.0.1",
"react-router-dom": "^7.13.1",
"react-router-dom": "^7.13.2",
"rehype-external-links": "^3.0.0",
"rehype-raw": "^7.0.0",
"remark-cjk-friendly": "^2.0.1",
@@ -411,7 +427,7 @@
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"scroll-into-view-if-needed": "^3.1.0",
"shiki": "^4.0.1",
"shiki": "^4.0.2",
"tinyglobby": "^0.2.15",
"tinypool": "^1.1.1",
"unified": "^11.0.5",
@@ -427,40 +443,40 @@
}
},
"node_modules/@rspress/plugin-client-redirects": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@rspress/plugin-client-redirects/-/plugin-client-redirects-2.0.5.tgz",
"integrity": "sha512-sxwWzwHPefSPWUyV6/AA/hBlQUeNFntL8dBQi/vZCQiZHM6ShvKbqa3s5Xu2yI7DeFKHH3jb0VGbjufu8M3Ypw==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@rspress/plugin-client-redirects/-/plugin-client-redirects-2.0.8.tgz",
"integrity": "sha512-6/+CYf4u2PGOmuQkqvzLeUKTdOlj+Fnt3D/6IgjZmbXcSDweLvHhC+dHgdZw7T4paiqIxeCqU0duYX8W5agAug==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"@rspress/core": "^2.0.5"
"@rspress/core": "^2.0.8"
}
},
"node_modules/@rspress/plugin-sitemap": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@rspress/plugin-sitemap/-/plugin-sitemap-2.0.5.tgz",
"integrity": "sha512-wBxKL8sNd3bkKFxlFtB1xJ7jCtSRDL6pfVvWsmTIbTNDPCtefd1nmiMBIDMLOR8EflwuStIz3bMQXdWpbC7ahA==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@rspress/plugin-sitemap/-/plugin-sitemap-2.0.8.tgz",
"integrity": "sha512-V3u+wRvzmJmC+GkigvQYDQfEo43xUlya9OVLqSyRcB/crQ0U99oto6v73isZB/qS/pTb2wbFY+CbzOqrD1uBsA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"@rspress/core": "^2.0.5"
"@rspress/core": "^2.0.8"
}
},
"node_modules/@rspress/shared": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@rspress/shared/-/shared-2.0.5.tgz",
"integrity": "sha512-Wdhh+VjU8zJWoVLhv9KJTRAZQ4X2V/Z81Lo2D0hQsa0Kj5F3EaxlMt5/dhX7DoflqNuZPZk/e7CSUB+gO/Umlg==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@rspress/shared/-/shared-2.0.8.tgz",
"integrity": "sha512-kvfBUvMvWcn/7PJHqZxPeu1yblzvAuB1/gk/1orp5KsYu3wbZ7X3Hsm9smDJVs5Plw1iPt67t9fOYNSM0+VjUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rsbuild/core": "2.0.0-beta.6",
"@shikijs/rehype": "^4.0.1",
"@rsbuild/core": "2.0.0-beta.11",
"@shikijs/rehype": "^4.0.2",
"gray-matter": "4.0.3",
"lodash-es": "^4.17.23",
"unified": "^11.0.5"
@@ -593,9 +609,9 @@
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
"version": "0.5.20",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.20.tgz",
"integrity": "sha512-2egEBHUMasdypIzrprsu8g+OEVd7Vp2MM3a2eVlM/cyFYto0nGz5BX5BTgh/ShZZI9ed+ozEq+Ngt+rgmUs8tw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -3135,9 +3151,9 @@
}
},
"node_modules/react-router": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
"version": "7.13.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz",
"integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3158,13 +3174,13 @@
}
},
"node_modules/react-router-dom": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
"version": "7.13.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz",
"integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==",
"dev": true,
"license": "MIT",
"dependencies": {
"react-router": "7.13.1"
"react-router": "7.13.2"
},
"engines": {
"node": ">=20.0.0"
@@ -3711,9 +3727,9 @@
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {

View File

@@ -25,6 +25,6 @@
"@rspress/core": "^2.0.0",
"@rspress/plugin-client-redirects": "^2.0.0",
"@rspress/plugin-sitemap": "^2.0.0",
"typescript": "^5.9.3"
"typescript": "^6.0.0"
}
}

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", "replacements:all", ":semanticCommitTypeAll(chore)"],
"extends": ["config:recommended", "replacements:all", ":semanticCommitTypeAll(chore)", "helpers:pinGitHubActionDigests"],
"dependencyDashboard": true,
"osvVulnerabilityAlerts": true,
"lockFileMaintenance": {
@@ -95,16 +95,16 @@
}
],
"customManagers": [
{
"customType": "regex",
"description": "Update _VERSION variables in Dockerfiles",
"managerFilePatterns": [
"/(^|/)([Dd]ocker|[Cc]ontainer)file[^/]*$/",
"/(^|/|\\.)([Dd]ocker|[Cc]ontainer)file$/"
],
"matchStrings": [
"# renovate: datasource=(?<datasource>[a-zA-Z0-9-._]+?) depName=(?<depName>[^\\s]+?)(?: (lookupName|packageName)=(?<packageName>[^\\s]+?))?(?: versioning=(?<versioning>[^\\s]+?))?(?: extractVersion=(?<extractVersion>[^\\s]+?))?(?: registryUrl=(?<registryUrl>[^\\s]+?))?\\s+(?:ENV\\s+|ARG\\s+)?[A-Za-z0-9_]+?_VERSION[ =][\"']?(?<currentValue>.+?)[\"']?\\s+(?:(?:ENV\\s+|ARG\\s+)?[A-Za-z0-9_]+?_CHECKSUM[ =][\"']?(?<currentDigest>.+?)[\"']?\\s)?"
]
}
{
"customType": "regex",
"description": "Update _VERSION variables in Dockerfiles",
"managerFilePatterns": [
"/(^|/)([Dd]ocker|[Cc]ontainer)file[^/]*$/",
"/(^|/|\\.)([Dd]ocker|[Cc]ontainer)file$/"
],
"matchStrings": [
"# renovate: datasource=(?<datasource>[a-zA-Z0-9-._]+?) depName=(?<depName>[^\\s]+?)(?: (lookupName|packageName)=(?<packageName>[^\\s]+?))?(?: versioning=(?<versioning>[^\\s]+?))?(?: extractVersion=(?<extractVersion>[^\\s]+?))?(?: registryUrl=(?<registryUrl>[^\\s]+?))?\\s+(?:ENV\\s+|ARG\\s+)?[A-Za-z0-9_]+?_VERSION[ =][\"']?(?<currentValue>.+?)[\"']?\\s+(?:(?:ENV\\s+|ARG\\s+)?[A-Za-z0-9_]+?_CHECKSUM[ =][\"']?(?<currentDigest>.+?)[\"']?\\s)?"
]
}
]
}

View File

@@ -10,7 +10,7 @@
[toolchain]
profile = "minimal"
channel = "1.90.0"
channel = "1.92.0"
components = [
# For rust-analyzer
"rust-src",

View File

@@ -80,6 +80,7 @@ conduwuit-macros.workspace = true
conduwuit-service.workspace = true
const-str.workspace = true
futures.workspace = true
lettre.workspace = true
log.workspace = true
ruma.workspace = true
serde_json.workspace = true

View File

@@ -19,6 +19,7 @@
warn,
};
use futures::{FutureExt, StreamExt, TryStreamExt};
use lettre::message::Mailbox;
use ruma::{
CanonicalJsonObject, CanonicalJsonValue, EventId, OwnedEventId, OwnedRoomId,
OwnedRoomOrAliasId, OwnedServerName, RoomId, RoomVersionId,
@@ -876,3 +877,31 @@ pub(super) async fn trim_memory(&self) -> Result {
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(())
}

View File

@@ -225,6 +225,9 @@ pub enum DebugCommand {
level: Option<i32>,
},
/// Send a test email to the invoking admin's email address
SendTestEmail,
/// Developer test stubs
#[command(subcommand)]
#[allow(non_snake_case)]

View File

@@ -11,6 +11,7 @@
warn,
};
use futures::{FutureExt, StreamExt};
use lettre::Address;
use ruma::{
OwnedEventId, OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedUserId, UserId,
events::{
@@ -1094,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
}
#[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
},
}
}

View File

@@ -35,6 +35,24 @@ pub enum UserCommand {
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
///
/// User will be removed from all rooms by default.

View File

@@ -85,6 +85,7 @@ http-body-util.workspace = true
hyper.workspace = true
ipaddress.workspace = true
itertools.workspace = true
lettre.workspace = true
log.workspace = true
rand.workspace = true
reqwest.workspace = true

View File

@@ -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(&notice).await;
}
} else {
let notice = format!("New user \"{user_id}\" registered on this server.");
info!("{notice}");
if services.server.config.admin_room_notices {
services.admin.notice(&notice).await;
}
}
}
// 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(())
}

View File

@@ -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(())
}

View File

@@ -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(&notice).await;
}
} else {
let notice = format!("New user \"{user_id}\" registered on this server.");
info!("{notice}");
if services.server.config.admin_room_notices {
services.admin.notice(&notice).await;
}
}
}
// 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(&params).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))
}

View File

@@ -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,
})
}

View File

@@ -30,8 +30,10 @@ pub(crate) async fn get_capabilities_route(
default: services.server.config.default_room_version.clone(),
};
// we do not implement 3PID stuff
capabilities.thirdparty_id_changes = ThirdPartyIdChangesCapability { enabled: false };
// Only allow 3pid changes if SMTP is configured
capabilities.thirdparty_id_changes = ThirdPartyIdChangesCapability {
enabled: services.mailer.mailer().is_some(),
};
capabilities.get_login_token = GetLoginTokenCapability {
enabled: services.server.config.login_via_existing_session,
@@ -51,7 +53,7 @@ pub(crate) async fn get_capabilities_route(
.await
{
// 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 })

View File

@@ -1,17 +1,15 @@
use axum::extract::State;
use axum_client_ip::InsecureClientIp;
use conduwuit::{Err, Error, Result, debug, err, utils};
use conduwuit::{Err, Result, debug, err, utils};
use futures::StreamExt;
use ruma::{
MilliSecondsSinceUnixEpoch, OwnedDeviceId,
api::client::{
device::{self, delete_device, delete_devices, get_device, get_devices, update_device},
error::ErrorKind,
uiaa::{AuthFlow, AuthType, UiaaInfo},
api::client::device::{
self, delete_device, delete_devices, get_device, get_devices, update_device,
},
};
use service::uiaa::Identity;
use super::SESSION_ID_LENGTH;
use crate::{Ruma, client::DEVICE_ID_LENGTH};
/// # `GET /_matrix/client/r0/devices`
@@ -123,7 +121,7 @@ pub(crate) async fn delete_device_route(
State(services): State<crate::State>,
body: Ruma<delete_device::v3::Request>,
) -> 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();
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 {});
}
// UIAA
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, 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.")));
},
},
}
// 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?;
services
.users
@@ -200,7 +168,7 @@ pub(crate) async fn delete_devices_route(
State(services): State<crate::State>,
body: Ruma<delete_devices::v3::Request>,
) -> 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();
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 {});
}
// UIAA
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, 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."));
},
},
}
// 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?;
for device_id in &body.devices {
services.users.remove_device(sender_user, device_id).await;

View File

@@ -7,7 +7,6 @@
use conduwuit::{
Err, Error, Result, debug, debug_warn, err,
result::NotFound,
utils,
utils::{IterStream, stream::WidebandExt},
};
use conduwuit_service::{Services, users::parse_master_key};
@@ -22,7 +21,6 @@
upload_signatures::{self},
upload_signing_keys,
},
uiaa::{AuthFlow, AuthType, UiaaInfo},
},
federation,
},
@@ -30,8 +28,8 @@
serde::Raw,
};
use serde_json::json;
use service::uiaa::Identity;
use super::SESSION_ID_LENGTH;
use crate::Ruma;
/// # `POST /_matrix/client/r0/keys/upload`
@@ -174,16 +172,7 @@ pub(crate) async fn upload_signing_keys_route(
State(services): State<crate::State>,
body: Ruma<upload_signing_keys::v3::Request>,
) -> Result<upload_signing_keys::v3::Response> {
let (sender_user, sender_device) = body.sender();
// UIAA
let mut uiaainfo = UiaaInfo {
flows: vec![AuthFlow { stages: vec![AuthType::Password] }],
completed: Vec::new(),
params: Box::default(),
session: None,
auth_error: None,
};
let sender_user = body.sender_user();
match check_for_new_keys(
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
},
| _ => {
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(Error::BadRequest(ErrorKind::NotJson, "Not json."));
},
},
}
let _ = services
.uiaa
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
.await?;
},
}

View File

@@ -114,7 +114,19 @@ pub(crate) async fn get_content_thumbnail_route(
content,
content_type,
content_disposition,
} = fetch_thumbnail(&services, &mxc, user, body.timeout_ms, &dim).await?;
} = match fetch_thumbnail(&services, &mxc, user, body.timeout_ms, &dim).await {
| Ok(meta) => meta,
| Err(conduwuit::Error::Io(e)) => match e.kind() {
| std::io::ErrorKind::NotFound =>
return Err!(Request(NotFound("Thumbnail not found."))),
| std::io::ErrorKind::PermissionDenied => {
error!("Permission denied when trying to read file: {e:?}");
return Err!(Request(Unknown("Unknown error when fetching thumbnail.")));
},
| _ => return Err!(Request(Unknown("Unknown error when fetching thumbnail."))),
},
| Err(_) => return Err!(Request(Unknown("Unknown error when fetching thumbnail."))),
};
Ok(get_content_thumbnail::v1::Response {
file: content.expect("entire file contents"),

View File

@@ -92,6 +92,3 @@
/// generated user access token length
const TOKEN_LENGTH: usize = 32;
/// generated user session ID length
const SESSION_ID_LENGTH: usize = service::uiaa::SESSION_ID_LENGTH;

View File

@@ -8,8 +8,9 @@
warn,
};
use conduwuit_core::{debug_error, debug_warn};
use conduwuit_service::{Services, uiaa::SESSION_ID_LENGTH};
use conduwuit_service::Services;
use futures::StreamExt;
use lettre::Address;
use ruma::{
OwnedUserId, UserId,
api::client::{
@@ -26,9 +27,10 @@
},
logout, logout_all,
},
uiaa,
uiaa::UserIdentifier,
},
};
use service::uiaa::Identity;
use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH};
use crate::Ruma;
@@ -80,7 +82,7 @@ pub(crate) async fn password_login(
.password_hash(lowercased_user_id)
.await
.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() {
@@ -89,7 +91,7 @@ pub(crate) async fn password_login(
hash::verify_password(password, &hash)
.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())
}
@@ -161,28 +163,38 @@ pub(super) async fn ldap_login(
pub(crate) async fn handle_login(
services: &Services,
body: &Ruma<login::v3::Request>,
identifier: Option<&uiaa::UserIdentifier>,
identifier: Option<&UserIdentifier>,
password: &str,
user: Option<&String>,
) -> Result<OwnedUserId> {
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 =
if let Some(uiaa::UserIdentifier::UserIdOrLocalpart(user_id)) = identifier {
UserId::parse_with_server_name(user_id, &services.config.server_name)
} 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}")))))?;
UserId::parse_with_server_name(user_id_or_localpart, &services.config.server_name)
.map_err(|_| err!(Request(InvalidUsername("User ID is malformed"))))?;
let lowercased_user_id = UserId::parse_with_server_name(
user_id.localpart().to_lowercase(),
&services.config.server_name,
)?;
)
.unwrap();
if !services.globals.user_is_local(&user_id)
|| !services.globals.user_is_local(&lowercased_user_id)
@@ -244,7 +256,7 @@ pub(crate) async fn login_route(
password,
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 }) => {
debug!("Got token login type");
if !services.server.config.login_via_existing_session {
@@ -264,7 +276,7 @@ pub(crate) async fn login_route(
};
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)
} else if let Some(user) = user {
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?)")
)));
}
.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) {
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")));
}
// This route SHOULD have UIA
// TODO: How do we make only UIA sessions that have not been used before valid?
let (sender_user, sender_device) = body.sender();
let sender_user = body.sender_user();
let mut uiaainfo = uiaa::UiaaInfo {
flows: vec![uiaa::AuthFlow { stages: vec![uiaa::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, 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.")));
},
},
}
// 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?;
let login_token = utils::random_string(TOKEN_LENGTH);
let expires_in = services.users.create_login_token(sender_user, &login_token);

View File

@@ -1,3 +1,5 @@
#[cfg(test)]
mod tests;
use axum::extract::State;
use axum_client_ip::InsecureClientIp;
use conduwuit::{
@@ -194,6 +196,7 @@ async fn send_state_event_for_key_helper(
state_key: &str,
timestamp: Option<MilliSecondsSinceUnixEpoch>,
) -> Result<OwnedEventId> {
let json: &mut Raw<AnyStateEventContent> = &mut json.clone();
allowed_to_send_state_event(services, room_id, event_type, state_key, json).await?;
let state_lock = services.rooms.state.mutex.lock(room_id).await;
let event_id = services
@@ -221,7 +224,7 @@ async fn allowed_to_send_state_event(
room_id: &RoomId,
event_type: &StateEventType,
state_key: &str,
json: &Raw<AnyStateEventContent>,
json: &mut Raw<AnyStateEventContent>,
) -> Result {
match event_type {
| StateEventType::RoomCreate => {
@@ -366,7 +369,7 @@ async fn allowed_to_send_state_event(
}
},
| StateEventType::RoomMember => match json.deserialize_as::<RoomMemberEventContent>() {
| Ok(membership_content) => {
| Ok(mut membership_content) => {
let Ok(state_key) = UserId::parse(state_key) else {
return Err!(Request(BadJson(
"Membership event has invalid or non-existent state key"
@@ -376,20 +379,24 @@ async fn allowed_to_send_state_event(
if let Some(authorising_user) =
membership_content.join_authorized_via_users_server
{
if membership_content.membership != MembershipState::Join {
return Err!(Request(BadJson(
"join_authorised_via_users_server is only for member joins"
)));
}
// join_authorized_via_users_server must be thrown away, if user is already a
// member of the room.
if services
.rooms
.state_cache
.is_joined(state_key, room_id)
.await
{
return Err!(Request(InvalidParam(
"{state_key} is already joined, an authorising user is not required."
membership_content.join_authorized_via_users_server = None;
*json = Raw::<AnyStateEventContent>::from_json_string(
serde_json::to_string(&membership_content)?,
)?;
return Ok(());
}
if membership_content.membership != MembershipState::Join {
return Err!(Request(BadJson(
"join_authorised_via_users_server is only for member joins"
)));
}

View File

@@ -0,0 +1,34 @@
use super::*;
#[test]
fn test_strip_room_member() -> Result<()> {
//Test setup
let body = r#"
{
"avatar_url": "Something",
"displayname": "Someone",
"join_authorized_via_users_server": "@someone:domain.tld",
"membership": "join"
}"#;
println!("JSON (original): {body}");
let json: &mut Raw<AnyStateEventContent> =
&mut Raw::<AnyStateEventContent>::from_json_string(body.to_owned())?;
let mut membership_content: RoomMemberEventContent =
json.deserialize_as::<RoomMemberEventContent>()?;
//Begin Test
membership_content.join_authorized_via_users_server = None;
*json = Raw::<AnyStateEventContent>::from_json_string(serde_json::to_string(
&membership_content,
)?)?;
//Compare result
let result = json.json().get();
println!("JSON (modified): {result}");
assert_eq!(
result,
r#"{"avatar_url":"Something","displayname":"Someone","membership":"join"}"#
);
Ok(())
}

View File

@@ -28,7 +28,8 @@ pub fn build(router: Router<State>, server: &Server) -> Router<State> {
.ruma_route(&client::appservice_ping)
.ruma_route(&client::get_supported_versions_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::login_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_all_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::third_party_route)
.ruma_route(&client::request_3pid_management_token_via_email_route)
.ruma_route(&client::request_3pid_management_token_via_msisdn_route)
.ruma_route(&client::threepid::third_party_route)
.ruma_route(&client::threepid::request_3pid_management_token_via_email_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::get_capabilities_route)
.ruma_route(&client::get_pushrules_all_route)

View File

@@ -2,14 +2,13 @@
use axum::{body::Body, extract::FromRequest};
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::{
CanonicalJsonObject, CanonicalJsonValue, DeviceId, OwnedDeviceId, OwnedServerName,
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};
/// Extractor for Ruma request structs
@@ -108,7 +107,7 @@ async fn from_request(
}
let auth = auth::auth(services, &mut request, json_body.as_ref(), &T::METADATA).await?;
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,
sender_user: auth.sender_user,
sender_device: auth.sender_device,
@@ -118,16 +117,11 @@ async fn from_request(
}
}
fn make_body<T>(
services: &Services,
request: &mut Request,
json_body: Option<&mut CanonicalJsonValue>,
auth: &Auth,
) -> Result<T>
fn make_body<T>(request: &mut Request, json_body: Option<&mut CanonicalJsonValue>) -> Result<T>
where
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);
T::try_from_http_request(http_request, &request.path)
.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)]
fn take_body(
services: &Services,
request: &mut Request,
json_body: Option<&mut CanonicalJsonValue>,
auth: &Auth,
) -> Bytes {
fn take_body(request: &mut Request, json_body: Option<&mut CanonicalJsonValue>) -> Bytes {
let Some(CanonicalJsonValue::Object(json_body)) = json_body else {
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();
serde_json::to_writer(&mut buf, &json_body).expect("value serialization can't fail");
buf.into_inner().freeze()

View File

@@ -84,6 +84,7 @@ libc.workspace = true
libloading.workspace = true
libloading.optional = true
log.workspace = true
lettre.workspace = true
num-traits.workspace = true
rand.workspace = true
rand_core = { version = "0.6.4", features = ["getrandom"] }
@@ -114,6 +115,7 @@ tracing.workspace = true
url.workspace = true
parking_lot.workspace = true
lock_api.workspace = true
hyper-util.workspace = true
[target.'cfg(unix)'.dependencies]
nix.workspace = true

View File

@@ -16,6 +16,7 @@
};
use figment::providers::{Env, Format, Toml};
pub use figment::{Figment, value::Value as FigmentValue};
use lettre::message::Mailbox;
use regex::RegexSet;
use ruma::{
OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedUserId, RoomVersionId,
@@ -145,6 +146,10 @@ pub struct Config {
/// engine API. To use this, set a database backup path that continuwuity
/// can write to.
///
/// If you are using systemd, you will need to add the path to
/// ReadWritePaths in the service file, preferably via a drop-in file
/// through `systemctl edit`.
///
/// For more information, see:
/// https://continuwuity.org/maintenance.html#backups
///
@@ -756,6 +761,9 @@ pub struct Config {
#[serde(default)]
pub well_known: WellKnownConfig,
/// display: nested
pub smtp: Option<SmtpConfig>,
/// Enable OpenTelemetry OTLP tracing export. This replaces the deprecated
/// Jaeger exporter. Traces will be sent via OTLP to a collector (such as
/// Jaeger) that supports the OpenTelemetry Protocol.
@@ -2440,6 +2448,52 @@ pub struct DraupnirConfig {
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] = &[
"cache_capacity",
"conduit_cache_capacity_modifier",

View File

@@ -4,7 +4,7 @@
mod response;
mod serde;
use std::{any::Any, borrow::Cow, convert::Infallible, sync::PoisonError};
use std::{any::Any, borrow::Cow, convert::Infallible, error::Error as _, sync::PoisonError};
pub use self::{err::visit, log::*};
@@ -66,8 +66,8 @@ pub enum Error {
Poison(Cow<'static, str>),
#[error("Regex error: {0}")]
Regex(#[from] regex::Error),
#[error("Request error: {0}")]
Reqwest(#[from] reqwest::Error),
#[error("{0}")]
Reqwest(FormattedReqwestError),
#[error("{0}")]
SerdeDe(Cow<'static, str>),
#[error("{0}")]
@@ -236,3 +236,41 @@ pub fn infallible(_e: &Infallible) {
#[must_use]
#[allow(clippy::needless_pass_by_value)]
pub fn sanitized_message(e: Error) -> String { e.sanitized_message() }
#[derive(Debug)]
pub struct FormattedReqwestError(reqwest::Error);
impl std::ops::Deref for FormattedReqwestError {
type Target = reqwest::Error;
fn deref(&self) -> &Self::Target { &self.0 }
}
impl std::error::Error for FormattedReqwestError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { self.0.source() }
}
impl std::fmt::Display for FormattedReqwestError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(hyper_error) = self.0.source()
&& hyper_error.is::<hyper_util::client::legacy::Error>()
&& let Some(real_error) = hyper_error.source()
{
if let Some(real_reason) = real_error.source() {
write!(f, "{real_error}: {real_reason}")
} else {
write!(f, "{real_error}")
}
} else {
write!(f, "Request error: {}", &self.0)
}
}
}
impl From<reqwest::Error> for FormattedReqwestError {
fn from(err: reqwest::Error) -> Self { Self(err) }
}
impl From<reqwest::Error> for Error {
fn from(err: reqwest::Error) -> Self { Self::Reqwest(err.into()) }
}

View File

@@ -415,13 +415,6 @@ fn deserialize_ignored_any<V: Visitor<'de>>(self, _visitor: V) -> Result<V::Valu
tracing::instrument(level = "trace", skip_all, fields(?self.buf))
)]
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() {
| Some(b'{') => self.deserialize_map(visitor),
| Some(b'[') => serde_json::Deserializer::from_slice(self.record_next())

View File

@@ -70,15 +70,17 @@ fn descriptor_cf_options(
);
}
opts.set_options_from_string("{{arena_block_size=2097152;}}")
let mut opts = opts
.get_options_from_string("{{arena_block_size=2097152;}}")
.map_err(map_err)?;
#[cfg(debug_assertions)]
opts.set_options_from_string(
"{{paranoid_checks=true;paranoid_file_checks=true;force_consistency_checks=true;\
verify_sst_unique_id_in_manifest=true;}}",
)
.map_err(map_err)?;
let opts = opts
.get_options_from_string(
"{{paranoid_checks=true;paranoid_file_checks=true;force_consistency_checks=true;\
verify_sst_unique_id_in_manifest=true;}}",
)
.map_err(map_err)?;
Ok(opts)
}
@@ -105,7 +107,7 @@ fn set_table_options(opts: &mut Options, desc: &Descriptor, cache: Option<&Cache
prepopulate,
);
opts.set_options_from_string(&string).map_err(map_err)?;
let mut opts = opts.get_options_from_string(&string).map_err(map_err)?;
opts.set_block_based_table_factory(&table);

View File

@@ -138,7 +138,7 @@ fn set_logging_defaults(opts: &mut Options, config: &Config) {
if config.rocksdb_log_stderr {
opts.set_stderr_logger(rocksdb_log_level, "rocksdb");
} else {
opts.set_callback_logger(rocksdb_log_level, &handle_log);
opts.set_callback_logger(rocksdb_log_level, handle_log);
}
}

View File

@@ -53,6 +53,10 @@ pub(super) fn open_list(db: &Arc<Engine>, maps: &[Descriptor]) -> Result<Maps> {
name: "disabledroomids",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "email_localpart",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "eventid_outlierpdu",
cache_disp: CacheDisp::SharedWith("pduid_pdu"),
@@ -100,6 +104,10 @@ pub(super) fn open_list(db: &Arc<Engine>, maps: &[Descriptor]) -> Result<Maps> {
name: "lazyloadedids",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "localpart_email",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "mediaid_file",
..descriptor::RANDOM_SMALL

View File

@@ -91,6 +91,7 @@ conduwuit-database.workspace = true
const-str.workspace = true
either.workspace = true
futures.workspace = true
governor.workspace = true
hickory-resolver.workspace = true
http.workspace = true
image.workspace = true
@@ -102,6 +103,7 @@ ldap3.optional = true
log.workspace = true
loole.workspace = true
lru-cache.workspace = true
nonzero_ext.workspace = true
rand.workspace = true
regex.workspace = true
reqwest.workspace = true
@@ -123,6 +125,7 @@ blurhash.workspace = true
blurhash.optional = true
recaptcha-verify = { version = "0.2.0", default-features = false }
yansi.workspace = true
lettre.workspace = true
[target.'cfg(all(unix, target_os = "linux"))'.dependencies]
sd-notify.workspace = true

View File

@@ -122,7 +122,7 @@ fn disable_first_run(&self) -> bool {
/// if they were not.
pub async fn empower_first_user(&self, user: &UserId) -> Result<bool> {
#[derive(Template)]
#[template(path = "welcome.md.j2")]
#[template(path = "welcome.md")]
struct WelcomeMessage<'a> {
config: &'a Dep<config::Service>,
domain: &'a str,
@@ -228,19 +228,34 @@ pub fn print_first_run_banner(&self) {
);
},
}
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!(
"{} https://matrix.org/ecosystem/clients/",
"Find a list of Matrix clients here:".bold()
);
if self.services.config.suspend_on_register {
eprintln!(
"{} Because you enabled suspend-on-register in your configuration, accounts \
created after yours will be automatically suspended.",
"Your account will not be suspended when you register.".green()
);
}
if self
.services
.config

View File

@@ -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() }
}

109
src/service/mailer/mod.rs Normal file
View File

@@ -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(())
}
}

View File

@@ -21,6 +21,7 @@
pub mod firstrun;
pub mod globals;
pub mod key_backups;
pub mod mailer;
pub mod media;
pub mod moderation;
pub mod password_reset;
@@ -32,6 +33,7 @@
pub mod sending;
pub mod server_keys;
pub mod sync;
pub mod threepid;
pub mod transactions;
pub mod uiaa;
pub mod users;

View File

@@ -13,8 +13,6 @@
use crate::{Dep, config, firstrun};
const RANDOM_TOKEN_LENGTH: usize = 16;
pub struct Service {
db: Data,
services: Services,
@@ -103,9 +101,11 @@ fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
}
impl Service {
const RANDOM_TOKEN_LENGTH: usize = 16;
/// Generate a random string suitable to be used as a registration token.
#[must_use]
pub fn generate_token_string() -> String { utils::random_string(RANDOM_TOKEN_LENGTH) }
pub fn generate_token_string() -> String { utils::random_string(Self::RANDOM_TOKEN_LENGTH) }
/// Issue a new registration token and save it in the database.
pub fn issue_token(

View File

@@ -26,7 +26,10 @@ pub(super) async fn request_well_known(&self, dest: &str) -> Result<Option<Strin
return Ok(None);
}
let text = response.limit_read_text(8192).await?;
let Ok(text) = response.limit_read_text(8192).await else {
debug!("failed to read well-known response (too large or non-text content)");
return Ok(None);
};
trace!("response text: {text:?}");
let body: serde_json::Value = serde_json::from_str(&text).unwrap_or_default();

View File

@@ -104,17 +104,13 @@ pub async fn remove_alias(&self, alias: &RoomAliasId, user_id: &UserId) -> Resul
return Err!(Request(Forbidden("User is not permitted to remove this alias.")));
}
let alias_full = alias.as_bytes().to_vec();
let alias = alias.alias();
let Ok(room_id) = self.db.alias_roomid.get(&alias).await else {
return Err!(Request(NotFound("Alias does not exist or is invalid.")));
};
let prefix = (&room_id, Interfix);
self.db
.aliasid_alias
.keys_prefix_raw(&prefix)
.ignore_err()
.ready_for_each(|key| self.db.aliasid_alias.remove(key))
self.remove_aliasid_alias_entries(&room_id, &alias_full)
.await;
self.db.alias_roomid.remove(alias.as_bytes());
@@ -274,6 +270,22 @@ async fn who_created_alias(&self, alias: &RoomAliasId) -> Result<OwnedUserId> {
self.db.alias_userid.get(alias.alias()).await.deserialized()
}
async fn remove_aliasid_alias_entries(&self, room_id: &[u8], alias_full: &[u8]) {
let prefix = (room_id, Interfix);
let keys: Vec<Vec<u8>> = self
.db
.aliasid_alias
.stream_prefix_raw(&prefix)
.ignore_err()
.ready_filter_map(|(key, value)| (value == alias_full).then_some(key.to_vec()))
.collect()
.await;
for key in keys {
self.db.aliasid_alias.remove(&key);
}
}
async fn resolve_appservice_alias(
&self,
room_alias: &RoomAliasId,

View File

@@ -9,12 +9,12 @@
use crate::{
account_data, admin, announcements, antispam, appservice, client, config, emergency,
federation, firstrun, globals, key_backups,
federation, firstrun, globals, key_backups, mailer,
manager::Manager,
media, moderation, password_reset, presence, pusher, registration_tokens, resolver, rooms,
sending, server_keys,
service::{self, Args, Map, Service},
sync, transactions, uiaa, users,
sync, threepid, transactions, uiaa, users,
};
pub struct Services {
@@ -28,6 +28,7 @@ pub struct Services {
pub key_backups: Arc<key_backups::Service>,
pub media: Arc<media::Service>,
pub password_reset: Arc<password_reset::Service>,
pub mailer: Arc<mailer::Service>,
pub presence: Arc<presence::Service>,
pub pusher: Arc<pusher::Service>,
pub registration_tokens: Arc<registration_tokens::Service>,
@@ -39,6 +40,7 @@ pub struct Services {
pub server_keys: Arc<server_keys::Service>,
pub sync: Arc<sync::Service>,
pub transactions: Arc<transactions::Service>,
pub threepid: Arc<threepid::Service>,
pub uiaa: Arc<uiaa::Service>,
pub users: Arc<users::Service>,
pub moderation: Arc<moderation::Service>,
@@ -83,6 +85,7 @@ macro_rules! build {
key_backups: build!(key_backups::Service),
media: build!(media::Service),
password_reset: build!(password_reset::Service),
mailer: build!(mailer::Service),
presence: build!(presence::Service),
pusher: build!(pusher::Service),
registration_tokens: build!(registration_tokens::Service),
@@ -112,6 +115,7 @@ macro_rules! build {
sending: build!(sending::Service),
server_keys: build!(server_keys::Service),
sync: build!(sync::Service),
threepid: build!(threepid::Service),
transactions: build!(transactions::Service),
uiaa: build!(uiaa::Service),
users: build!(users::Service),

View File

@@ -0,0 +1,3 @@
{%- block content %}{% endblock %}
Message sent by Continuwuity {{ env!("CARGO_PKG_VERSION") }}. 🐈

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -0,0 +1,5 @@
{% extends "_base.txt" %}
{% block content -%}
If you're seeing this, SMTP is configured correctly. :3
{%- endblock %}

281
src/service/threepid/mod.rs Normal file
View File

@@ -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()
}
}

View File

@@ -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
}
}

View File

@@ -1,24 +1,29 @@
use std::{collections::BTreeMap, sync::Arc};
use conduwuit::{
Err, Error, Result, SyncRwLock, err, error, implement, utils,
utils::{hash, string::EMPTY},
use std::{
borrow::Cow,
collections::{HashMap, HashSet, hash_map::Entry},
sync::Arc,
};
use database::{Deserialized, Json, Map};
use conduwuit::{Err, Error, Result, error, utils, utils::hash};
use lettre::Address;
use ruma::{
CanonicalJsonValue, DeviceId, OwnedDeviceId, OwnedUserId, UserId,
UserId,
api::client::{
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 {
userdevicesessionid_uiaarequest: SyncRwLock<RequestMap>,
db: Data,
services: Services,
uiaa_sessions: Mutex<HashMap<String, UiaaSession>>,
}
struct Services {
@@ -26,205 +31,191 @@ struct Services {
users: Dep<users::Service>,
config: Dep<config::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 {
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
Ok(Arc::new(Self {
userdevicesessionid_uiaarequest: SyncRwLock::new(RequestMap::new()),
db: Data {
userdevicesessionid_uiaainfo: args.db["userdevicesessionid_uiaainfo"].clone(),
},
services: Services {
globals: args.depend::<globals::Service>("globals"),
users: args.depend::<users::Service>("users"),
config: args.depend::<config::Service>("config"),
registration_tokens: args
.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!()) }
}
/// Creates a new Uiaa session. Make sure the session token is unique.
#[implement(Service)]
pub fn create(
&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),
);
struct UiaaSession {
info: UiaaInfo,
identity: Identity,
}
#[implement(Service)]
#[allow(clippy::useless_let_if_seq)]
pub async fn try_auth(
&self,
user_id: &UserId,
device_id: &DeviceId,
auth: &AuthData,
uiaainfo: &UiaaInfo,
) -> Result<(bool, UiaaInfo)> {
let mut uiaainfo = if let Some(session) = auth.session() {
self.get_uiaa_session(user_id, device_id, session).await?
} else {
uiaainfo.clone()
};
/// Information about the authenticated user's identity.
///
/// A field of this struct will only be Some if the user completed
/// a stage which provided that information. If multiple stages provide
/// the same field, authentication will fail if they do not all provide
/// _identical_ values for that field.
#[derive(Default, Clone)]
pub struct Identity {
/// The authenticated user's user ID, if it could be determined.
///
/// This will be Some if:
/// - The user completed a m.login.password stage
/// - 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() {
uiaainfo.session = Some(utils::random_string(SESSION_ID_LENGTH));
/// The authenticated user's email address, if it could be determined.
///
/// 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 {
// Find out what the user completed
| AuthData::Password(Password {
identifier,
password,
#[cfg(feature = "element_hacks")]
user,
..
}) => {
#[cfg(feature = "element_hacks")]
let username = if let Some(UserIdentifier::UserIdOrLocalpart(username)) = identifier {
username
} else if let Some(username) = user {
username
} else {
return Err(Error::BadRequest(
ErrorKind::Unrecognized,
"Identifier type not recognized.",
));
};
/// A helper to perform UIAA authentication with just a password stage.
#[inline]
pub async fn authenticate_password(
&self,
auth: &Option<AuthData>,
identity: Option<Identity>,
) -> Result<Identity> {
self.authenticate(
auth,
vec![AuthFlow::new(vec![AuthType::Password])],
Box::default(),
identity,
)
.await
}
#[cfg(not(feature = "element_hacks"))]
let Some(UserIdentifier::UserIdOrLocalpart(username)) = identifier else {
return Err(Error::BadRequest(
ErrorKind::Unrecognized,
"Identifier type not recognized.",
));
};
/// Create a new UIAA session with a random session ID.
///
/// If information about the user's identity is already known, it may be
/// supplied with the `identity` parameter. Authentication will fail if
/// 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(
username.clone(),
self.services.globals.server_name(),
)
.map_err(|_| Error::BadRequest(ErrorKind::InvalidParam, "User ID is invalid."))?;
let session_id = utils::random_string(Self::SESSION_ID_LENGTH);
let mut info = UiaaInfo::new(flows, params);
info.session = Some(session_id.clone());
// Check if the access token being used matches the credentials used for UIAA
if user_id.localpart() != user_id_from_username.localpart() {
return Err!(Request(Forbidden("User ID and access token mismatch.")));
}
let user_id = user_id_from_username;
uiaa_sessions.insert(session_id, UiaaSession {
info: info.clone(),
identity: identity.unwrap_or_default(),
});
// Check if password is correct
let mut password_verified = false;
info
}
// First try local password hash verification
if let Ok(hash) = self.services.users.password_hash(&user_id).await {
password_verified = hash::verify_password(password, &hash).is_ok();
}
/// Proceed with UIAA authentication given a client's authorization data.
async fn continue_session(
&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
#[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();
}
}
}
let Entry::Occupied(mut session) = uiaa_sessions.entry(session.to_owned()) else {
return Err!(Request(InvalidParam("Invalid session")));
};
if !password_verified {
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) => {
let Some(ref private_site_key) = self.services.config.recaptcha_private_site_key
else {
return Err!(Request(Forbidden("ReCaptcha is not configured.")));
};
match recaptcha_verify::verify_v3(private_site_key, 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(_) => {
if let &AuthData::FallbackAcknowledgement(_) = auth {
// The client is checking if authentication has succeeded out-of-band. This is
// possible if the client is using "fallback auth" (see spec section
// 4.9.1.4), which we don't support (and probably never will, because it's a
@@ -232,109 +223,243 @@ pub async fn try_auth(
// Return early to tell the client that no, authentication did not succeed while
// it wasn't looking.
return Ok((false, uiaainfo));
},
| k => error!("type not supported: {:?}", k),
}
// Check if a flow now succeeds
let mut completed = false;
'flows: for flow in &mut uiaainfo.flows {
for stage in &flow.stages {
if !uiaainfo.completed.contains(stage) {
continue 'flows;
}
return Ok(Err(session.get().info.clone()));
}
let completed = {
let UiaaSession { info, identity } = session.get_mut();
let auth_type = auth.auth_type().expect("auth type should be set");
let flow_stages: Vec<HashSet<_>> = info
.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 {
self.update_uiaa_session(
user_id,
device_id,
uiaainfo.session.as_ref().expect("session is always set"),
Some(&uiaainfo),
);
/// Check if the provided authentication data is valid.
///
/// Returns the completed stage's type on success and error information on
/// failure.
async fn check_stage(
&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
self.update_uiaa_session(
user_id,
device_id,
uiaainfo.session.as_ref().expect("session is always set"),
None,
);
identity.try_set_email(email)?;
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)]
fn set_uiaa_request(
&self,
user_id: &UserId,
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());
}
if let Some(localpart) =
self.services.threepid.get_localpart_for_email(&email).await
{
identity.try_set_email(email)?;
#[implement(Service)]
pub fn get_uiaa_request(
&self,
user_id: &UserId,
device_id: Option<&DeviceId>,
session: &str,
) -> Option<CanonicalJsonValue> {
let key = (
user_id.to_owned(),
device_id.unwrap_or_else(|| EMPTY.into()).to_owned(),
session.to_owned(),
);
localpart
} else {
return Err(StandardErrorBody {
kind: ErrorKind::forbidden(),
message: "Invalid identifier or password".to_owned(),
});
}
},
| _ =>
return Err(StandardErrorBody {
kind: ErrorKind::Unrecognized,
message: "Identifier type not recognized".to_owned(),
}),
};
self.userdevicesessionid_uiaarequest
.read()
.get(&key)
.cloned()
}
let Ok(user_id) = UserId::parse_with_server_name(
user_id_or_localpart,
self.services.globals.server_name(),
) else {
return Err(StandardErrorBody {
kind: ErrorKind::InvalidParam,
message: "User ID is malformed".to_owned(),
});
};
#[implement(Service)]
fn update_uiaa_session(
&self,
user_id: &UserId,
device_id: &DeviceId,
session: &str,
uiaainfo: Option<&UiaaInfo>,
) {
let key = (user_id, device_id, session);
// Check if password is correct
let mut password_verified = false;
if let Some(uiaainfo) = uiaainfo {
self.db
.userdevicesessionid_uiaainfo
.put(key, Json(uiaainfo));
} else {
self.db.userdevicesessionid_uiaainfo.del(key);
// First try local password hash verification
if let Ok(hash) = self.services.users.password_hash(&user_id).await {
password_verified = hash::verify_password(password, &hash).is_ok();
}
// 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."))))
}

View File

@@ -90,6 +90,7 @@ pub fn build() -> Router<state::State> {
.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>| {

View File

@@ -1,17 +1,14 @@
use askama::Template;
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_handler))
.route("/_continuwuity/", get(index_handler))
.route("/", get(index))
.route("/_continuwuity/", get(index))
}
async fn index_handler(
State(services): State<crate::State>,
) -> Result<impl IntoResponse, WebError> {
async fn index(State(services): State<crate::State>) -> Result<impl IntoResponse, WebError> {
template! {
struct Index<'a> use "index.html.j2" {
server_name: &'a str,

View File

@@ -3,6 +3,7 @@
pub(super) mod index;
pub(super) mod password_reset;
pub(super) mod resources;
pub(super) mod threepid;
#[derive(Debug)]
pub(crate) struct TemplateContext {
@@ -43,6 +44,8 @@ fn new(state: &$crate::State, $($field_name: $field_type,)*) -> Self {
#[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()

View File

@@ -1,4 +1,3 @@
use askama::Template;
use axum::{
Router,
extract::{
@@ -20,11 +19,6 @@
const INVALID_TOKEN_ERROR: &str = "Invalid reset token. Your reset link may have expired.";
#[derive(Deserialize)]
struct PasswordResetQuery {
token: String,
}
template! {
struct PasswordReset<'a> use "password_reset.html.j2" {
user_card: UserCard<'a>,
@@ -63,6 +57,11 @@ pub(crate) fn build() -> Router<crate::State> {
.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,

View File

@@ -0,0 +1,8 @@
{% extends "_layout.html.j2" %}
{%- block content -%}
<div class="panel">
<h1>Email verification</h1>
<p>Your email address has been verified. Return to your Matrix client to continue.</p>
</div>
{%- endblock content -%}

39
src/web/pages/threepid.rs Normal file
View File

@@ -0,0 +1,39 @@
use axum::{
Router,
extract::{Query, State, rejection::QueryRejection},
response::IntoResponse,
routing::get,
};
use ruma::OwnedSessionId;
use serde::Deserialize;
use crate::{WebError, template};
template! {
struct ThreepidValidation use "threepid_validation.html.j2" {}
}
pub(crate) fn build() -> Router<crate::State> {
Router::new().route("/3pid/email/validate", get(threepid_validation))
}
#[derive(Deserialize)]
struct ThreepidValidationQuery {
session: OwnedSessionId,
token: String,
}
async fn threepid_validation(
State(services): State<crate::State>,
query: Result<Query<ThreepidValidationQuery>, QueryRejection>,
) -> Result<impl IntoResponse, WebError> {
let Query(query) = query?;
services
.threepid
.try_validate_session(&query.session, &query.token)
.await
.map_err(|message| WebError::BadRequest(message.into_owned()))?;
Ok(ThreepidValidation::new(&services))
}