Compare commits

...

116 Commits

Author SHA1 Message Date
Paul Makles
2eb596fe99 chore: pin crate versions to "MaxSRV" 2024-09-09 13:05:43 +01:00
Paul Makles
0bbb9e7072 docs: start work on default development configuration 2024-09-09 12:49:38 +01:00
Paul Makles
2433fa661a chore: lock Rust version to 1.77.2 (related to #341) 2024-09-09 12:49:09 +01:00
Paul Makles
70bdeecf77 feat(services/autumn): strip exif data for images/videos
feat(services/autumn): hash files and log information
2024-09-06 15:10:36 +01:00
Paul Makles
24dc96f80f feat(services/autumn): work towards upload API 2024-09-01 16:42:28 +01:00
Paul Makles
ebbbb5e174 feat(services/autumn): download and preview files 2024-09-01 13:58:11 +01:00
Paul Makles
78757ac7f1 fix: spelling issue 2024-08-31 17:16:23 +01:00
Paul Makles
01368960f3 feat(core/database, bindings/node): suspend user 2024-08-31 17:10:47 +01:00
Paul Makles
ae1d5d07e3 docs: Brexit 2024-08-31 16:04:59 +01:00
Paul Makles
5ad72abca7 refactor: move authifier config to database package 2024-08-31 16:04:40 +01:00
Paul Makles
c1b92ef56e feat: scaffold services/january 2024-08-30 19:48:58 +01:00
Paul Makles
acbc1b8956 feat(core/database): clear expired FCM tokens 2024-08-30 15:51:20 +01:00
Paul Makles
bce24c8b1b chore(scripts): add push to publish-debug-image.sh [skip ci] 2024-08-30 15:34:53 +01:00
Paul Makles
31262315e7 chore(fcm): just send the whole payload as a json string 2024-08-30 15:32:39 +01:00
Paul Makles
d8acaa1107 chore: strip information from fcm notification for testing 2024-08-30 12:46:37 +01:00
Paul Makles
730039eda6 chore: switch to distroless Docker runtime images
fix: match Debian releases for build

closes #297

Co-authored-by: MaticPoh <85814976+MaticPoh@users.noreply.github.com>
2024-08-30 12:06:05 +01:00
Paul Makles
79e1388000 fix: use MSRV for dockerfile 2024-08-29 20:27:47 +01:00
Paul Makles
5c8ece0727 fix: add new crates to docker build 2024-08-29 20:26:16 +01:00
Paul Makles
ab6b92aa2a chore(core/files): update toml 2024-08-29 20:22:09 +01:00
Paul Makles
239710951a chore: add revolt-files to justfile publish cmd 2024-08-29 20:20:47 +01:00
Paul Makles
28960979ec chore: bump version to 0.7.16 2024-08-29 20:18:34 +01:00
Paul Makles
1938ebc3fa feat(services/autumn): scaffold implementation of api 2024-08-29 20:18:17 +01:00
Paul Makles
1fb4032d9e refactor: switch from fcm to fcm_v1 crate 2024-08-29 20:17:52 +01:00
Paul Makles
ae2194cce8 chore: (wip) suspend bindings 2024-08-29 20:17:35 +01:00
Paul Makles
949a0cf649 chore(core/config): add configuration options 2024-08-29 20:17:10 +01:00
Paul Makles
f6c57b23b4 feat: bindings for node.js 2024-08-26 20:01:10 +01:00
Paul Makles
12ae781621 fix: detach rather than delete emoji during server deletion 2024-08-20 13:19:23 +01:00
Paul Makles
a52d610e6c ci: use docker compose instead of docker-compose
Signed-off-by: Paul Makles <me@insrt.uk>
2024-08-05 16:32:21 +01:00
Paul Makles
f513a9cb5e chore: bump version to 0.7.15 2024-08-05 16:22:18 +01:00
Paul Makles
535f01604b fix(core/database): include users from pin events 2024-08-05 16:21:00 +01:00
Paul Makles
f8ec6ba5ff fix(delta): add check to roles_fetch route 2024-08-05 16:20:21 +01:00
Paul Makles
b8fdc07508 refactor: remove inspect_err debug code 2024-08-05 16:20:21 +01:00
Paul Makles
5ce7530206 chore: bump nixpkgs and MSRV 2024-08-05 16:20:21 +01:00
Paul Makles
b36bac3225 merge: pull request #333 from revoltchat/feat/apns
APNS fixes
2024-08-05 17:17:29 +02:00
infi
beef06ebe3 chore: applemusic option for special embed
See https://github.com/revoltchat/january/pull/41

Signed-off-by: Infi <infi@infi.sh>
2024-08-05 17:16:24 +02:00
Zomatree
4fc46f765b refactor: change Channel::id to return a reference 2024-07-31 00:05:40 +01:00
Zomatree
b45ae2cd4d fix: include user for pinned system messages 2024-07-30 18:09:35 +01:00
Zomatree
0a8089787b chore: remove debugging logging 2024-07-30 18:06:54 +01:00
Paul Makles
475fa6f2e6 chore: add optional fields for CI as template [skip ci]
Signed-off-by: Paul Makles <me@insrt.uk>
2024-07-28 10:43:01 +01:00
Zomatree
96bf73cb57 fix: send pin system messages 2024-07-22 00:09:27 +01:00
Zomatree
87a9fb79f2 chore: Write tests for pinning 2024-07-21 22:22:46 +01:00
Zomatree
c50435d499 fix: Switch to remove for unpinning 2024-07-21 22:16:23 +01:00
Zomatree
d2f4823ceb chore: Add index and migration 2024-07-21 22:12:55 +01:00
Zomatree
39230c559a fix: Bump revolt_a2 dep 2024-07-20 22:43:12 +01:00
Zomatree
32d1d5df2e feat: Send badge updates from message acks 2024-07-20 20:49:22 +01:00
Zomatree
7547fbe245 Merge branch 'IAmTomahawkx-main' 2024-07-20 19:51:41 +01:00
IAmTomahawkx
9f13cb6f47 add Badge update capabilities
Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>
2024-07-19 13:13:13 -07:00
Tom
d94571fbf9 Merge branch 'main' into main
Signed-off-by: Tom <iamtomahawkx@gmail.com>
2024-07-19 09:20:42 -07:00
Zomatree
389ecc0e5c feat: Message pinning 2024-07-18 18:37:41 +01:00
Paul Makles
412f4a99d7 feat(core/events): add user settings / unreads to Ready payload
refactor(core/events): make all Ready payload items optional
2024-07-14 17:03:40 +01:00
Paul Makles
caa0795d07 fix(core/database); never include relations on known users 2024-07-14 16:27:11 +01:00
Paul Makles
5c82343bb3 chore: bump version to 0.7.14
Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>
2024-07-12 15:25:33 -07:00
Paul Makles
e17af1c064 feat: add reason to ServerMemberLeave event
closes #314

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>
2024-07-12 15:25:33 -07:00
Paul Makles
a9e309395e fix: execute query to remove users from groups
Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>
2024-07-12 15:25:33 -07:00
IAmTomahawkx
8468ce19cb fix apple push notifications
Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>
2024-07-12 15:25:33 -07:00
IAmTomahawkx
27f15f7b02 change apns to use sandbox, and (somewhat) provide a custom payload
Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>
2024-07-12 15:25:33 -07:00
Paul Makles
78b9fa168b chore: bump version to 0.7.14 2024-07-10 14:17:31 +01:00
Paul Makles
e290d168ac feat: add reason to ServerMemberLeave event
closes #314
2024-07-10 14:12:53 +01:00
Paul Makles
78cd89ec32 fix: execute query to remove users from groups 2024-07-10 14:04:17 +01:00
Paul Makles
f9d9059e73 fix: bot model test did not destruct tuple 2024-06-29 18:20:01 +01:00
Paul Makles
0954d71f51 chore: bump version to 0.7.13 2024-06-29 17:36:54 +01:00
Paul Makles
9ea2bd9f2f feat: implement apple push notifications 2024-06-29 17:35:47 +01:00
Paul Makles
d6bcb844db feat: include user in responses when creating and editing bots 2024-06-26 19:49:26 +01:00
Paul Makles
ce20e689cc refactor: fix spelling of "suppress" 2024-06-26 19:48:42 +01:00
Paul Makles
d9cfc9d0e1 chore: bump version to 0.7.12 2024-06-26 18:47:40 +01:00
Paul Makles
64a07d09f4 feat: silent messages and message flags 2024-06-26 18:46:05 +01:00
Paul Makles
00e881799f fix: replace user with target user when editing someone else 2024-06-26 18:24:38 +01:00
Paul Makles
fa598dd6f8 docs: update README.md
Signed-off-by: Paul Makles <me@insrt.uk>
2024-06-26 17:18:10 +01:00
Paul Makles
ac4e2cb10b ci: disable image builds if they're not tagged [skip ci]
(seems like a waste of CPU)
2024-06-26 15:08:12 +01:00
Paul Makles
ec578ab0ef chore: lessen restrictions on interacting with bot users as owner 2024-06-26 15:07:19 +01:00
Paul Makles
ea6ba59841 fix: add DisplayName back to user fields (prevented removing name) 2024-06-26 14:54:06 +01:00
Paul Makles
f903f716e3 chore: bump version to 0.7.11 2024-06-25 21:03:00 +01:00
Paul Makles
c5f4b94aa5 fix(bonfire): Ready event should include user with online status true (if applicable) 2024-06-25 21:02:27 +01:00
Paul Makles
1d5dae4751 feat(core): include user and member on Message events 2024-06-25 21:01:45 +01:00
Paul Makles
ac20b6bc99 chore: bump version to 0.7.10 2024-06-25 19:24:28 +01:00
Paul Makles
6ec8007e4e feat(core): separate limits for new user accounts 2024-06-25 19:20:34 +01:00
Paul Makles
e5eea267cf refactor(core/config): move limits to 'global' key 2024-06-25 18:10:23 +01:00
Paul Makles
de5add09d0 fix(core/database): only run outgoing friend checks if we are creating new request
closes #327
2024-06-25 18:07:16 +01:00
Paul Makles
1ec8f46c1d chore: bump version to 0.7.9 2024-06-23 19:39:32 +01:00
Paul Makles
80666848cc feat(core): add a limit to no. of outgoing pending friend requests 2024-06-23 19:39:18 +01:00
Paul Makles
e8e9613169 chore: bump version to 0.7.8 2024-06-21 16:13:16 +01:00
Paul Makles
eda36436a8 fix: specify configuration 2024-06-21 16:13:02 +01:00
Paul Makles
93e05e9f18 fix(bonfire): ignore all Redis errors but Canceled 2024-06-20 22:12:00 +01:00
Paul Makles
090d8cba71 chore: bump version to 0.7.7 2024-06-20 21:41:12 +01:00
Paul Makles
f16e72e329 fix(bonfire): drop connections if Redis disconnects 2024-06-20 21:40:44 +01:00
Paul Makles
8248a4a5b8 fix(bonfire): clean up Redis connection after disconnect 2024-06-20 21:30:47 +01:00
Paul Makles
cf0b4274c8 chore: bump version to 0.7.6 2024-06-19 18:25:11 +01:00
Paul Makles
36ecb48c7b feat(bonfire): don't fan out UserUpdate (server) by default 2024-06-19 18:24:51 +01:00
Paul Makles
ba411fef43 chore: bump version to 0.7.5 2024-06-15 11:02:30 +01:00
Paul Makles
4055363cff fix(core/database): fetch users mentioned in system messages 2024-06-15 11:02:09 +01:00
Paul Makles
d179d1e695 fix(delta): don't specify the other member in permission check
supersedes / closes #323
2024-06-15 11:01:03 +01:00
Paul Makles
fc9ceb7540 refactor(bonfire): add additional error handling 2024-06-15 10:51:00 +01:00
Paul Makles
a31a3e2e65 refactor: remove profile from v0 user, added unintentionally 2024-06-15 10:50:43 +01:00
Paul Makles
b12e728514 feat(core/result): implement std::error::Error for Error 2024-06-15 10:50:22 +01:00
Paul Makles
962c7d62c7 fix: allow fetching discoverable servers as invites 2024-06-11 18:46:07 +01:00
Paul Makles
d677716f93 docs: update development guide 2024-06-11 14:16:44 +01:00
Paul Makles
8099310f89 fix: wait on unwaited futures 2024-06-11 14:16:35 +01:00
Paul Makles
5c40f66010 fix: wrong match keyword causing disconnects on typing event 2024-06-11 14:01:59 +01:00
Paul Makles
8e7dd21bce docs: add note about creating GitHub release [skip ci] 2024-06-11 13:08:24 +01:00
Paul Makles
4868205df4 chore: bump version to 0.7.4 2024-06-11 12:52:45 +01:00
Paul Makles
96fb0eecca fix: don't allow sending typing notification to unknown channel
closes #177
2024-06-11 12:51:52 +01:00
Paul Makles
2cb20618da feat(bonfire): add disconnection mechanism
feat(bonfire): handle session deletion and logout events
feat(core): trigger logout on bot token reset
2024-06-11 12:38:32 +01:00
Paul Makles
92e948aabc chore: update README [skip ci] 2024-06-09 14:11:30 +01:00
Paul Makles
cb4435d700 chore: bump version to 0.7.3 2024-06-09 13:49:38 +01:00
Paul Makles
4c6e78e1a5 chore: relicense some core crates to MIT
chore: update/create LICENSE files
closes #278
closes #277
2024-06-09 13:47:22 +01:00
Paul Makles
da9a91e05f fix(bonfire): send InvalidSession error if token is not sent successfully
closes #289
2024-06-09 13:42:12 +01:00
Paul Makles
506968634e fix(core/database): set do not set online status if presence is Invisible
closes #34
2024-06-09 13:41:51 +01:00
Paul Makles
a5d0cdf0dd fix(core/database): skip insertion / deletion if no invites need to be corrected 2024-06-04 18:46:11 +01:00
Paul Makles
c59aad43c8 chore: bump version to 0.7.2 2024-05-16 15:44:09 +01:00
Paul Makles
19e72babc9 fix: must not use replace_one as it expects no change in _id 2024-05-16 15:43:33 +01:00
Paul Makles
bfcdb13d6d chore: remove cla workflow 2024-05-16 15:33:07 +01:00
Zomatree
878c1a83ad fix: continue fix 2024-05-15 22:57:34 +01:00
Zomatree
471e0f55e4 fix: Use correct enum tagging for invites 2024-05-15 22:48:20 +01:00
Paul Makles
08127218ce ci: fix path to pages artifact
ci: ignore changes to documentation for docker/rust builds
2024-05-11 17:50:52 +01:00
Paul Makles
b69202c222 ci: add deployment action for documentation 2024-05-11 17:42:50 +01:00
Paul Makles
b3494b12fc docs: create mdbook 2024-05-11 17:42:08 +01:00
175 changed files with 10623 additions and 1578 deletions

37
.github/workflows/book.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Build documentation
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: write # To push a branch
pages: write # To push to a GitHub Pages site
id-token: write # To update the deployment status
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install latest mdbook
run: |
tag=$(curl 'https://api.github.com/repos/rust-lang/mdbook/releases/latest' | jq -r '.tag_name')
url="https://github.com/rust-lang/mdbook/releases/download/${tag}/mdbook-${tag}-x86_64-unknown-linux-gnu.tar.gz"
mkdir mdbook
curl -sSL $url | tar -xz --directory=./mdbook
echo `pwd`/mdbook >> $GITHUB_PATH
- name: Build Book
run: |
cd doc
mdbook build
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: "doc/book"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -1,36 +0,0 @@
name: "CLA Assistant"
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened,closed,synchronize]
jobs:
CLAssistant:
runs-on: ubuntu-latest
steps:
- name: "CLA Assistant"
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
# Beta Release
uses: cla-assistant/github-action@v2.1.3-beta
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret
PERSONAL_ACCESS_TOKEN : ${{ secrets.PAT }}
with:
path-to-signatures: 'signatures/version1/cla.json'
path-to-document: 'https://github.com/revoltchat/cla/blob/master/CLA.md' # e.g. a CLA or a DCO document
# branch should not be protected
branch: 'master'
allowlist: insertish,bot*
#below are the optional inputs - If the optional inputs are not given, then default values will be taken
remote-organization-name: revoltchat
remote-repository-name: cla
create-file-commit-message: 'cla(create): creating file for storing CLA Signatures'
signed-commit-message: 'cla(sign): $contributorName has signed the CLA in #$pullRequestNo'
#custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign'
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
#use-dco-flag: true - If you are using DCO instead of CLA

View File

@@ -2,14 +2,15 @@ name: Docker Test & Publish
on:
push:
branches:
- "main"
# branches:
# - "main"
tags:
- "*"
paths-ignore:
- ".github/**"
- "!.github/workflows/docker.yml"
- ".vscode/**"
- "doc/**"
- ".gitignore"
- "LICENSE"
- "README"

View File

@@ -2,6 +2,14 @@ name: Rust build, test, and generate specification
on:
push:
paths-ignore:
- ".github/**"
- "!.github/workflows/rust.yaml"
- ".vscode/**"
- "doc/**"
- ".gitignore"
- "LICENSE"
- "README"
pull_request:
branches: [main]
@@ -33,7 +41,7 @@ jobs:
- name: Run services in background
run: |
docker-compose -f docker-compose.db.yml up -d
docker compose -f docker-compose.db.yml up -d
- name: Copy .env.example
run: |

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
Rocket.toml
Revolt.toml
Revolt.*.toml
target
.data

View File

@@ -1,6 +1,5 @@
{
"editor.formatOnSave": true,
"rust-analyzer.checkOnSave.command": "clippy",
"nixEnvSelector.suggestion": false,
"nixEnvSelector.nixFile": "${workspaceRoot}/default.nix"
"nixEnvSelector.suggestion": false
}

3348
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,12 @@
[workspace]
resolver = "2"
members = ["crates/delta", "crates/bonfire", "crates/core/*"]
members = [
"crates/delta",
"crates/bonfire",
"crates/core/*",
"crates/services/*",
"crates/bindings/*",
]
[patch.crates-io]
# mobc-redis = { git = "https://github.com/insertish/mobc", rev = "8b880bb59f2ba80b4c7bc40c649c113d8857a186" }

View File

@@ -1,5 +1,5 @@
# Build Stage
FROM --platform="${BUILDPLATFORM}" rust:1.70.0-slim
FROM --platform="${BUILDPLATFORM}" rust:1.77.2-slim-bookworm
USER 0:0
WORKDIR /home/rust/src
@@ -17,14 +17,18 @@ RUN sh /tmp/build-image-layer.sh tools
# Build all dependencies
COPY Cargo.toml Cargo.lock ./
COPY crates/bindings/node/Cargo.toml ./crates/bindings/node/
COPY crates/bonfire/Cargo.toml ./crates/bonfire/
COPY crates/delta/Cargo.toml ./crates/delta/
COPY crates/core/config/Cargo.toml ./crates/core/config/
COPY crates/core/database/Cargo.toml ./crates/core/database/
COPY crates/core/files/Cargo.toml ./crates/core/files/
COPY crates/core/models/Cargo.toml ./crates/core/models/
COPY crates/core/permissions/Cargo.toml ./crates/core/permissions/
COPY crates/core/presence/Cargo.toml ./crates/core/presence/
COPY crates/core/result/Cargo.toml ./crates/core/result/
COPY crates/services/autumn/Cargo.toml ./crates/services/autumn/
COPY crates/services/january/Cargo.toml ./crates/services/january/
RUN sh /tmp/build-image-layer.sh deps
# Build all apps

View File

@@ -1,5 +1,5 @@
# Build Stage
FROM rust:1.70.0-slim
FROM rust:1.77.2-slim-bookworm
USER 0:0
WORKDIR /home/rust/src
@@ -13,14 +13,18 @@ COPY scripts/build-image-layer.sh /tmp/
# Build all dependencies
COPY Cargo.toml Cargo.lock ./
COPY crates/bindings/node/Cargo.toml ./crates/bindings/node/
COPY crates/bonfire/Cargo.toml ./crates/bonfire/
COPY crates/delta/Cargo.toml ./crates/delta/
COPY crates/core/config/Cargo.toml ./crates/core/config/
COPY crates/core/database/Cargo.toml ./crates/core/database/
COPY crates/core/files/Cargo.toml ./crates/core/files/
COPY crates/core/models/Cargo.toml ./crates/core/models/
COPY crates/core/permissions/Cargo.toml ./crates/core/permissions/
COPY crates/core/presence/Cargo.toml ./crates/core/presence/
COPY crates/core/result/Cargo.toml ./crates/core/result/
COPY crates/services/autumn/Cargo.toml ./crates/services/autumn/
COPY crates/services/january/Cargo.toml ./crates/services/january/
RUN sh /tmp/build-image-layer.sh deps
# Build all apps

View File

@@ -1,4 +1,7 @@
GNU AFFERO GENERAL PUBLIC LICENSE
With the exception of crates that specify their own LICENSE file,
the following license applies to the source code of this project.
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>

View File

@@ -17,10 +17,15 @@ Note: `january`, `autumn`, and `vortex` are yet to be moved into this monorepo.
## Minimum Supported Rust Version
Rust 1.70 or higher.
Rust 1.76 or higher.
> [!CAUTION]
> The events server has a significant performance regression between Rust 1.77.2 and 1.78.0 onwards, see [issue #341](https://github.com/revoltchat/backend/issues/341).
## Development Guide
Before contributing, make yourself familiar with [our contribution guidelines](https://developers.revolt.chat/contrib.html) and the [technical documentation for this project](https://revoltchat.github.io/backend/).
Before getting started, you'll want to install:
- Rust toolchain (rustup recommended)
@@ -31,6 +36,20 @@ Before getting started, you'll want to install:
> A **default.nix** is available for Nix users!
> Just run `nix-shell` and continue.
As a heads-up, the development environment uses the following ports:
| Service | Port |
| ------------------------- | :------------: |
| MongoDB | 14017 |
| Redis | 14079 |
| MinIO | 14009 |
| Maildev | 14025<br>14080 |
| Revolt Web App | 14701 |
| `crates/delta` | 14702 |
| `crates/bonfire` | 14703 |
| `crates/services/autumn` | 14704 |
| `crates/services/january` | 14705 |
Now you can clone and build the project:
```bash
@@ -39,16 +58,19 @@ cd revolt-backend
cargo build
```
If you want to run the API and event servers:
A default configuration `Revolt.toml` is present in this project that is suited for development.
If you'd like to change anything, create a `Revolt.overrides.toml` file to overwrite it.
You may need to configure the legacy environment options:
```bash
# create environment file (will be deprecated in future)
cp .env.example .env
```
# (optionally) copy the default configuration file
cp crates/core/config/Revolt.toml Revolt.toml
# configure as necessary...
Then continue:
```bash
# start other necessary services
docker compose up -d
@@ -56,6 +78,10 @@ docker compose up -d
cargo run --bin revolt-delta
# run the events server
cargo run --bin revolt-bonfire
# run the file server
cargo run --bin revolt-autumn
# run th proxy server
cargo run --bin revolt-january
# hint:
# mold -run <cargo build, cargo run, etc...>
@@ -72,10 +98,10 @@ git clone --recursive https://github.com/revoltchat/revite
cd revite
yarn
yarn build:deps
yarn dev --port 3001
yarn dev --port 14701
```
Then go to https://local.revolt.chat:3001
Then go to https://local.revolt.chat:14701
## Deployment Guide
@@ -105,6 +131,23 @@ Tag and push a new release by running:
just release
```
If you have bumped the crate versions, proceed to [GitHub releases](https://github.com/revoltchat/backend/releases/new) to create a changelog.
## Testing
First, start the required services:
```sh
docker compose -f docker-compose.db.yml up -d
```
Now run tests for whichever database:
```sh
TEST_DB=REFERENCE cargo nextest run
TEST_DB=MONGODB cargo nextest run
```
## License
The Revolt backend is generally licensed under the [GNU Affero General Public License v3.0](https://github.com/revoltchat/backend/blob/master/LICENSE).

199
Revolt.toml Normal file
View File

@@ -0,0 +1,199 @@
# ⚠️ This configuration is intended for development environment.
# If you'd like to override anything, create a Revolt.override.toml
[database]
# MongoDB connection URL
# Defaults to the container name specified in self-hosted
mongodb = "mongodb://localhost:14017"
# Redis connection URL
# Defaults to the container name specified in self-hosted
redis = "redis://localhost:14079/"
[hosts]
# Web locations of various services
# Defaults assume all services are reverse-proxied
# See https://github.com/revoltchat/self-hosted/blob/master/Caddyfile
#
# Remember to change these to https/wss where appropriate in production!
app = "http://local.revolt.chat:14701"
api = "http://local.revolt.chat:14702"
events = "ws://local.revolt.chat:14703"
autumn = "http://local.revolt.chat:14704"
january = "http://local.revolt.chat:14705"
voso_legacy = ""
voso_legacy_ws = ""
[api]
[api.registration]
# Whether an invite should be required for registration
# See https://github.com/revoltchat/self-hosted#making-your-instance-invite-only
invite_only = false
[api.smtp]
# Email server configuration for verification
# Defaults to no email verification (host field is empty)
host = "localhost"
username = "smtp"
password = "smtp"
from_address = "development@revolt.chat"
reply_to = "support@revolt.chat"
port = 14025
use_tls = false
[api.vapid]
# Generate your own keys:
# 1. Run `openssl ecparam -name prime256v1 -genkey -noout -out vapid_private.pem`
# 2. Find `private_key` using `base64 vapid_private.pem`
# 3. Find `public_key` using `openssl ec -in vapid_private.pem -outform DER|tail -c 65|base64|tr '/+' '_-'|tr -d '\n'`
private_key = "LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUJSUWpyTWxLRnBiVWhsUHpUbERvcEliYk1yeVNrNXpKYzVYVzIxSjJDS3hvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFWnkrQkg2TGJQZ2hEa3pEempXOG0rUXVPM3pCajRXT1phdkR6ZU00c0pqbmFwd1psTFE0WAp1ZDh2TzVodU94QWhMQlU3WWRldVovWHlBdFpWZmNyQi9BPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo"
public_key = "BGcvgR-i2z4IQ5Mw841vJvkLjt8wY-FjmWrw83jOLCY52qcGZS0OF7nfLzuYbjsQISwVO2HXrmf18gLWVX3Kwfw="
[api.fcm]
# Google Firebase Cloud Messaging Service Account Key
# Obtained from the cloud messaging console
key_type = ""
project_id = ""
private_key_id = ""
private_key = ""
client_email = ""
client_id = ""
auth_uri = ""
token_uri = ""
auth_provider_x509_cert_url = ""
client_x509_cert_url = ""
[api.apn]
# Apple Push Notifications keys for sending notifications
sandbox = false
pkcs8 = ""
key_id = ""
team_id = ""
[api.security]
# Authifier Shield API key
authifier_shield_key = ""
# Legacy voice server management token
voso_legacy_token = ""
# Whether services are behind the Cloudflare network
trust_cloudflare = false
[api.security.captcha]
# hCaptcha configuration
hcaptcha_key = ""
hcaptcha_sitekey = ""
[api.workers]
# Maximum concurrent connections (to proxy server)
max_concurrent_connections = 50
[files]
# Encryption key for stored files
# Generate your own key using `openssl rand -base64 32`
encryption_key = "qcuMA+ssxhMyKaNAKBGFfryfFtUH8NDlamQyDwGW6fU="
# Quality used for lossy WebP previews (set to 100 for lossless)
webp_quality = 80.0
# Mime types that cannot be uploaded or served
#
# Example for Windows executables and Android installation files:
# ["application/vnd.microsoft.portable-executable", "application/vnd.android.package-archive"]
blocked_mime_types = []
# ClamAV service
# hostname:port
clamd_host = ""
[files.limit]
# Minimum image resolution
min_resolution = [1, 1]
# Maximum MP of images
max_mega_pixels = 40
# Maximum pixel side of an image
max_pixel_side = 10_000
[files.preview]
# Maximum image resolution
attachments = [1280, 1280]
avatars = [128, 128]
backgrounds = [1280, 720]
icons = [128, 128]
banners = [480, 480]
emojis = [128, 128]
[files.s3]
# Configuration for S3
# Defaults included for MinIO + self-hosted setup
#
# Backblaze B2:
# - endpoint is listed on the "Buckets" page
# - region is `eu-central-003` string from endpoint URL
# - access_key_id is keyID generated on the "Application Keys" page
# - secret_access_key is token generated on the "Application Keys" page
# - default_bucket matches the name of the bucket you've created
# S3 protocol endpoint
endpoint = "http://localhost:14009"
# S3 region name
region = "minio"
# S3 protocol key ID
access_key_id = "minioautumn"
# S3 protocol access key
secret_access_key = "minioautumn"
# Bucket to upload to by default
default_bucket = "revolt-uploads"
[features]
# Feature gate options
webhooks_enabled = false
[features.limits]
[features.limits.global]
group_size = 100
message_embeds = 5
message_replies = 5
message_reactions = 20
server_emoji = 100
server_roles = 200
server_channels = 200
# How many days since creation a user is considered new
new_user_days = 3
# Maximum permissible body size in bytes for uploads
# (should be greater than any one file upload limit)
body_limit_size = 20_000_000
[features.limits.new_user]
outgoing_friend_requests = 5
bots = 2
message_length = 2000
message_attachments = 5
servers = 100
attachment_size = 20_000_000
avatar_size = 4_000_000
background_size = 6_000_000
icon_size = 2_500_000
banner_size = 6_000_000
emoji_size = 500_000
[features.limits.default]
outgoing_friend_requests = 10
bots = 5
message_length = 2000
message_attachments = 5
servers = 100
attachment_size = 20_000_000
avatar_size = 4_000_000
background_size = 6_000_000
icon_size = 2_500_000
banner_size = 6_000_000
emoji_size = 500_000
[sentry]
# Configuration for Sentry error reporting
api = ""
events = ""

55
compose.yml Normal file
View File

@@ -0,0 +1,55 @@
services:
# Redis
redis:
image: eqalpha/keydb
ports:
- "14079:6379"
# MongoDB
database:
image: mongo
ports:
- "14017:27017"
volumes:
- ./.data/db:/data/db
# MinIO
minio:
image: minio/minio
command: server /data
environment:
MINIO_ROOT_USER: minioautumn
MINIO_ROOT_PASSWORD: minioautumn
volumes:
- ./.data/minio:/data
ports:
- "14009:9000"
restart: always
# Create buckets for minio.
createbuckets:
image: minio/mc
depends_on:
- minio
environment:
MINIO_ROOT_USER: minioautumn
MINIO_ROOT_PASSWORD: minioautumn
entrypoint: >
/bin/sh -c "
/usr/bin/mc config host add minio http://minio:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD;
while ! /usr/bin/mc ready minio; do echo 'Waiting minio...' && sleep 1; done;
/usr/bin/mc mb minio/revolt-uploads;
exit 0;
"
# Mock SMTP server
maildev:
image: soulteary/maildev
ports:
- "14025:8080"
- "14080:8080"
environment:
MAILDEV_SMTP_PORT: 25
MAILDEV_WEB_PORT: 8080
MAILDEV_INCOMING_USER: smtp
MAILDEV_INCOMING_PASS: smtp

7
crates/bindings/node/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
target
index.node
**/node_modules
**/.DS_Store
npm-debug.log*
cargo.log
cross.log

View File

@@ -0,0 +1,25 @@
[package]
name = "revolt-nodejs-bindings"
version = "0.7.16"
description = "Node.js bindings for the Revolt software"
authors = ["Paul Makles <me@insrt.uk>"]
license = "MIT"
edition = "2021"
exclude = ["index.node"]
[lib]
crate-type = ["cdylib"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
neon = "1.0.0"
neon-serde4 = "1.0.0"
serde = { version = "1", features = ["derive"] }
async-std = "1.12.0"
revolt-config = { version = "0.7.16", path = "../../core/config" }
revolt-result = { version = "0.7.16", path = "../../core/result" }
revolt-database = { version = "0.7.16", path = "../../core/database" }

View File

@@ -0,0 +1,92 @@
# revolt.node
**revolt.node:** Node.js bindings for the Revolt software
This project was bootstrapped by [create-neon](https://www.npmjs.com/package/create-neon).
## Building revolt.node
Building revolt.node requires a [supported version of Node and Rust](https://github.com/neon-bindings/neon#platform-support).
To run the build, run:
```sh
$ npm run build
```
This command uses the [@neon-rs/cli](https://www.npmjs.com/package/@neon-rs/cli) utility to assemble the binary Node addon from the output of `cargo`.
## Exploring revolt.node
After building revolt.node, you can explore its exports at the Node console:
```sh
$ npm i
$ npm run build
$ node
> require('.').hello()
'hello node'
```
## Available Scripts
In the project directory, you can run:
#### `npm install`
Installs the project, including running `npm run build`.
#### `npm run build`
Builds the Node addon (`index.node`) from source, generating a release build with `cargo --release`.
Additional [`cargo build`](https://doc.rust-lang.org/cargo/commands/cargo-build.html) arguments may be passed to `npm run build` and similar commands. For example, to enable a [cargo feature](https://doc.rust-lang.org/cargo/reference/features.html):
```
npm run build -- --feature=beetle
```
#### `npm run debug`
Similar to `npm run build` but generates a debug build with `cargo`.
#### `npm run cross`
Similar to `npm run build` but uses [cross-rs](https://github.com/cross-rs/cross) to cross-compile for another platform. Use the [`CARGO_BUILD_TARGET`](https://doc.rust-lang.org/cargo/reference/config.html#buildtarget) environment variable to select the build target.
#### `npm test`
Runs the unit tests by calling `cargo test`. You can learn more about [adding tests to your Rust code](https://doc.rust-lang.org/book/ch11-01-writing-tests.html) from the [Rust book](https://doc.rust-lang.org/book/).
## Project Layout
The directory structure of this project is:
```
revolt.node/
├── Cargo.toml
├── README.md
├── src/
| └── lib.rs
├── index.node
├── package.json
└── target/
```
| Entry | Purpose |
|----------------|------------------------------------------------------------------------------------------------------------------------------------------|
| `Cargo.toml` | The Cargo [manifest file](https://doc.rust-lang.org/cargo/reference/manifest.html), which informs the `cargo` command. |
| `README.md` | This file. |
| `src/` | The directory tree containing the Rust source code for the project. |
| `lib.rs` | Entry point for the Rust source code. |
| `index.node` | The main module, a [Node addon](https://nodejs.org/api/addons.html) generated by the build and pointed to by `"main"` in `package.json`. |
| `package.json` | The npm [manifest file](https://docs.npmjs.com/cli/v7/configuring-npm/package-json), which informs the `npm` command. |
| `target/` | Binary artifacts generated by the Rust build. |
## Learn More
Learn more about:
- [Neon](https://neon-bindings.com).
- [Rust](https://www.rust-lang.org).
- [Node](https://nodejs.org).

84
crates/bindings/node/index.d.ts vendored Normal file
View File

@@ -0,0 +1,84 @@
import { Channel, User } from "revolt-api";
/**
* Opaque type for Revolt database
*/
export declare interface Database {}
/**
* Opaque type for Revolt database
*/
export declare interface OpaqueUser {}
/**
* Error type from Revolt backend
*/
export declare interface Err {
type: string;
location: string;
}
/**
* Initialises background tasks and logging, must be called before anything else!
* Can be called multiple times!
*/
export declare function init();
/**
* Gets a new handle to the Revolt database
* @returns {Database} Handle
*/
export declare function database(): Database;
/**
* Fetch user from database
* @param {string} userId User's ID
* @this {Database}
*/
export declare function database_fetch_user(userId: string): OpaqueUser;
/**
* Fetch user from database
* @param {string} username Username
* @param {string} discriminator Discriminator
* @this {Database}
*/
export declare function database_fetch_user_by_username(
username: string,
discriminator: string
): OpaqueUser;
/**
* Gets model data as JSON
* @this {OpaqueUser}
*/
export declare function model_data(): User;
/**
* Gets error if the model failed to fetch
* @this {OpaqueUser}
*/
export declare function model_error(): Err;
/**
* Open a direct message channel between two users
* @param {string} userA User A ID
* @param {string} userB User B ID
* @returns Existing or newly created channel
*/
export declare function proc_channels_create_dm(
userA: string,
userB: string
): Promise<Channel & { error: Err }>;
/**
* Suspend a user
* @param {string} user User
* @param {number} duration Duration (in days), set to 0 for indefinite
* @param {string} reason Pipe-separated list of reasons (e.g. reason1|reason2|reason3)
*/
export declare function proc_users_suspend(
user: OpaqueUser,
duration: number,
reason: string
): Promise<{ error: Err }>;

View File

@@ -0,0 +1,36 @@
{
"name": "revolt-nodejs-bindings",
"version": "0.7.15-rev0.0.3",
"description": "Node.js bindings for the Revolt software",
"main": "index.node",
"scripts": {
"test": "cargo test",
"cargo-build": "cargo build --message-format=json > cargo.log",
"cross-build": "cross build --message-format=json > cross.log",
"postcargo-build": "neon dist < cargo.log",
"postcross-build": "neon dist -m /target < cross.log",
"debug": "npm run cargo-build --",
"build": "npm run cargo-build -- --release",
"cross": "npm run cross-build -- --release"
},
"author": "Paul Makles",
"license": "AGPL-3.0",
"devDependencies": {
"@neon-rs/cli": "0.1.73"
},
"repository": {
"type": "git",
"url": "git+https://github.com/revoltchat/backend"
},
"keywords": [
"revolt",
"chat"
],
"bugs": {
"url": "https://github.com/revoltchat/backend/issues"
},
"homepage": "https://github.com/revoltchat/backend#readme",
"dependencies": {
"revolt-api": "^0.7.15"
}
}

117
crates/bindings/node/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,117 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
dependencies:
revolt-api:
specifier: ^0.7.15
version: 0.7.15
devDependencies:
'@neon-rs/cli':
specifier: 0.1.73
version: 0.1.73
packages:
/@cargo-messages/android-arm-eabi@0.1.72:
resolution: {integrity: sha512-gGZxIM1mj+Y5x+ULND6ZCNr7f70OJi9wDlycSK8hGONy9wrChN6JAIHryddC5cqcwlYAoQ6IDcDFElnhAYbybA==}
cpu: [arm]
os: [android]
requiresBuild: true
dev: true
optional: true
/@cargo-messages/darwin-arm64@0.1.72:
resolution: {integrity: sha512-EAzN5MLaXPljZKZDO5qR+aBs44eSq2ZbEnS7AI/FziE3MzeXbrGOS3fLba5+7yWPFXJyZolXzePm8N1EBv8ovg==}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@cargo-messages/darwin-x64@0.1.72:
resolution: {integrity: sha512-RLo6j8s3nYbjdd1LDct4wamfChyRit7zokUuxtIYCu9XOlltkN5vnj1vwnrPvoqCMZ/7CbbuHFwSTn9A71de/w==}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@cargo-messages/linux-arm-gnueabihf@0.1.72:
resolution: {integrity: sha512-tHsRshuzfjrX6SDW3jg6al8vMNLTMgczGnVYl5RuBZf/yrAUuwe30KxA9ge6w6mW6Ox797DyBchzAc9OLgTgmQ==}
cpu: [arm]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@cargo-messages/linux-x64-gnu@0.1.72:
resolution: {integrity: sha512-VGtL6CCnUbhsP4aYuBNT5kfrAL7o0qjrxw97a+ax13t+nJd26tVEEIKHMu5drvvS/Nm/hn7sLT8zMnnCv0pvHg==}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@cargo-messages/win32-arm64-msvc@0.1.72:
resolution: {integrity: sha512-V93Cgz39K+yqa3MveNbhh29pYCp8izK5uEavjPoxlNxAbsMCWH+s0verGDdUcfGxjR1H2V7oZ4FszPqR2SqMRQ==}
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@cargo-messages/win32-x64-msvc@0.1.72:
resolution: {integrity: sha512-knz3uSrO0OSbq3U5VWfCY8FB4NsM43BOWLZ7x4sfaMOC1XWv+IyvDdkLe6DhJx8KUw46KIAimYs9YROrp6l46Q==}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@neon-rs/cli@0.1.73:
resolution: {integrity: sha512-1kv8S/feB6UQWQQwsnGfkSkEBOtlFDNExnioL81E2BwvUWgjQPaseHgpi2EpWVgsPUgur5eBm4QowmlpWkD4/w==}
hasBin: true
optionalDependencies:
'@cargo-messages/android-arm-eabi': 0.1.72
'@cargo-messages/darwin-arm64': 0.1.72
'@cargo-messages/darwin-x64': 0.1.72
'@cargo-messages/linux-arm-gnueabihf': 0.1.72
'@cargo-messages/linux-x64-gnu': 0.1.72
'@cargo-messages/win32-arm64-msvc': 0.1.72
'@cargo-messages/win32-x64-msvc': 0.1.72
dev: true
/axios@0.26.1:
resolution: {integrity: sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==}
dependencies:
follow-redirects: 1.15.6
transitivePeerDependencies:
- debug
dev: false
/follow-redirects@1.15.6:
resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
dev: false
/lodash.defaultsdeep@4.6.1:
resolution: {integrity: sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA==}
dev: false
/revolt-api@0.7.15:
resolution: {integrity: sha512-rWRnjBFvoXt/RHRxHg9KuP+x1Jk61KdsOpKOczVJg5tIqGDmqp/6QZHw5rJTtG+wCH3Ah3YKwY+MM5NHfg1W3Q==}
dependencies:
axios: 0.26.1
lodash.defaultsdeep: 4.6.1
transitivePeerDependencies:
- debug
dev: false

View File

@@ -0,0 +1,221 @@
#[macro_use]
extern crate serde;
use std::sync::OnceLock;
use neon::prelude::*;
use revolt_database::{Database, DatabaseInfo};
fn js_init(mut cx: FunctionContext) -> JsResult<JsUndefined> {
static INIT: OnceLock<()> = OnceLock::new();
if INIT.get().is_none() {
INIT.get_or_init(|| {
async_std::task::block_on(async {
revolt_config::configure!(api);
match DatabaseInfo::Auto.connect().await {
Ok(db) => {
let authifier_db = db.clone().to_authifier().await.database;
revolt_database::tasks::start_workers(db, authifier_db);
Ok(())
}
Err(err) => Err(err),
}
})
.or_else(|err| cx.throw_error(err))
.unwrap();
});
}
Ok(cx.undefined())
}
struct DatabaseBinding(Database, Channel);
impl Finalize for DatabaseBinding {}
impl DatabaseBinding {
fn take(&self) -> (Database, Channel) {
(self.0.clone(), self.1.clone())
}
}
fn js_database(mut cx: FunctionContext) -> JsResult<JsBox<DatabaseBinding>> {
let db = async_std::task::block_on(DatabaseInfo::Auto.connect())
.or_else(|err| cx.throw_error(err))?;
let channel = cx.channel();
Ok(cx.boxed(DatabaseBinding(db, channel)))
}
// Implementations for models
#[derive(Clone)]
#[allow(clippy::large_enum_variant)]
enum Model {
User(revolt_database::User),
Error(revolt_result::Error),
}
impl Model {
fn give(&self) -> Model {
self.clone()
}
}
impl Finalize for Model {}
macro_rules! shim_boxed {
($cx: ident, $name: ident, $model: ident, $( $variable: ident $type: ident $id: expr )+, $cmd: ident, $( $arg: expr, )+) => {
fn $name(mut cx: FunctionContext) -> JsResult<JsPromise> {
$(
let $variable = cx.argument::<$type>($id)?.value(&mut cx);
)+
let (db, channel) = cx.this::<JsBox<DatabaseBinding>>()?.take();
let (deferred, promise) = cx.promise();
async_std::task::spawn(async move {
let result = db.$cmd($($arg,)+).await;
deferred.settle_with(&channel, move |mut cx| {
Ok(cx.boxed(match result {
Ok(value) => Model::$model(value),
Err(error) => Model::Error(error)
}))
})
});
Ok(promise)
}
$cx.export_function(stringify!($name), $name)?;
};
}
fn js_data(mut cx: FunctionContext) -> JsResult<JsValue> {
match cx.this::<JsBox<Model>>()?.give() {
Model::Error(_) => neon_serde4::to_value(&mut cx, &None::<()>),
Model::User(user) => neon_serde4::to_value(&mut cx, &user),
}
.or_else(|e| cx.throw_error(e.to_string()))
}
fn js_error(mut cx: FunctionContext) -> JsResult<JsValue> {
let value = match cx.this::<JsBox<Model>>()?.give() {
Model::Error(err) => Some(err),
_ => None,
};
neon_serde4::to_value(&mut cx, &value).or_else(|e| cx.throw_error(e.to_string()))
}
// Basic data implementation
#[derive(Serialize, Deserialize)]
struct ResultBinding<T> {
#[serde(flatten)]
value: Option<T>,
error: Option<revolt_result::Error>,
}
macro_rules! shim {
($cx: ident, $name: ident, $( $variable: ident $type: ident $id: expr )*, $( $model: ident $modelType: ident $modelId: expr )*, | $db: ident | $closure: expr, $( $arg: expr, )+) => {
fn $name(mut cx: FunctionContext) -> JsResult<JsPromise> {
$(
let $variable = cx.argument::<$type>($id)?.value(&mut cx);
)*
$(
let mut $model = if let Model::$modelType(value) = cx.argument::<JsBox<Model>>($modelId)?.give() {
value
} else {
unreachable!()
};
)*
let (db, channel) = cx.this::<JsBox<DatabaseBinding>>()?.take();
let (deferred, promise) = cx.promise();
async_std::task::spawn(async move {
#[allow(clippy::redundant_closure_call)]
let result = (|$db: $crate::Database| $closure)(db.clone()).await;
deferred.settle_with(&channel, move |mut cx| {
neon_serde4::to_value(
&mut cx,
&match result {
Ok(value) => ResultBinding {
value: Some(value),
error: None,
},
Err(error) => ResultBinding {
value: None,
error: Some(error),
},
},
)
.or_else(|e| cx.throw_error(e.to_string()))
})
});
Ok(promise)
}
$cx.export_function(stringify!($name), $name)?;
};
}
#[neon::main]
fn main(mut cx: ModuleContext) -> NeonResult<()> {
// initialise required background stuff
cx.export_function("init", js_init)?;
// database & model stuff
cx.export_function("database", js_database)?;
cx.export_function("model_data", js_data)?;
cx.export_function("model_error", js_error)?;
shim_boxed!(
cx,
database_fetch_user,
User,
user_id JsString 0,
fetch_user,
&user_id,
);
shim_boxed!(
cx,
database_fetch_user_by_username,
User,
username JsString 0
discriminator JsString 1,
fetch_user_by_username,
&username, &discriminator,
);
// procedure calls
shim!(
cx,
proc_channels_create_dm,
user_a JsString 0
user_b JsString 1,
,
|db| async move {
let user_a = db.fetch_user(&user_a).await?;
let user_b = db.fetch_user(&user_b).await?;
revolt_database::Channel::create_dm(&db, &user_a, &user_b).await
},
&userA, &userB,
);
shim!(
cx,
proc_users_suspend,
duration JsNumber 1
reason JsString 2,
user User 0,
|db| async move {
let duration = duration as usize;
user.suspend(&db, if duration == 0 { None } else { Some(duration) }, Some(reason.split('|').map(|x| x.to_owned()).collect())).await
},
&user,
);
Ok(())
}

View File

@@ -0,0 +1,53 @@
const Internal = require(".");
// playing around with class wrapper, not practical
class Model {
constructor(model) {
this.model = model;
}
data() {
return Internal.model_data.bind(this.model)();
}
error() {
return Internal.model_error.bind(this.model)();
}
}
class User extends Model {
constructor(db, user) {
super(user);
this.db = db;
}
}
class Database {
constructor() {
this.db = Internal.database();
}
async fetchUser(userId) {
return new User(
this,
await Internal.database_fetch_user.bind(this.db)(userId)
);
}
async fetchUserByUsername(username, discriminator) {
return new User(
this,
await Internal.database_fetch_user_by_username.bind(this.db)(
username,
discriminator
)
);
}
}
const db = new Database();
db.fetchUserByUsername("dos", "7624").then((user) => console.info(user.data()));
db.fetchUserByUsername("dos", "1111").then((user) => console.info(user.data()));
db.fetchUserByUsername("dos", "1111").then((user) =>
console.info(user.error())
);

6
crates/bindings/package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "bindings",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-bonfire"
version = "0.7.1"
version = "0.7.16"
license = "AGPL-3.0-or-later"
edition = "2021"
@@ -14,6 +14,8 @@ lru = "0.7.6"
ulid = "0.5.0"
once_cell = "1.9.0"
redis-kiss = "0.1.4"
lru_time_cache = "0.11.11"
async-channel = "2.3.1"
# parsing
querystring = "1.1.0"
@@ -34,11 +36,12 @@ async-std = { version = "1.8.0", features = [
] }
# core
authifier = { version = "1.0.8" }
revolt-result = { path = "../core/result" }
revolt-models = { path = "../core/models" }
revolt-config = { path = "../core/config" }
revolt-database = { path = "../core/database" }
revolt-permissions = { version = "0.7.1", path = "../core/permissions" }
revolt-permissions = { version = "0.7.16", path = "../core/permissions" }
revolt-presence = { path = "../core/presence", features = ["redis-is-patched"] }
# redis

View File

@@ -2,10 +2,10 @@
FROM ghcr.io/revoltchat/base:latest AS builder
# Bundle Stage
FROM debian:bullseye-slim
RUN apt-get update && \
apt-get install -y ca-certificates && \
apt-get clean
FROM gcr.io/distroless/cc-debian12:nonroot
COPY --from=builder /home/rust/src/target/release/revolt-bonfire ./
EXPOSE 9000
USER nonroot
ENV HOST=0.0.0.0:9000
CMD ["./revolt-bonfire"]

View File

@@ -1,5 +1,6 @@
use async_tungstenite::tungstenite::{handshake, Message};
use futures::channel::oneshot::Sender;
use revolt_database::events::client::ReadyPayloadFields;
use revolt_result::{create_error, Result};
use serde::{Deserialize, Serialize};
@@ -83,6 +84,17 @@ impl ProtocolConfiguration {
pub fn get_protocol_format(&self) -> &ProtocolFormat {
&self.format
}
/// Get ready payload fields
pub fn get_ready_payload_fields(&self) -> Vec<ReadyPayloadFields> {
vec![
ReadyPayloadFields::Users,
ReadyPayloadFields::Servers,
ReadyPayloadFields::Channels,
ReadyPayloadFields::Members,
ReadyPayloadFields::Emoji,
]
}
}
/// Object holding one side of a channel for receiving the parsed information

View File

@@ -1,8 +1,9 @@
use std::collections::HashSet;
use revolt_database::{
events::client::EventV1, util::permissions::DatabasePermissionQuery, Channel, Database, Member,
MemberCompositeKey, Presence, RelationshipStatus,
events::client::{EventV1, ReadyPayloadFields},
util::permissions::DatabasePermissionQuery,
Channel, Database, Member, MemberCompositeKey, Presence, RelationshipStatus,
};
use revolt_models::v0;
use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
@@ -21,11 +22,11 @@ impl Cache {
let server = self.servers.get(server);
let mut query =
DatabasePermissionQuery::new(db, self.users.get(&self.user_id).unwrap())
.channel(&channel);
.channel(channel);
// let mut perms = perms(self.users.get(&self.user_id).unwrap()).channel(channel);
if let Some(member) = member {
query = query.member(&member);
query = query.member(member);
}
if let Some(server) = server {
@@ -90,8 +91,13 @@ impl Cache {
/// State Manager
impl State {
/// Generate a Ready packet for the current user
pub async fn generate_ready_payload(&mut self, db: &Database) -> Result<EventV1> {
pub async fn generate_ready_payload(
&mut self,
db: &Database,
fields: Vec<ReadyPayloadFields>,
) -> Result<EventV1> {
let user = self.clone_user();
self.cache.is_bot = user.bot.is_some();
// Find all relationships to the user.
let mut user_ids: HashSet<String> = user
@@ -149,14 +155,36 @@ impl State {
.await?;
// Fetch customisations.
let emojis = db
.fetch_emoji_by_parent_ids(
&servers
.iter()
.map(|x| x.id.to_string())
.collect::<Vec<String>>(),
let emojis = if fields.contains(&ReadyPayloadFields::Emoji) {
Some(
db.fetch_emoji_by_parent_ids(
&servers
.iter()
.map(|x| x.id.to_string())
.collect::<Vec<String>>(),
)
.await?,
)
.await?;
} else {
None
};
// Fetch user settings
let user_settings = if let Some(ReadyPayloadFields::UserSettings(keys)) = fields
.iter()
.find(|e| matches!(e, ReadyPayloadFields::UserSettings(_)))
{
Some(db.fetch_user_settings(&user.id, &keys).await?)
} else {
None
};
// Fetch channel unreads
let channel_unreads = if fields.contains(&ReadyPayloadFields::ChannelUnreads) {
Some(db.fetch_unreads(&user.id).await?)
} else {
None
};
// Copy data into local state cache.
self.cache.users = users.iter().cloned().map(|x| (x.id.clone(), x)).collect();
@@ -179,30 +207,52 @@ impl State {
.collect();
// Make sure we see our own user correctly.
users.push(user.into_self().await);
users.push(user.into_self(true).await);
// Set subscription state internally.
self.reset_state();
self.insert_subscription(self.private_topic.clone());
self.reset_state().await;
self.insert_subscription(self.private_topic.clone()).await;
for user in &users {
self.insert_subscription(user.id.clone());
self.insert_subscription(user.id.clone()).await;
}
for server in &servers {
self.insert_subscription(server.id.clone());
self.insert_subscription(server.id.clone()).await;
if self.cache.is_bot {
self.insert_subscription(format!("{}u", server.id)).await;
}
}
for channel in &channels {
self.insert_subscription(channel.id().to_string());
self.insert_subscription(channel.id().to_string()).await;
}
Ok(EventV1::Ready {
users,
servers: servers.into_iter().map(Into::into).collect(),
channels: channels.into_iter().map(Into::into).collect(),
members: members.into_iter().map(Into::into).collect(),
emojis: emojis.into_iter().map(Into::into).collect(),
users: if fields.contains(&ReadyPayloadFields::Users) {
Some(users)
} else {
None
},
servers: if fields.contains(&ReadyPayloadFields::Servers) {
Some(servers.into_iter().map(Into::into).collect())
} else {
None
},
channels: if fields.contains(&ReadyPayloadFields::Channels) {
Some(channels.into_iter().map(Into::into).collect())
} else {
None
},
members: if fields.contains(&ReadyPayloadFields::Members) {
Some(members.into_iter().map(Into::into).collect())
} else {
None
},
emojis: emojis.map(|vec| vec.into_iter().map(Into::into).collect()),
user_settings,
channel_unreads: channel_unreads.map(|vec| vec.into_iter().map(Into::into).collect()),
})
}
@@ -236,11 +286,11 @@ impl State {
let mut bulk_events = vec![];
for id in added_channels {
self.insert_subscription(id);
self.insert_subscription(id).await;
}
for id in removed_channels {
self.remove_subscription(&id);
self.remove_subscription(&id).await;
self.cache.channels.remove(&id);
bulk_events.push(EventV1::ChannelDelete { id });
@@ -263,7 +313,7 @@ impl State {
.channels
.insert(channel.id().to_string(), channel.clone());
self.insert_subscription(channel.id().to_string());
self.insert_subscription(channel.id().to_string()).await;
bulk_events.push(EventV1::ChannelCreate(channel.into()));
}
}
@@ -336,7 +386,7 @@ impl State {
match event {
EventV1::ChannelCreate(channel) => {
let id = channel.id().to_string();
self.insert_subscription(id.clone());
self.insert_subscription(id.clone()).await;
self.cache.channels.insert(id, channel.clone().into());
}
EventV1::ChannelUpdate {
@@ -376,17 +426,17 @@ impl State {
}
}
EventV1::ChannelDelete { id } => {
self.remove_subscription(id);
self.remove_subscription(id).await;
self.cache.channels.remove(id);
}
EventV1::ChannelGroupJoin { user, .. } => {
self.insert_subscription(user.clone());
self.insert_subscription(user.clone()).await;
}
EventV1::ChannelGroupLeave { id, user, .. } => {
if user == &self.cache.user_id {
self.remove_subscription(id);
self.remove_subscription(id).await;
} else if !self.cache.can_subscribe_to_user(user) {
self.remove_subscription(user);
self.remove_subscription(user).await;
}
}
@@ -396,7 +446,12 @@ impl State {
channels,
emojis: _,
} => {
self.insert_subscription(id.clone());
self.insert_subscription(id.clone()).await;
if self.cache.is_bot {
self.insert_subscription(format!("{}u", id)).await;
}
self.cache.servers.insert(id.clone(), server.clone().into());
let member = Member {
id: MemberCompositeKey {
@@ -433,13 +488,13 @@ impl State {
EventV1::ServerMemberJoin { .. } => {
// We will always receive ServerCreate when joining a new server.
}
EventV1::ServerMemberLeave { id, user } => {
EventV1::ServerMemberLeave { id, user, .. } => {
if user == &self.cache.user_id {
self.remove_subscription(id);
self.remove_subscription(id).await;
if let Some(server) = self.cache.servers.remove(id) {
for channel in &server.channels {
self.remove_subscription(channel);
self.remove_subscription(channel).await;
self.cache.channels.remove(channel);
}
}
@@ -447,11 +502,11 @@ impl State {
}
}
EventV1::ServerDelete { id } => {
self.remove_subscription(id);
self.remove_subscription(id).await;
if let Some(server) = self.cache.servers.remove(id) {
for channel in &server.channels {
self.remove_subscription(channel);
self.remove_subscription(channel).await;
self.cache.channels.remove(channel);
}
}
@@ -524,9 +579,23 @@ impl State {
self.cache.users.insert(id.clone(), user.clone().into());
if self.cache.can_subscribe_to_user(id) {
self.insert_subscription(id.clone());
self.insert_subscription(id.clone()).await;
} else {
self.remove_subscription(id);
self.remove_subscription(id).await;
}
}
EventV1::Message(message) => {
// Since Message events are fanned out to many clients,
// we must reconstruct the relationship value at this end.
if let Some(user) = &mut message.user {
user.relationship = self
.cache
.users
.get(&self.cache.user_id)
.expect("missing self?")
.relationship_with(&message.author)
.into();
}
}
@@ -540,11 +609,11 @@ impl State {
// Sub / unsub accordingly.
if let Some(id) = queue_add {
self.insert_subscription(id);
self.insert_subscription(id).await;
}
if let Some(id) = queue_remove {
self.remove_subscription(&id);
self.remove_subscription(&id).await;
}
true

View File

@@ -1,6 +1,12 @@
use std::collections::{HashMap, HashSet};
use std::{
collections::{HashMap, HashSet},
sync::Arc,
time::Duration,
};
use async_std::sync::{Mutex, RwLock};
use lru::LruCache;
use lru_time_cache::{LruCache as LruTimeCache, TimedEntry};
use revolt_database::{Channel, Member, Server, User};
/// Enumeration representing some change in subscriptions
@@ -30,6 +36,7 @@ pub enum SubscriptionStateChange {
#[derive(Debug)]
pub struct Cache {
pub user_id: String,
pub is_bot: bool,
pub users: HashMap<String, User>,
pub channels: HashMap<String, Channel>,
@@ -43,6 +50,7 @@ impl Default for Cache {
fn default() -> Self {
Cache {
user_id: Default::default(),
is_bot: false,
users: Default::default(),
channels: Default::default(),
@@ -58,14 +66,17 @@ impl Default for Cache {
pub struct State {
pub cache: Cache,
pub session_id: String,
pub private_topic: String,
subscribed: HashSet<String>,
state: SubscriptionStateChange,
pub state: SubscriptionStateChange,
pub subscribed: Arc<RwLock<HashSet<String>>>,
pub active_servers: Arc<Mutex<LruTimeCache<String, ()>>>,
}
impl State {
/// Create state from User
pub fn from(user: User) -> State {
pub fn from(user: User, session_id: String) -> State {
let mut subscribed = HashSet::new();
let private_topic = format!("{}!", user.id);
subscribed.insert(private_topic.clone());
@@ -80,22 +91,61 @@ impl State {
State {
cache,
subscribed,
subscribed: Arc::new(RwLock::new(subscribed)),
active_servers: Arc::new(Mutex::new(LruTimeCache::with_expiry_duration_and_capacity(
Duration::from_secs(900),
5,
))),
session_id,
private_topic,
state: SubscriptionStateChange::Reset,
}
}
/// Apply currently queued state
pub fn apply_state(&mut self) -> SubscriptionStateChange {
pub async fn apply_state(&mut self) -> SubscriptionStateChange {
// Check if we need to change subscriptions to member event topics
if !self.cache.is_bot {
enum Server {
Subscribe(String),
Unsubscribe(String),
}
let active_server_changes: Vec<Server> = {
let mut active_servers = self.active_servers.lock().await;
active_servers
.notify_iter()
.map(|e| match e {
TimedEntry::Valid(k, _) => Server::Subscribe(format!("{}u", k)),
TimedEntry::Expired(k, _) => Server::Unsubscribe(format!("{}u", k)),
})
.collect()
// It is bad practice to open more than one Mutex at once and could
// lead to a deadlock, so instead we choose to collect the changes.
};
for entry in active_server_changes {
match entry {
Server::Subscribe(k) => {
self.insert_subscription(k).await;
}
Server::Unsubscribe(k) => {
self.remove_subscription(&k).await;
}
}
}
}
// Flush changes to subscriptions
let state = std::mem::replace(&mut self.state, SubscriptionStateChange::None);
let mut subscribed = self.subscribed.write().await;
if let SubscriptionStateChange::Change { add, remove } = &state {
for id in add {
self.subscribed.insert(id.clone());
subscribed.insert(id.clone());
}
for id in remove {
self.subscribed.remove(id);
subscribed.remove(id);
}
}
@@ -107,20 +157,16 @@ impl State {
self.cache.users.get(&self.cache.user_id).unwrap().clone()
}
/// Iterate through all subscriptions
pub fn iter_subscriptions(&self) -> std::collections::hash_set::Iter<'_, std::string::String> {
self.subscribed.iter()
}
/// Reset the current state
pub fn reset_state(&mut self) {
pub async fn reset_state(&mut self) {
self.state = SubscriptionStateChange::Reset;
self.subscribed.clear();
self.subscribed.write().await.clear();
}
/// Add a new subscription
pub fn insert_subscription(&mut self, subscription: String) {
if self.subscribed.contains(&subscription) {
pub async fn insert_subscription(&mut self, subscription: String) {
let mut subscribed = self.subscribed.write().await;
if subscribed.contains(&subscription) {
return;
}
@@ -137,12 +183,13 @@ impl State {
SubscriptionStateChange::Reset => {}
}
self.subscribed.insert(subscription);
subscribed.insert(subscription);
}
/// Remove existing subscription
pub fn remove_subscription(&mut self, subscription: &str) {
if !self.subscribed.contains(&subscription.to_string()) {
pub async fn remove_subscription(&mut self, subscription: &str) {
let mut subscribed = self.subscribed.write().await;
if !subscribed.contains(&subscription.to_string()) {
return;
}
@@ -159,6 +206,6 @@ impl State {
SubscriptionStateChange::Reset => panic!("Should not remove during a reset!"),
}
self.subscribed.remove(subscription);
subscribed.remove(subscription);
}
}

View File

@@ -22,8 +22,8 @@ async fn main() {
clear_region(None).await;
// Setup a TCP listener to accept WebSocket connections on.
// By default, we bind to port 9000 on all interfaces.
let bind = env::var("HOST").unwrap_or_else(|_| "0.0.0.0:9000".into());
// By default, we bind to port 14703 on all interfaces.
let bind = env::var("HOST").unwrap_or_else(|_| "0.0.0.0:14703".into());
info!("Listening on host {bind}");
let try_socket = TcpListener::bind(bind).await;
let listener = try_socket.expect("Failed to bind");

View File

@@ -1,13 +1,15 @@
use std::net::SocketAddr;
use std::{collections::HashSet, net::SocketAddr, sync::Arc};
use async_tungstenite::WebSocketStream;
use authifier::AuthifierEvent;
use fred::{
error::{RedisError, RedisErrorKind},
interfaces::{ClientLike, EventInterface, PubsubInterface},
types::RedisConfig,
};
use futures::{
channel::oneshot,
pin_mut, select,
join, pin_mut, select,
stream::{SplitSink, SplitStream},
FutureExt, SinkExt, StreamExt, TryStreamExt,
};
@@ -18,7 +20,13 @@ use revolt_database::{
};
use revolt_presence::{create_session, delete_session};
use async_std::{net::TcpStream, sync::Mutex};
use async_std::{
net::TcpStream,
sync::{Mutex, RwLock},
task::spawn,
};
use revolt_result::create_error;
use sentry::Level;
use crate::config::{ProtocolConfiguration, WebsocketHandshakeCallback};
use crate::events::state::{State, SubscriptionStateChange};
@@ -42,10 +50,12 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
else {
return;
};
// Verify we've received a valid config, otherwise we should just drop the connection.
let Ok(mut config) = receiver.await else {
return;
};
info!(
"User {addr:?} provided protocol configuration (version = {}, format = {:?})",
config.get_protocol_version(),
@@ -57,10 +67,8 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
// If the user has not provided authentication, request information.
if config.get_session_token().is_none() {
while let Ok(message) = read.try_next().await {
if let Ok(ClientMessage::Authenticate { token }) =
config.decode(message.as_ref().unwrap())
{
while let Ok(Some(message)) = read.try_next().await {
if let Ok(ClientMessage::Authenticate { token }) = config.decode(&message) {
config.set_session_token(token);
break;
}
@@ -69,35 +77,49 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
// Try to authenticate the user.
let Some(token) = config.get_session_token().as_ref() else {
write
.send(config.encode(&create_error!(InvalidSession)))
.await
.ok();
return;
};
let user = match User::from_token(db, token, UserHint::Any).await {
let (user, session_id) = match User::from_token(db, token, UserHint::Any).await {
Ok(user) => user,
Err(err) => {
write.send(config.encode(&err)).await.ok();
return;
}
};
info!("User {addr:?} authenticated as @{}", user.username);
// Create local state.
let mut state = State::from(user);
let mut state = State::from(user, session_id);
let user_id = state.cache.user_id.clone();
// Notify socket we have authenticated.
if write
.send(config.encode(&EventV1::Authenticated))
.await
.is_err()
{
if let Err(err) = write.send(config.encode(&EventV1::Authenticated)).await {
error!("Failed to write: {err:?}");
sentry::capture_error(&err);
return;
}
// Download required data to local cache and send Ready payload.
let Ok(ready_payload) = state.generate_ready_payload(db).await else {
return;
let ready_payload = match state
.generate_ready_payload(db, config.get_ready_payload_fields())
.await
{
Ok(ready_payload) => ready_payload,
Err(err) => {
sentry::capture_error(&err);
return;
}
};
if write.send(config.encode(&ready_payload)).await.is_err() {
if let Err(err) = write.send(config.encode(&ready_payload)).await {
error!("Failed to write: {err:?}");
sentry::capture_error(&err);
return;
}
@@ -110,20 +132,44 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
}
{
// Setup channels and mutexes
let write = Mutex::new(write);
let subscribed = state.subscribed.clone();
let active_servers = state.active_servers.clone();
let (topic_signal_s, topic_signal_r) = async_channel::unbounded();
// TODO: this needs to be rewritten
// Create channels through which the tasks can signal to each other they need to clean up
let (kill_signal_1_s, kill_signal_1_r) = async_channel::bounded(1);
let (kill_signal_2_s, kill_signal_2_r) = async_channel::bounded(1);
// Create a PubSub connection to poll on.
let listener = listener(db, &mut state, addr, &config, &write).fuse();
// Read from WebSocket stream.
let worker = worker(addr, user_id.clone(), &config, read, &write).fuse();
// Pin both tasks.
pin_mut!(listener, worker);
// Wait for either disconnect or for listener to die.
select!(
() = listener => {},
() = worker => {}
let listener = listener_with_kill_signal(
db,
&mut state,
addr,
&config,
topic_signal_r,
kill_signal_1_r,
&write,
kill_signal_2_s,
);
// Read from WebSocket stream.
let worker = worker_with_kill_signal(
addr,
subscribed,
active_servers,
user_id.clone(),
&config,
topic_signal_s,
kill_signal_2_r,
read,
&write,
kill_signal_1_s,
);
join!(listener, worker);
}
// Clean up presence session.
let last_session = delete_session(&user_id, session_id).await;
@@ -134,29 +180,88 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
}
}
#[allow(clippy::too_many_arguments)]
async fn listener_with_kill_signal(
db: &'static Database,
state: &mut State,
addr: SocketAddr,
config: &ProtocolConfiguration,
topic_signal_r: async_channel::Receiver<()>,
kill_signal_r: async_channel::Receiver<()>,
write: &Mutex<WsWriter>,
kill_signal_s: async_channel::Sender<()>,
) {
listener(
db,
state,
addr,
config,
topic_signal_r,
kill_signal_r,
write,
)
.await;
kill_signal_s.send(()).await.ok();
}
async fn listener(
db: &'static Database,
state: &mut State,
addr: SocketAddr,
config: &ProtocolConfiguration,
topic_signal_r: async_channel::Receiver<()>,
kill_signal_r: async_channel::Receiver<()>,
write: &Mutex<WsWriter>,
) {
let redis_config = RedisConfig::from_url(&REDIS_URI).unwrap();
let Ok(subscriber) = fred::types::Builder::from_config(redis_config).build_subscriber_client()
else {
return;
};
if subscriber.init().await.is_err() {
let subscriber = match fred::types::Builder::from_config(redis_config).build_subscriber_client()
{
Ok(subscriber) => subscriber,
Err(err) => {
error!("Failed to build a subscriber: {err:?}");
sentry::capture_error(&err);
return;
}
};
if let Err(err) = subscriber.init().await {
error!("Failed to init subscriber: {err:?}");
sentry::capture_error(&err);
return;
};
// Handle Redis connection dropping
let (clean_up_s, clean_up_r) = async_channel::bounded(1);
let clean_up_s = Arc::new(Mutex::new(clean_up_s));
subscriber.on_error(move |err| {
if let RedisErrorKind::Canceled = err.kind() {
let clean_up_s = clean_up_s.clone();
spawn(async move {
clean_up_s.lock().await.send(()).await.ok();
});
}
Ok(())
});
let mut message_rx = subscriber.message_rx();
loop {
'out: loop {
// Check for state changes for subscriptions.
match state.apply_state() {
match state.apply_state().await {
SubscriptionStateChange::Reset => {
subscriber.unsubscribe_all().await.unwrap();
for id in state.iter_subscriptions() {
subscriber.subscribe(id).await.unwrap();
if let Err(err) = subscriber.unsubscribe_all().await {
error!("Unsubscribe all failed: {err:?}");
sentry::capture_error(&err);
break 'out;
}
let subscribed = state.subscribed.read().await;
for id in subscribed.iter() {
if let Err(err) = subscriber.subscribe(id).await {
error!("Subscribe failed: {err:?}");
sentry::capture_error(&err);
break 'out;
}
}
#[cfg(debug_assertions)]
@@ -167,112 +272,255 @@ async fn listener(
#[cfg(debug_assertions)]
info!("{addr:?} unsubscribing from {id}");
subscriber.unsubscribe(id).await.unwrap();
if let Err(err) = subscriber.unsubscribe(id).await {
error!("Unsubscribe failed: {err:?}");
sentry::capture_error(&err);
break 'out;
}
}
for id in add {
#[cfg(debug_assertions)]
info!("{addr:?} subscribing to {id}");
subscriber.subscribe(id).await.unwrap();
if let Err(err) = subscriber.subscribe(id).await {
error!("Subscribe failed: {err:?}");
sentry::capture_error(&err);
break 'out;
}
}
}
SubscriptionStateChange::None => {}
}
// Handle incoming events.
let Ok(message) = message_rx.recv().await.map_err(|e| {
warn!("Error while consuming pub/sub messages: {e:?}");
sentry::capture_error(&e);
}) else {
return;
};
let event = match *REDIS_PAYLOAD_TYPE {
PayloadType::Json => message
.value
.as_str()
.and_then(|s| serde_json::from_str::<EventV1>(s.as_ref()).ok()),
PayloadType::Msgpack => message
.value
.as_bytes()
.and_then(|b| rmp_serde::from_slice::<EventV1>(b).ok()),
PayloadType::Bincode => message
.value
.as_bytes()
.and_then(|b| bincode::deserialize::<EventV1>(b).ok()),
};
let Some(mut event) = event else {
warn!("Failed to deserialise an event for {}!", message.channel);
return;
};
let should_send = state.handle_incoming_event_v1(db, &mut event).await;
if !should_send {
continue;
}
let t1 = message_rx.recv().fuse();
let t2 = topic_signal_r.recv().fuse();
let t3 = kill_signal_r.recv().fuse();
let t4 = clean_up_r.recv().fuse();
let result = write.lock().await.send(config.encode(&event)).await;
if let Err(e) = result {
use async_tungstenite::tungstenite::Error;
if !matches!(e, Error::AlreadyClosed | Error::ConnectionClosed) {
warn!("Error while sending an event to {addr:?}: {e:?}");
pin_mut!(t1, t2, t3, t4);
select! {
_ = t4 => {
break 'out;
},
_ = t3 => {
break 'out;
},
_ = t2 => {},
message = t1 => {
// Handle incoming events.
let message = match message {
Ok(message) => message,
Err(e) => {
error!("Error while consuming pub/sub messages: {e:?}");
sentry::capture_error(&e);
break 'out;
}
};
let event = match *REDIS_PAYLOAD_TYPE {
PayloadType::Json => message
.value
.as_str()
.and_then(|s| serde_json::from_str::<EventV1>(s.as_ref()).ok()),
PayloadType::Msgpack => message
.value
.as_bytes()
.and_then(|b| rmp_serde::from_slice::<EventV1>(b).ok()),
PayloadType::Bincode => message
.value
.as_bytes()
.and_then(|b| bincode::deserialize::<EventV1>(b).ok()),
};
let Some(mut event) = event else {
let err = format!(
"Failed to deserialise an event for {}! Introspection: `{:?}`",
message.channel,
message
.value
.as_string()
.map(|x| x.chars().take(32).collect::<String>())
);
error!("{}", err);
sentry::capture_message(&err, Level::Error);
break 'out;
};
if let EventV1::Auth(auth) = &event {
if let AuthifierEvent::DeleteSession { session_id, .. } = auth {
if &state.session_id == session_id {
event = EventV1::Logout;
}
} else if let AuthifierEvent::DeleteAllSessions {
exclude_session_id, ..
} = auth
{
if let Some(excluded) = exclude_session_id {
if &state.session_id != excluded {
event = EventV1::Logout;
}
} else {
event = EventV1::Logout;
}
}
} else {
let should_send = state.handle_incoming_event_v1(db, &mut event).await;
if !should_send {
continue;
}
}
let result = write.lock().await.send(config.encode(&event)).await;
if let Err(e) = result {
use async_tungstenite::tungstenite::Error;
if !matches!(e, Error::AlreadyClosed | Error::ConnectionClosed) {
let err = format!("Error while sending an event to {addr:?}: {e:?}");
warn!("{}", err);
sentry::capture_message(&err, Level::Warning);
}
break 'out;
}
if let EventV1::Logout = event {
info!("User {addr:?} received log out event!");
break 'out;
}
}
return;
}
}
if let Err(err) = subscriber.quit().await {
error!("{}", err);
sentry::capture_error(&err);
}
}
async fn worker(
#[allow(clippy::too_many_arguments)]
async fn worker_with_kill_signal(
addr: SocketAddr,
subscribed: Arc<RwLock<HashSet<String>>>,
active_servers: Arc<Mutex<lru_time_cache::LruCache<String, ()>>>,
user_id: String,
config: &ProtocolConfiguration,
topic_signal_s: async_channel::Sender<()>,
kill_signal_r: async_channel::Receiver<()>,
read: WsReader,
write: &Mutex<WsWriter>,
kill_signal_s: async_channel::Sender<()>,
) {
worker(
addr,
subscribed,
active_servers,
user_id,
config,
topic_signal_s,
kill_signal_r,
read,
write,
)
.await;
kill_signal_s.send(()).await.ok();
}
#[allow(clippy::too_many_arguments)]
async fn worker(
addr: SocketAddr,
subscribed: Arc<RwLock<HashSet<String>>>,
active_servers: Arc<Mutex<lru_time_cache::LruCache<String, ()>>>,
user_id: String,
config: &ProtocolConfiguration,
topic_signal_s: async_channel::Sender<()>,
kill_signal_r: async_channel::Receiver<()>,
mut read: WsReader,
write: &Mutex<WsWriter>,
) {
loop {
let result = read.try_next().await;
let msg = match result {
Ok(Some(msg)) => msg,
Ok(None) => return,
Err(e) => {
use async_tungstenite::tungstenite::Error;
if !matches!(e, Error::AlreadyClosed | Error::ConnectionClosed) {
warn!("Error while reading an event from {addr:?}: {e:?}");
}
return;
}
};
let t1 = read.try_next().fuse();
let t2 = kill_signal_r.recv().fuse();
let Ok(payload) = config.decode(&msg) else {
continue;
};
match payload {
ClientMessage::BeginTyping { channel } => {
EventV1::ChannelStartTyping {
id: channel.clone(),
user: user_id.clone(),
}
.p(channel.clone())
.await;
}
ClientMessage::EndTyping { channel } => {
EventV1::ChannelStopTyping {
id: channel.clone(),
user: user_id.clone(),
}
.p(channel.clone())
.await;
}
ClientMessage::Ping { data, responded } => {
if responded.is_none() {
write
.lock()
.await
.send(config.encode(&EventV1::Pong { data }))
.await
.ok();
pin_mut!(t1, t2);
select! {
_ = t2 => {
return;
},
result = t1 => {
let msg = match result {
Ok(Some(msg)) => msg,
Ok(None) => {
warn!("Received a None message!");
sentry::capture_message("Received a None message!", Level::Warning);
return;
}
Err(e) => {
use async_tungstenite::tungstenite::Error;
if !matches!(e, Error::AlreadyClosed | Error::ConnectionClosed) {
let err = format!("Error while reading an event from {addr:?}: {e:?}");
warn!("{}", err);
sentry::capture_message(&err, Level::Warning);
}
return;
}
};
let Ok(payload) = config.decode(&msg) else {
continue;
};
match payload {
ClientMessage::BeginTyping { channel } => {
if !subscribed.read().await.contains(&channel) {
continue;
}
EventV1::ChannelStartTyping {
id: channel.clone(),
user: user_id.clone(),
}
.p(channel.clone())
.await;
}
ClientMessage::EndTyping { channel } => {
if !subscribed.read().await.contains(&channel) {
continue;
}
EventV1::ChannelStopTyping {
id: channel.clone(),
user: user_id.clone(),
}
.p(channel.clone())
.await;
}
ClientMessage::Subscribe { server_id } => {
let mut servers = active_servers.lock().await;
let has_item = servers.contains_key(&server_id);
servers.insert(server_id, ());
if !has_item {
// Poke the listener to adjust subscriptions
topic_signal_s.send(()).await.ok();
}
}
ClientMessage::Ping { data, responded } => {
if responded.is_none() {
write
.lock()
.await
.send(config.encode(&EventV1::Pong { data }))
.await
.ok();
}
}
_ => {}
}
}
_ => {}
}
}
}

View File

@@ -1,8 +1,8 @@
[package]
name = "revolt-config"
version = "0.7.1"
version = "0.7.16"
edition = "2021"
license = "AGPL-3.0-or-later"
license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"]
description = "Revolt Backend: Configuration"

View File

@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2024 Pawel Makles
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,8 +1,17 @@
[database]
# MongoDB connection URL
# Defaults to the container name specified in self-hosted
mongodb = "mongodb://database"
# Redis connection URL
# Defaults to the container name specified in self-hosted
redis = "redis://redis/"
[hosts]
# Web locations of various services
# Defaults assume all services are reverse-proxied
# See https://github.com/revoltchat/self-hosted/blob/master/Caddyfile
#
# Remember to change these to https/wss where appropriate in production!
app = "http://local.revolt.chat"
api = "http://local.revolt.chat/api"
events = "ws://local.revolt.chat/ws"
@@ -14,58 +23,174 @@ voso_legacy_ws = ""
[api]
[api.registration]
# Whether an invite should be required for registration
# See https://github.com/revoltchat/self-hosted#making-your-instance-invite-only
invite_only = false
[api.smtp]
# Email server configuration for verification
# Defaults to no email verification (host field is empty)
host = ""
username = ""
password = ""
from_address = ""
from_address = "noreply@example.com"
# reply_to = "noreply@example.com"
# port = 587
# use_tls = true
[api.vapid]
# Generate your own keys:
# 1. Run `openssl ecparam -name prime256v1 -genkey -noout -out vapid_private.pem`
# 2. Find `private_key` using `base64 vapid_private.pem`
# 3. Find `public_key` using `openssl ec -in vapid_private.pem -outform DER|tail -c 65|base64|tr '/+' '_-'|tr -d '\n'`
private_key = "LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUJSUWpyTWxLRnBiVWhsUHpUbERvcEliYk1yeVNrNXpKYzVYVzIxSjJDS3hvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFWnkrQkg2TGJQZ2hEa3pEempXOG0rUXVPM3pCajRXT1phdkR6ZU00c0pqbmFwd1psTFE0WAp1ZDh2TzVodU94QWhMQlU3WWRldVovWHlBdFpWZmNyQi9BPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo"
public_key = "BGcvgR-i2z4IQ5Mw841vJvkLjt8wY-FjmWrw83jOLCY52qcGZS0OF7nfLzuYbjsQISwVO2HXrmf18gLWVX3Kwfw="
[api.fcm]
api_key = ""
# Google Firebase Cloud Messaging Service Account Key
# Obtained from the cloud messaging console
key_type = ""
project_id = ""
private_key_id = ""
private_key = ""
client_email = ""
client_id = ""
auth_uri = ""
token_uri = ""
auth_provider_x509_cert_url = ""
client_x509_cert_url = ""
[api.apn]
# Apple Push Notifications keys for sending notifications
sandbox = false
pkcs8 = ""
key_id = ""
team_id = ""
[api.security]
# Authifier Shield API key
authifier_shield_key = ""
# Legacy voice server management token
voso_legacy_token = ""
# Whether services are behind the Cloudflare network
trust_cloudflare = false
[api.security.captcha]
# hCaptcha configuration
hcaptcha_key = ""
hcaptcha_sitekey = ""
[api.workers]
# Maximum concurrent connections (to proxy server)
max_concurrent_connections = 50
[files]
# Encryption key for stored files
# Generate your own key using `openssl rand -base64 32`
encryption_key = "qcuMA+ssxhMyKaNAKBGFfryfFtUH8NDlamQyDwGW6fU="
# Quality used for lossy WebP previews (set to 100 for lossless)
webp_quality = 80.0
# Mime types that cannot be uploaded or served
#
# Example for Windows executables and Android installation files:
# ["application/vnd.microsoft.portable-executable", "application/vnd.android.package-archive"]
blocked_mime_types = []
# ClamAV service
# hostname:port
clamd_host = ""
[files.limit]
# Minimum image resolution
min_resolution = [1, 1]
# Maximum MP of images
max_mega_pixels = 40
# Maximum pixel side of an image
max_pixel_side = 10_000
[files.preview]
# Maximum image resolution
attachments = [1280, 1280]
avatars = [128, 128]
backgrounds = [1280, 720]
icons = [128, 128]
banners = [480, 480]
emojis = [128, 128]
[files.s3]
# Configuration for S3
# Defaults included for MinIO + self-hosted setup
#
# Backblaze B2:
# - endpoint is listed on the "Buckets" page
# - region is `eu-central-003` string from endpoint URL
# - access_key_id is keyID generated on the "Application Keys" page
# - secret_access_key is token generated on the "Application Keys" page
# - default_bucket matches the name of the bucket you've created
# S3 protocol endpoint
endpoint = "http://minio:9000"
# S3 region name
region = "minio"
# S3 protocol key ID
access_key_id = "minioautumn"
# S3 protocol access key
secret_access_key = "minioautumn"
# Bucket to upload to by default
default_bucket = "revolt-uploads"
[features]
# Feature gate options
webhooks_enabled = false
[features.limits]
[features.limits.default]
[features.limits.global]
group_size = 100
bots = 5
message_length = 2000
message_embeds = 5
message_replies = 5
message_attachments = 5
message_reactions = 20
servers = 100
server_emoji = 100
server_roles = 200
server_channels = 200
attachment_size = 20000000
avatar_size = 4000000
background_size = 6000000
icon_size = 2500000
banner_size = 6000000
emoji_size = 500000
# How many days since creation a user is considered new
new_user_days = 3
# Maximum permissible body size in bytes for uploads
# (should be greater than any one file upload limit)
body_limit_size = 20_000_000
[features.limits.new_user]
outgoing_friend_requests = 5
bots = 2
message_length = 2000
message_attachments = 5
servers = 100
attachment_size = 20_000_000
avatar_size = 4_000_000
background_size = 6_000_000
icon_size = 2_500_000
banner_size = 6_000_000
emoji_size = 500_000
[features.limits.default]
outgoing_friend_requests = 10
bots = 5
message_length = 2000
message_attachments = 5
servers = 100
attachment_size = 20_000_000
avatar_size = 4_000_000
background_size = 6_000_000
icon_size = 2_500_000
banner_size = 6_000_000
emoji_size = 500_000
[sentry]
# Configuration for Sentry error reporting
api = ""
events = ""

View File

@@ -6,6 +6,8 @@ use futures_locks::RwLock;
use once_cell::sync::Lazy;
use serde::Deserialize;
pub use sentry::capture_error;
#[cfg(not(debug_assertions))]
use std::env;
@@ -23,6 +25,8 @@ static CONFIG_BUILDER: Lazy<RwLock<Config>> = Lazy::new(|| {
));
} else if std::path::Path::new("Revolt.toml").exists() {
builder = builder.add_source(File::new("Revolt.toml", FileFormat::Toml));
} else if std::path::Path::new("/Revolt.toml").exists() {
builder = builder.add_source(File::new("/Revolt.toml", FileFormat::Toml));
}
builder.build().unwrap()
@@ -72,7 +76,24 @@ pub struct ApiVapid {
#[derive(Deserialize, Debug, Clone)]
pub struct ApiFcm {
pub api_key: String,
pub key_type: String,
pub project_id: String,
pub private_key_id: String,
pub private_key: String,
pub client_email: String,
pub client_id: String,
pub auth_uri: String,
pub token_uri: String,
pub auth_provider_x509_cert_url: String,
pub client_x509_cert_url: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiApn {
pub sandbox: bool,
pub pkcs8: String,
pub key_id: String,
pub team_id: String,
}
#[derive(Deserialize, Debug, Clone)]
@@ -100,24 +121,63 @@ pub struct Api {
pub smtp: ApiSmtp,
pub vapid: ApiVapid,
pub fcm: ApiFcm,
pub apn: ApiApn,
pub security: ApiSecurity,
pub workers: ApiWorkers,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FeaturesLimits {
pub struct FilesLimit {
pub min_resolution: [usize; 2],
pub max_mega_pixels: usize,
pub max_pixel_side: usize,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FilesS3 {
pub endpoint: String,
pub region: String,
pub access_key_id: String,
pub secret_access_key: String,
pub default_bucket: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Files {
pub encryption_key: String,
pub webp_quality: f32,
pub blocked_mime_types: Vec<String>,
pub clamd_host: String,
pub limit: FilesLimit,
pub preview: HashMap<String, [usize; 2]>,
pub s3: FilesS3,
}
#[derive(Deserialize, Debug, Clone)]
pub struct GlobalLimits {
pub group_size: usize,
pub bots: usize,
pub message_length: usize,
pub message_replies: usize,
pub message_attachments: usize,
pub message_embeds: usize,
pub message_replies: usize,
pub message_reactions: usize,
pub servers: usize,
pub server_emoji: usize,
pub server_roles: usize,
pub server_channels: usize,
pub new_user_days: usize,
pub body_limit_size: usize,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FeaturesLimits {
pub outgoing_friend_requests: usize,
pub bots: usize,
pub message_length: usize,
pub message_attachments: usize,
pub servers: usize,
pub attachment_size: usize,
pub avatar_size: usize,
pub background_size: usize,
@@ -128,6 +188,9 @@ pub struct FeaturesLimits {
#[derive(Deserialize, Debug, Clone)]
pub struct FeaturesLimitsCollection {
pub global: GlobalLimits,
pub new_user: FeaturesLimits,
pub default: FeaturesLimits,
#[serde(flatten)]
@@ -151,6 +214,7 @@ pub struct Settings {
pub database: Database,
pub hosts: Hosts,
pub api: Api,
pub files: Files,
pub features: Features,
pub sentry: Sentry,
}
@@ -158,22 +222,10 @@ pub struct Settings {
impl Settings {
pub fn preflight_checks(&self) {
if self.api.smtp.host.is_empty() {
#[cfg(not(debug_assertions))]
if !env::var("REVOLT_UNSAFE_NO_EMAIL").map_or(false, |v| v == *"1") {
panic!("Running in production without email is not recommended, set REVOLT_UNSAFE_NO_EMAIL=1 to override.");
}
#[cfg(debug_assertions)]
log::warn!("No SMTP settings specified! Remember to configure email.");
}
if self.api.security.captcha.hcaptcha_key.is_empty() {
#[cfg(not(debug_assertions))]
if !env::var("REVOLT_UNSAFE_NO_CAPTCHA").map_or(false, |v| v == *"1") {
panic!("Running in production without CAPTCHA is not recommended, set REVOLT_UNSAFE_NO_CAPTCHA=1 to override.");
}
#[cfg(debug_assertions)]
log::warn!("No Captcha key specified! Remember to add hCaptcha key.");
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-database"
version = "0.7.1"
version = "0.7.16"
edition = "2021"
license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"]
@@ -23,13 +23,13 @@ default = ["mongodb", "async-std-runtime", "tasks"]
[dependencies]
# Core
revolt-config = { version = "0.7.1", path = "../config" }
revolt-result = { version = "0.7.1", path = "../result" }
revolt-models = { version = "0.7.1", path = "../models", features = [
revolt-config = { version = "0.7.16", path = "../config" }
revolt-result = { version = "0.7.16", path = "../result" }
revolt-models = { version = "0.7.16", path = "../models", features = [
"validator",
] }
revolt-presence = { version = "0.7.1", path = "../presence" }
revolt-permissions = { version = "0.7.1", path = "../permissions", features = [
revolt-presence = { version = "0.7.16", path = "../presence" }
revolt-permissions = { version = "0.7.16", path = "../permissions", features = [
"serde",
"bson",
] }
@@ -85,8 +85,9 @@ revolt_okapi = { version = "0.9.1", optional = true }
revolt_rocket_okapi = { version = "0.9.1", optional = true }
# Notifications
fcm = "0.9.2"
fcm_v1 = "0.3.0"
web-push = "0.10.0"
revolt_a2 = { version = "0.10", default-features = false, features = ["ring"] }
# Authifier
authifier = { version = "1.0.8" }

View File

@@ -1,6 +1,14 @@
mod mongodb;
mod reference;
use authifier::config::Captcha;
use authifier::config::EmailVerificationConfig;
use authifier::config::ResolveIp;
use authifier::config::SMTPSettings;
use authifier::config::Shield;
use authifier::config::Template;
use authifier::config::Templates;
use authifier::Authifier;
use rand::Rng;
use revolt_config::config;
@@ -87,3 +95,87 @@ impl DatabaseInfo {
})
}
}
impl Database {
/// Create an Authifier reference
pub async fn to_authifier(self) -> Authifier {
let config = config().await;
let mut auth_config = authifier::Config {
email_verification: if !config.api.smtp.host.is_empty() {
EmailVerificationConfig::Enabled {
smtp: SMTPSettings {
from: config.api.smtp.from_address,
host: config.api.smtp.host,
username: config.api.smtp.username,
password: config.api.smtp.password,
reply_to: Some(
config
.api
.smtp
.reply_to
.unwrap_or("support@revolt.chat".into()),
),
port: config.api.smtp.port,
use_tls: config.api.smtp.use_tls,
},
expiry: Default::default(),
templates: Templates {
verify: Template {
title: "Verify your Revolt account.".into(),
text: include_str!("../../templates/verify.txt").into(),
url: format!("{}/login/verify/", config.hosts.app),
html: Some(include_str!("../../templates/verify.html").into()),
},
reset: Template {
title: "Reset your Revolt password.".into(),
text: include_str!("../../templates/reset.txt").into(),
url: format!("{}/login/reset/", config.hosts.app),
html: Some(include_str!("../../templates/reset.html").into()),
},
deletion: Template {
title: "Confirm account deletion.".into(),
text: include_str!("../../templates/deletion.txt").into(),
url: format!("{}/delete/", config.hosts.app),
html: Some(include_str!("../../templates/deletion.html").into()),
},
welcome: None,
},
}
} else {
EmailVerificationConfig::Disabled
},
..Default::default()
};
auth_config.invite_only = config.api.registration.invite_only;
if !config.api.security.captcha.hcaptcha_key.is_empty() {
auth_config.captcha = Captcha::HCaptcha {
secret: config.api.security.captcha.hcaptcha_key,
};
}
if !config.api.security.authifier_shield_key.is_empty() {
auth_config.shield = Shield::Enabled {
api_key: config.api.security.authifier_shield_key,
strict: false,
};
}
if config.api.security.trust_cloudflare {
auth_config.resolve_ip = ResolveIp::Cloudflare;
}
Authifier {
database: match self {
Database::Reference(_) => Default::default(),
Database::MongoDb(MongoDb(client, _)) => authifier::Database::MongoDb(
authifier::database::MongoDb(client.database("revolt")),
),
},
config: auth_config,
event_channel: Some(crate::tasks::authifier_relay::sender()),
}
}
}

View File

@@ -3,7 +3,7 @@ use std::{collections::HashMap, sync::Arc};
use futures::lock::Mutex;
use crate::{
Bot, Channel, ChannelCompositeKey, ChannelUnread, Emoji, File, Invite, Member,
Bot, Channel, ChannelCompositeKey, ChannelUnread, Emoji, File, FileHash, Invite, Member,
MemberCompositeKey, Message, RatelimitEvent, Report, Server, ServerBan, Snapshot, User,
UserSettings, Webhook,
};
@@ -18,6 +18,7 @@ database_derived!(
pub channel_unreads: Arc<Mutex<HashMap<ChannelCompositeKey, ChannelUnread>>>,
pub channel_webhooks: Arc<Mutex<HashMap<String, Webhook>>>,
pub emojis: Arc<Mutex<HashMap<String, Emoji>>>,
pub file_hashes: Arc<Mutex<HashMap<String, FileHash>>>,
pub files: Arc<Mutex<HashMap<String, File>>>,
pub messages: Arc<Mutex<HashMap<String, Message>>>,
pub ratelimit_events: Arc<Mutex<HashMap<String, RatelimitEvent>>>,

View File

@@ -2,10 +2,7 @@ use authifier::AuthifierEvent;
use serde::{Deserialize, Serialize};
use revolt_models::v0::{
AppendMessage, Channel, Emoji, FieldsChannel, FieldsMember, FieldsRole, FieldsServer,
FieldsUser, FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialMember,
PartialMessage, PartialRole, PartialServer, PartialUser, PartialWebhook, Report, Server, User,
UserSettings, Webhook,
AppendMessage, Channel, ChannelUnread, Emoji, FieldsChannel, FieldsMember, FieldsMessage, FieldsRole, FieldsServer, FieldsUser, FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser, PartialWebhook, RemovalIntention, Report, Server, User, UserSettings, Webhook
};
use revolt_result::Error;
@@ -39,6 +36,19 @@ pub enum ErrorEvent {
APIError(Error),
}
/// Fields provided in Ready payload
#[derive(PartialEq)]
pub enum ReadyPayloadFields {
Users,
Servers,
Channels,
Members,
Emoji,
UserSettings(Vec<String>),
ChannelUnreads,
}
/// Protocol Events
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(tag = "type")]
@@ -48,13 +58,25 @@ pub enum EventV1 {
/// Successfully authenticated
Authenticated,
/// Logged out
Logout,
/// Basic data to cache
Ready {
users: Vec<User>,
servers: Vec<Server>,
channels: Vec<Channel>,
members: Vec<Member>,
emojis: Vec<Emoji>,
#[serde(skip_serializing_if = "Option::is_none")]
users: Option<Vec<User>>,
#[serde(skip_serializing_if = "Option::is_none")]
servers: Option<Vec<Server>>,
#[serde(skip_serializing_if = "Option::is_none")]
channels: Option<Vec<Channel>>,
#[serde(skip_serializing_if = "Option::is_none")]
members: Option<Vec<Member>>,
#[serde(skip_serializing_if = "Option::is_none")]
emojis: Option<Vec<Emoji>>,
#[serde(skip_serializing_if = "Option::is_none")]
user_settings: Option<UserSettings>,
#[serde(skip_serializing_if = "Option::is_none")]
channel_unreads: Option<Vec<ChannelUnread>>,
},
/// Ping response
@@ -67,6 +89,7 @@ pub enum EventV1 {
id: String,
channel: String,
data: PartialMessage,
clear: Vec<FieldsMessage>,
},
/// Append information to existing message
@@ -134,7 +157,11 @@ pub enum EventV1 {
ServerMemberJoin { id: String, user: String },
/// User left server
ServerMemberLeave { id: String, user: String },
ServerMemberLeave {
id: String,
user: String,
reason: RemovalIntention,
},
/// Server role created or updated
ServerRoleUpdate {
@@ -247,7 +274,7 @@ impl EventV1 {
// TODO: this should be captured by member list in the future and not immediately fanned out to users
if let Ok(members) = db.fetch_all_memberships(&id).await {
for member in members {
self.clone().p(member.id.server).await;
self.clone().server(member.id.server).await;
}
}
}
@@ -257,6 +284,11 @@ impl EventV1 {
self.p(format!("{id}!")).await;
}
/// Publish server member event
pub async fn server(self, id: String) {
self.p(format!("{id}u")).await;
}
/// Publish internal global event
pub async fn global(self) {
self.p("global".to_string()).await;

View File

@@ -8,5 +8,6 @@ pub enum ClientMessage {
Authenticate { token: String },
BeginTyping { channel: String },
EndTyping { channel: String },
Subscribe { server_id: String },
Ping { data: Ping, responded: Option<()> },
}

View File

@@ -87,3 +87,8 @@ pub mod tasks;
pub fn if_false(t: &bool) -> bool {
!t
}
/// Utility function to check if an option doesnt contain true
pub fn if_option_false(t: &Option<bool>) -> bool {
t != &Some(true)
}

View File

@@ -146,7 +146,14 @@ pub async fn create_database(db: &MongoDb) {
"author": 1_i32
},
"name": "author"
}
},
{
"key": {
"channel": 1_i32,
"pinned": 1_i32
},
"name": "channel_pinned_compound"
},
]
},
None,

View File

@@ -5,8 +5,9 @@ use crate::{
bson::{doc, from_bson, from_document, to_document, Bson, DateTime, Document},
options::FindOptions,
},
MongoDb, DISCRIMINATOR_SEARCH_SPACE,
Invite, MongoDb, DISCRIMINATOR_SEARCH_SPACE,
};
use bson::oid::ObjectId;
use futures::StreamExt;
use rand::seq::SliceRandom;
use revolt_permissions::DEFAULT_WEBHOOK_PERMISSIONS;
@@ -19,7 +20,7 @@ struct MigrationInfo {
revision: i32,
}
pub const LATEST_REVISION: i32 = 26;
pub const LATEST_REVISION: i32 = 28;
pub async fn migrate_database(db: &MongoDb) {
let migrations = db.col::<Document>("migrations");
@@ -983,6 +984,116 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
.expect("Failed to create ratelimit_events index.");
}
if revision <= 26 {
info!("Running migration [revision 26 / 15-05-2024]: fix invites being incorrectly serialized with wrong enum tagging.");
auto_derived!(
pub enum OldInvite {
Server {
#[serde(rename = "_id")]
code: String,
server: String,
creator: String,
channel: String,
},
Group {
#[serde(rename = "_id")]
code: String,
creator: String,
channel: String,
},
}
);
#[derive(serde::Serialize, serde::Deserialize)]
struct Outer {
_id: ObjectId,
#[serde(flatten)]
invite: OldInvite,
}
let invites = db
.db()
.collection::<Outer>("channel_invites")
.find(
doc! {
"type": { "$exists": false }
},
None,
)
.await
.expect("failed to find invites")
.filter_map(|s| async { s.ok() })
.collect::<Vec<Outer>>()
.await
.into_iter()
.map(|invite| match invite.invite {
OldInvite::Server {
code,
server,
creator,
channel,
} => Invite::Server {
code,
server,
creator,
channel,
},
OldInvite::Group {
code,
creator,
channel,
} => Invite::Group {
code,
creator,
channel,
},
})
.collect::<Vec<Invite>>();
if !invites.is_empty() {
db.db()
.collection("channel_invites")
.insert_many(invites, None)
.await
.expect("failed to insert corrected invite");
db.db()
.collection::<Outer>("channel_invites")
.delete_many(
doc! {
"type": { "$exists": false }
},
None,
)
.await
.expect("failed to find invites");
}
}
if revision <= 27 {
info!("Running migration [revision 27 / 21-07-2024]: create message pinned index.");
db.db()
.run_command(
doc! {
"createIndexes": "messages",
"indexes": [
{
"key": {
"channel": 1_i32,
"pinned": 1_i32
},
"name": "channel_pinned_compound"
}
]
},
None,
)
.await
.expect("Failed to create message index.");
}
// Need to migrate fields on attachments, change `user_id`, `object_id`, etc to `parent`.
// Reminder to update LATEST_REVISION when adding new migrations.

View File

@@ -2,7 +2,7 @@ use revolt_config::config;
use revolt_result::Result;
use ulid::Ulid;
use crate::{BotInformation, Database, PartialUser, User};
use crate::{events::client::EventV1, BotInformation, Database, PartialUser, User};
auto_derived_partial!(
/// Bot
@@ -72,7 +72,12 @@ impl Default for Bot {
#[allow(clippy::disallowed_methods)]
impl Bot {
/// Create a new bot
pub async fn create<D>(db: &Database, username: String, owner: &User, data: D) -> Result<Bot>
pub async fn create<D>(
db: &Database,
username: String,
owner: &User,
data: D,
) -> Result<(Bot, User)>
where
D: Into<Option<PartialBot>>,
{
@@ -80,14 +85,13 @@ impl Bot {
return Err(create_error!(IsBot));
}
let config = config().await;
if db.get_number_of_bots_by_user(&owner.id).await? >= config.features.limits.default.bots {
if db.get_number_of_bots_by_user(&owner.id).await? >= owner.limits().await.bots {
return Err(create_error!(ReachedMaximumBots));
}
let id = Ulid::new().to_string();
User::create(
let user = User::create(
db,
username,
Some(id.to_string()),
@@ -112,7 +116,7 @@ impl Bot {
}
db.insert_bot(&bot).await?;
Ok(bot)
Ok((bot, user))
}
/// Remove a field from this object
@@ -142,6 +146,10 @@ impl Bot {
db.update_bot(&self.id, &partial, remove).await?;
if partial.token.is_some() {
EventV1::Logout.private(self.id.clone()).await;
}
self.apply_options(partial);
Ok(())
}
@@ -164,7 +172,7 @@ mod tests {
.await
.unwrap();
let bot = Bot::create(
let (bot, _) = Bot::create(
&db,
"Bot Name".to_string(),
&owner,

View File

@@ -10,6 +10,7 @@ static ALPHABET: [char; 54] = [
auto_derived!(
/// Invite
#[serde(tag = "type")]
pub enum Invite {
/// Invite to a specific server channel
Server {

View File

@@ -7,13 +7,13 @@ mod reference;
#[async_trait]
pub trait AbstractChannelUnreads: Sync + Send {
/// Acknowledge a message.
/// Acknowledge a message, and returns updated channel unread.
async fn acknowledge_message(
&self,
channel_id: &str,
user_id: &str,
message_id: &str,
) -> Result<()>;
) -> Result<Option<ChannelUnread>>;
/// Acknowledge many channels.
async fn acknowledge_channels(&self, user_id: &str, channel_ids: &[String]) -> Result<()>;
@@ -28,4 +28,7 @@ pub trait AbstractChannelUnreads: Sync + Send {
/// Fetch all channel unreads for a user.
async fn fetch_unreads(&self, user_id: &str) -> Result<Vec<ChannelUnread>>;
/// Fetch unread for a specific user in a channel.
async fn fetch_unread(&self, user_id: &str, channel_id: &str) -> Result<Option<ChannelUnread>>;
}

View File

@@ -1,4 +1,6 @@
use bson::Document;
use mongodb::options::FindOneAndUpdateOptions;
use mongodb::options::ReturnDocument;
use mongodb::options::UpdateOptions;
use revolt_result::Result;
use ulid::Ulid;
@@ -12,31 +14,35 @@ static COL: &str = "channel_unreads";
#[async_trait]
impl AbstractChannelUnreads for MongoDb {
/// Acknowledge a message.
/// Acknowledge a message, and returns updated channel unread.
async fn acknowledge_message(
&self,
channel_id: &str,
user_id: &str,
message_id: &str,
) -> Result<()> {
self.col::<Document>(COL)
.update_one(
) -> Result<Option<ChannelUnread>> {
self.col::<ChannelUnread>(COL)
.find_one_and_update(
doc! {
"_id.channel": channel_id,
"_id.user": user_id,
},
doc! {
"$unset": {
"mentions": 1_i32
"$pull": {
"mentions": {
"$lt": message_id
}
},
"$set": {
"last_id": message_id
}
},
UpdateOptions::builder().upsert(true).build(),
FindOneAndUpdateOptions::builder()
.upsert(true)
.return_document(ReturnDocument::After)
.build(),
)
.await
.map(|_| ())
.map_err(|_| create_database_error!("update_one", COL))
}
@@ -116,4 +122,18 @@ impl AbstractChannelUnreads for MongoDb {
}
)
}
/// Fetch unread for a specific user in a channel.
async fn fetch_unread(&self, user_id: &str, channel_id: &str) -> Result<Option<ChannelUnread>> {
query!(
self,
find_one,
COL,
doc! {
"_id.user": user_id,
"_id.channel": channel_id
}
)
}
}

View File

@@ -13,7 +13,7 @@ impl AbstractChannelUnreads for ReferenceDb {
channel_id: &str,
user_id: &str,
message_id: &str,
) -> Result<()> {
) -> Result<Option<ChannelUnread>> {
let mut unreads = self.channel_unreads.lock().await;
let key = ChannelCompositeKey {
channel: channel_id.to_string(),
@@ -27,14 +27,14 @@ impl AbstractChannelUnreads for ReferenceDb {
unreads.insert(
key.clone(),
ChannelUnread {
id: key,
id: key.clone(),
last_id: Some(message_id.to_string()),
mentions: None,
},
);
}
Ok(())
Ok(unreads.get(&key).cloned())
}
/// Acknowledge many channels.
@@ -87,4 +87,14 @@ impl AbstractChannelUnreads for ReferenceDb {
.cloned()
.collect())
}
/// Fetch unread for a specific user in a channel.
async fn fetch_unread(&self, user_id: &str, channel_id: &str) -> Result<Option<ChannelUnread>> {
let unreads = self.channel_unreads.lock().await;
Ok(unreads.get(&ChannelCompositeKey {
channel: channel_id.to_string(),
user: user_id.to_string()
}).cloned())
}
}

View File

@@ -201,9 +201,9 @@ impl Channel {
update_server: bool,
) -> Result<Channel> {
let config = config().await;
if server.channels.len() > config.features.limits.default.server_channels {
if server.channels.len() > config.features.limits.global.server_channels {
return Err(create_error!(TooManyChannels {
max: config.features.limits.default.server_channels,
max: config.features.limits.global.server_channels,
}));
};
@@ -263,9 +263,9 @@ impl Channel {
data.users.insert(owner_id.to_string());
let config = config().await;
if data.users.len() > config.features.limits.default.group_size {
if data.users.len() > config.features.limits.global.group_size {
return Err(create_error!(GroupTooLarge {
max: config.features.limits.default.group_size,
max: config.features.limits.global.group_size,
}));
}
@@ -346,9 +346,9 @@ impl Channel {
}
let config = config().await;
if recipients.len() >= config.features.limits.default.group_size {
if recipients.len() >= config.features.limits.global.group_size {
return Err(create_error!(GroupTooLarge {
max: config.features.limits.default.group_size
max: config.features.limits.global.group_size
}));
}
@@ -377,6 +377,8 @@ impl Channel {
username: &user.username,
avatar: user.avatar.as_ref().map(|file| file.id.as_ref()),
},
None,
None,
self,
false,
)
@@ -415,13 +417,13 @@ impl Channel {
}
/// Clone this channel's id
pub fn id(&self) -> String {
pub fn id(&self) -> &str {
match self {
Channel::DirectMessage { id, .. }
| Channel::Group { id, .. }
| Channel::SavedMessages { id, .. }
| Channel::TextChannel { id, .. }
| Channel::VoiceChannel { id, .. } => id.clone(),
| Channel::VoiceChannel { id, .. } => id,
}
}
@@ -688,6 +690,8 @@ impl Channel {
username: name,
avatar: None,
},
None,
None,
self,
false,
)
@@ -698,6 +702,8 @@ impl Channel {
}
}
db.remove_user_from_group(id, &user.id).await?;
EventV1::ChannelGroupLeave {
id: id.to_string(),
user: user.id.to_string(),
@@ -723,6 +729,8 @@ impl Channel {
username: &user.username,
avatar: user.avatar.as_ref().map(|file| file.id.as_ref()),
},
None,
None,
self,
false,
)

View File

@@ -266,7 +266,7 @@ impl AbstractChannels for MongoDb {
.await?;
// Delete the channel itself
query!(self, delete_one_by_id, COL, &channel.id()).map(|_| ())
query!(self, delete_one_by_id, COL, channel.id()).map(|_| ())
}
}

View File

@@ -11,7 +11,7 @@ impl AbstractChannels for ReferenceDb {
/// Insert a new channel in the database
async fn insert_channel(&self, channel: &Channel) -> Result<()> {
let mut channels = self.channels.lock().await;
if let Entry::Vacant(entry) = channels.entry(channel.id()) {
if let Entry::Vacant(entry) = channels.entry(channel.id().to_string()) {
entry.insert(channel.clone());
Ok(())
} else {
@@ -148,7 +148,7 @@ impl AbstractChannels for ReferenceDb {
// Delete a channel
async fn delete_channel(&self, channel: &Channel) -> Result<()> {
let mut channels = self.channels.lock().await;
if channels.remove(&channel.id()).is_some() {
if channels.remove(channel.id()).is_some() {
Ok(())
} else {
Err(create_error!(NotFound))

View File

@@ -0,0 +1,5 @@
mod model;
mod ops;
pub use model::*;
pub use ops::*;

View File

@@ -0,0 +1,53 @@
use iso8601_timestamp::Timestamp;
auto_derived_partial!(
/// File hash
pub struct FileHash {
/// Sha256 hash of the file
#[serde(rename = "_id")]
pub id: String,
/// Sha256 hash of file after it has been processed
pub processed_hash: String,
/// When this file was created in system
pub created_at: Timestamp,
/// The bucket this file is stored in
pub bucket_id: String,
/// The path at which this file exists in
pub path: String,
/// Cryptographic nonce used to encrypt this file
pub iv: String,
/// Parsed metadata of this file
pub metadata: Metadata,
/// Raw content type of this file
pub content_type: String,
/// Size of this file (in bytes)
pub size: isize,
},
"PartialFile"
);
auto_derived!(
/// Metadata associated with a file
#[serde(tag = "type")]
#[derive(Default)]
pub enum Metadata {
/// File is just a generic uncategorised file
#[default]
File,
/// File contains textual data and should be displayed as such
Text,
/// File is an image with specific dimensions
Image {
width: isize,
height: isize,
// animated: bool // TODO: https://docs.rs/image/latest/image/trait.AnimationDecoder.html
},
/// File is a video with specific dimensions
Video { width: isize, height: isize },
/// File is audio
Audio,
}
);

View File

@@ -0,0 +1,12 @@
use revolt_result::Result;
use crate::FileHash;
mod mongodb;
mod reference;
#[async_trait]
pub trait AbstractAttachmentHashes: Sync + Send {
/// Fetch an attachment hash entry by sha256 hash.
async fn fetch_attachment_hash(&self, hash: &str) -> Result<FileHash>;
}

View File

@@ -0,0 +1,24 @@
use revolt_result::Result;
use crate::FileHash;
use crate::MongoDb;
use super::AbstractAttachmentHashes;
static COL: &str = "attachment_hashes";
#[async_trait]
impl AbstractAttachmentHashes for MongoDb {
/// Fetch an attachment hash entry by sha256 hash.
async fn fetch_attachment_hash(&self, hash: &str) -> Result<FileHash> {
query!(
self,
find_one,
COL,
doc! {
"_id": hash
}
)?
.ok_or_else(|| create_error!(NotFound))
}
}

View File

@@ -0,0 +1,23 @@
use revolt_result::Result;
use crate::FileHash;
use crate::ReferenceDb;
use super::AbstractAttachmentHashes;
#[async_trait]
impl AbstractAttachmentHashes for ReferenceDb {
/// Fetch an attachment hash entry by sha256 hash.
async fn fetch_attachment_hash(&self, hash: &str) -> Result<FileHash> {
let hashes = self.file_hashes.lock().await;
if let Some(file) = hashes.get(hash) {
if file.id == hash {
Ok(file.clone())
} else {
Err(create_error!(NotFound))
}
} else {
Err(create_error!(NotFound))
}
}
}

View File

@@ -1,5 +1,6 @@
use crate::Database;
use crate::{Database, FileHash, Metadata};
use iso8601_timestamp::Timestamp;
use revolt_result::Result;
auto_derived_partial!(
@@ -12,12 +13,16 @@ auto_derived_partial!(
pub tag: String,
/// Original filename
pub filename: String,
/// Parsed metadata of this file
pub metadata: Metadata,
/// Raw content type of this file
pub content_type: String,
/// Size of this file (in bytes)
pub size: isize,
/// Hash of this file
pub hash: Option<String>, // these are Option<>s to not break file uploads on legacy Autumn
/// When this file was uploaded
pub uploaded_at: Option<Timestamp>, // these are Option<>s to not break file uploads on legacy Autumn
/// ID of user who uploaded this file
pub uploaded_id: Option<String>, // these are Option<>s to not break file uploads on legacy Autumn
/// What the file was used for
pub used_for: Option<FileUsedFor>,
/// Whether this file was deleted
#[serde(skip_serializing_if = "Option::is_none")]
@@ -26,6 +31,14 @@ auto_derived_partial!(
#[serde(skip_serializing_if = "Option::is_none")]
pub reported: Option<bool>,
// !!! DEPRECATED:
/// Parsed metadata of this file
pub metadata: Metadata,
/// Raw content type of this file
pub content_type: String,
/// Size of this file (in bytes)
pub size: isize,
// TODO: migrate this mess to having:
// - author_id
// - parent: Parent { Message(id), User(id), etc }
@@ -44,25 +57,35 @@ auto_derived_partial!(
);
auto_derived!(
/// Metadata associated with a file
#[serde(tag = "type")]
#[derive(Default)]
pub enum Metadata {
/// File is just a generic uncategorised file
#[default]
File,
/// File contains textual data and should be displayed as such
Text,
/// File is an image with specific dimensions
Image { width: isize, height: isize },
/// File is a video with specific dimensions
Video { width: isize, height: isize },
/// File is audio
Audio,
/// Type of object file was used for
pub enum FileUsedForType {
// TODO: changing this requires a db migration
message,
serverBanner,
emoji,
userAvatar,
userProfileBackground,
legacyGroupIcon,
channelIcon,
serverIcon,
}
/// Information about what the file was used for
pub struct FileUsedFor {
/// Type of the object
#[serde(rename = "type")]
pub object_type: FileUsedForType,
/// ID of the object
pub id: String,
}
);
impl File {
/// Get the hash entry for this file
pub async fn as_hash(&self, db: &Database) -> Result<FileHash> {
db.fetch_attachment_hash(self.hash.as_ref().unwrap()).await
}
/// Use a file for a message attachment
pub async fn use_attachment(db: &Database, id: &str, parent: &str) -> Result<File> {
db.find_and_use_attachment(id, "attachments", "message", parent)

View File

@@ -10,6 +10,9 @@ pub trait AbstractAttachments: Sync + Send {
/// Insert attachment into database.
async fn insert_attachment(&self, attachment: &File) -> Result<()>;
/// Fetch an attachment by its id.
async fn fetch_attachment(&self, tag: &str, file_id: &str) -> Result<File>;
/// Find an attachment by its details and mark it as used by a given parent.
async fn find_and_use_attachment(
&self,

View File

@@ -15,6 +15,20 @@ impl AbstractAttachments for MongoDb {
query!(self, insert_one, COL, &attachment).map(|_| ())
}
/// Fetch an attachment by its id.
async fn fetch_attachment(&self, tag: &str, file_id: &str) -> Result<File> {
query!(
self,
find_one,
COL,
doc! {
"_id": file_id,
"tag": tag
}
)?
.ok_or_else(|| create_error!(NotFound))
}
/// Find an attachment by its details and mark it as used by a given parent.
async fn find_and_use_attachment(
&self,

View File

@@ -18,6 +18,20 @@ impl AbstractAttachments for ReferenceDb {
}
}
/// Fetch an attachment by its id.
async fn fetch_attachment(&self, tag: &str, file_id: &str) -> Result<File> {
let files = self.files.lock().await;
if let Some(file) = files.get(file_id) {
if file.tag == tag {
Ok(file.clone())
} else {
Err(create_error!(NotFound))
}
} else {
Err(create_error!(NotFound))
}
}
/// Find an attachment by its details and mark it as used by a given parent.
async fn find_and_use_attachment(
&self,

View File

@@ -2,10 +2,10 @@ use std::collections::HashSet;
use indexmap::{IndexMap, IndexSet};
use iso8601_timestamp::Timestamp;
use revolt_config::config;
use revolt_config::{config, FeaturesLimits};
use revolt_models::v0::{
self, BulkMessageResponse, DataMessageSend, Embed, MessageAuthor, MessageSort, MessageWebhook,
PushNotification, ReplyIntent, SendableEmbed, Text, RE_MENTION,
self, BulkMessageResponse, DataMessageSend, Embed, MessageAuthor, MessageFlags, MessageSort,
MessageWebhook, PushNotification, ReplyIntent, SendableEmbed, Text, RE_MENTION,
};
use revolt_permissions::{ChannelPermission, PermissionValue};
use revolt_result::Result;
@@ -65,6 +65,13 @@ auto_derived_partial!(
/// Name and / or avatar overrides for this message
#[serde(skip_serializing_if = "Option::is_none")]
pub masquerade: Option<Masquerade>,
/// Whether or not the message in pinned
#[serde(skip_serializing_if = "crate::if_option_false")]
pub pinned: Option<bool>,
/// Bitfield of message flags
#[serde(skip_serializing_if = "Option::is_none")]
pub flags: Option<i32>,
},
"PartialMessage"
);
@@ -95,6 +102,10 @@ auto_derived!(
ChannelIconChanged { by: String },
#[serde(rename = "channel_ownership_changed")]
ChannelOwnershipChanged { from: String, to: String },
#[serde(rename = "message_pinned")]
MessagePinned { id: String, by: String },
#[serde(rename = "message_unpinned")]
MessageUnpinned { id: String, by: String },
}
/// Name and / or avatar override information
@@ -164,6 +175,8 @@ auto_derived!(
pub author: Option<String>,
/// Search query
pub query: Option<String>,
/// Search for pinned
pub pinned: Option<bool>,
}
/// Message Query
@@ -179,6 +192,11 @@ auto_derived!(
#[serde(flatten)]
pub time_period: MessageTimePeriod,
}
/// Optional fields on message
pub enum FieldsMessage {
Pinned,
}
);
#[allow(clippy::derivable_impls)]
@@ -200,6 +218,8 @@ impl Default for Message {
reactions: Default::default(),
interactions: Default::default(),
masquerade: None,
flags: None,
pinned: None,
}
}
}
@@ -207,11 +227,15 @@ impl Default for Message {
#[allow(clippy::disallowed_methods)]
impl Message {
/// Create message from API data
#[allow(clippy::too_many_arguments)]
pub async fn create_from_api(
db: &Database,
channel: Channel,
data: DataMessageSend,
author: MessageAuthor<'_>,
user: Option<v0::User>,
member: Option<v0::Member>,
limits: FeaturesLimits,
mut idempotency: IdempotencyKey,
generate_embeds: bool,
allow_mentions: bool,
@@ -221,7 +245,7 @@ impl Message {
Message::validate_sum(
&data.content,
data.embeds.as_deref().unwrap_or_default(),
config.features.limits.default.message_length,
limits.message_length,
)?;
idempotency
@@ -237,6 +261,13 @@ impl Message {
return Err(create_error!(EmptyMessage));
}
// Ensure flags are either not set or have permissible values
if let Some(flags) = &data.flags {
if flags != &0 && flags != &1 {
return Err(create_error!(InvalidProperty));
}
}
// Ensure restrict_reactions is not specified without reactions list
if let Some(interactions) = &data.interactions {
if interactions.restrict_reactions {
@@ -262,7 +293,7 @@ impl Message {
let message_id = Ulid::new().to_string();
let mut message = Message {
id: message_id.clone(),
channel: channel.id(),
channel: channel.id().to_string(),
masquerade: data.masquerade.map(|masquerade| masquerade.into()),
interactions: data
.interactions
@@ -270,6 +301,7 @@ impl Message {
.unwrap_or_default(),
author: author_id,
webhook: webhook.map(|w| w.into()),
flags: data.flags.map(|v| v as i32),
..Default::default()
};
@@ -288,9 +320,9 @@ impl Message {
// Verify replies are valid.
let mut replies = HashSet::new();
if let Some(entries) = data.replies {
if entries.len() > config.features.limits.default.message_replies {
if entries.len() > config.features.limits.global.message_replies {
return Err(create_error!(TooManyReplies {
max: config.features.limits.default.message_replies,
max: config.features.limits.global.message_replies,
}));
}
@@ -320,20 +352,20 @@ impl Message {
if data
.attachments
.as_ref()
.is_some_and(|v| v.len() > config.features.limits.default.message_attachments)
.is_some_and(|v| v.len() > limits.message_attachments)
{
return Err(create_error!(TooManyAttachments {
max: config.features.limits.default.message_attachments,
max: limits.message_attachments,
}));
}
if data
.embeds
.as_ref()
.is_some_and(|v| v.len() > config.features.limits.default.message_embeds)
.is_some_and(|v| v.len() > config.features.limits.global.message_embeds)
{
return Err(create_error!(TooManyEmbeds {
max: config.features.limits.default.message_embeds,
max: config.features.limits.global.message_embeds,
}));
}
@@ -360,7 +392,9 @@ impl Message {
message.nonce = Some(idempotency.into_key());
// Send the message
message.send(db, author, &channel, generate_embeds).await?;
message
.send(db, author, user, member, &channel, generate_embeds)
.await?;
Ok(message)
}
@@ -369,13 +403,15 @@ impl Message {
pub async fn send_without_notifications(
&mut self,
db: &Database,
user: Option<v0::User>,
member: Option<v0::Member>,
is_dm: bool,
generate_embeds: bool,
) -> Result<()> {
db.insert_message(self).await?;
// Fan out events
EventV1::Message(self.clone().into())
EventV1::Message(self.clone().into_model(user, member))
.p(self.channel.to_string())
.await;
@@ -416,29 +452,40 @@ impl Message {
&mut self,
db: &Database,
author: MessageAuthor<'_>,
user: Option<v0::User>,
member: Option<v0::Member>,
channel: &Channel,
generate_embeds: bool,
) -> Result<()> {
self.send_without_notifications(
db,
user.clone(),
member.clone(),
matches!(channel, Channel::DirectMessage { .. }),
generate_embeds,
)
.await?;
// Push out Web Push notifications
crate::tasks::web_push::queue(
{
match channel {
Channel::DirectMessage { recipients, .. }
| Channel::Group { recipients, .. } => recipients.clone(),
Channel::TextChannel { .. } => self.mentions.clone().unwrap_or_default(),
_ => vec![],
}
},
PushNotification::from(self.clone().into(), Some(author), &channel.id()).await,
)
.await;
if !self.has_suppressed_notifications() {
// Push out Web Push notifications
crate::tasks::web_push::queue(
{
match channel {
Channel::DirectMessage { recipients, .. }
| Channel::Group { recipients, .. } => recipients.clone(),
Channel::TextChannel { .. } => self.mentions.clone().unwrap_or_default(),
_ => vec![],
}
},
PushNotification::from(
self.clone().into_model(user, member),
Some(author),
channel.id(),
)
.await,
)
.await;
}
Ok(())
}
@@ -470,15 +517,37 @@ impl Message {
}))
}
/// Whether this message has suppressed notifications
pub fn has_suppressed_notifications(&self) -> bool {
if let Some(flags) = self.flags {
flags & MessageFlags::SuppressNotifications as i32
== MessageFlags::SuppressNotifications as i32
} else {
false
}
}
/// Update message data
pub async fn update(&mut self, db: &Database, partial: PartialMessage) -> Result<()> {
pub async fn update(
&mut self,
db: &Database,
partial: PartialMessage,
remove: Vec<FieldsMessage>,
) -> Result<()> {
self.apply_options(partial.clone());
db.update_message(&self.id, &partial).await?;
for field in &remove {
self.remove_field(field);
}
db.update_message(&self.id, &partial, remove.clone())
.await?;
EventV1::MessageUpdate {
id: self.id.clone(),
channel: self.channel.clone(),
data: partial.into(),
clear: remove.into_iter().map(|field| field.into()).collect(),
}
.p(self.channel.clone())
.await;
@@ -498,13 +567,47 @@ impl Message {
.fetch_messages(query)
.await?
.into_iter()
.map(Into::into)
.map(|msg| msg.into_model(None, None))
.collect();
if let Some(true) = include_users {
let user_ids = messages
.iter()
.map(|m| m.author.clone())
.flat_map(|m| {
let mut users = vec![m.author.clone()];
if let Some(system) = &m.system {
match system {
v0::SystemMessage::ChannelDescriptionChanged { by } => {
users.push(by.clone())
}
v0::SystemMessage::ChannelIconChanged { by } => users.push(by.clone()),
v0::SystemMessage::ChannelOwnershipChanged { from, to, .. } => {
users.push(from.clone());
users.push(to.clone())
}
v0::SystemMessage::ChannelRenamed { by, .. } => users.push(by.clone()),
v0::SystemMessage::UserAdded { by, id, .. }
| v0::SystemMessage::UserRemove { by, id, .. } => {
users.push(by.clone());
users.push(id.clone());
}
v0::SystemMessage::UserBanned { id, .. }
| v0::SystemMessage::UserKicked { id, .. }
| v0::SystemMessage::UserJoined { id, .. }
| v0::SystemMessage::UserLeft { id, .. } => {
users.push(id.clone());
}
v0::SystemMessage::Text { .. } => {}
v0::SystemMessage::MessagePinned { by, .. } => {
users.push(by.clone());
}
v0::SystemMessage::MessageUnpinned { by, .. } => {
users.push(by.clone());
}
}
}
users
})
.collect::<HashSet<String>>()
.into_iter()
.collect::<Vec<String>>();
@@ -588,7 +691,7 @@ impl Message {
pub async fn add_reaction(&self, db: &Database, user: &User, emoji: &str) -> Result<()> {
// Check how many reactions are already on the message
let config = config().await;
if self.reactions.len() >= config.features.limits.default.message_reactions
if self.reactions.len() >= config.features.limits.global.message_reactions
&& !self.reactions.contains_key(emoji)
{
return Err(create_error!(InvalidOperation));
@@ -730,6 +833,12 @@ impl Message {
// Write to database
db.clear_reaction(&self.id, emoji).await
}
pub fn remove_field(&mut self, field: &FieldsMessage) {
match field {
FieldsMessage::Pinned => self.pinned = None,
}
}
}
impl SystemMessage {
@@ -753,7 +862,7 @@ impl Interactions {
if let Some(reactions) = &self.reactions {
permissions.throw_if_lacking_channel_permission(ChannelPermission::React)?;
if reactions.len() > config.features.limits.default.message_reactions {
if reactions.len() > config.features.limits.global.message_reactions {
return Err(create_error!(InvalidOperation));
}

View File

@@ -1,6 +1,6 @@
use revolt_result::Result;
use crate::{AppendMessage, Message, MessageQuery, PartialMessage};
use crate::{AppendMessage, FieldsMessage, Message, MessageQuery, PartialMessage};
mod mongodb;
mod reference;
@@ -20,7 +20,7 @@ pub trait AbstractMessages: Sync + Send {
async fn fetch_messages_by_id(&self, ids: &[String]) -> Result<Vec<Message>>;
/// Update a given message with new information
async fn update_message(&self, id: &str, message: &PartialMessage) -> Result<()>;
async fn update_message(&self, id: &str, message: &PartialMessage, remove: Vec<FieldsMessage>) -> Result<()>;
/// Append information to a given message
async fn append_message(&self, id: &str, append: &AppendMessage) -> Result<()>;

View File

@@ -5,7 +5,8 @@ use revolt_models::v0::MessageSort;
use revolt_result::Result;
use crate::{
AppendMessage, DocumentId, Message, MessageQuery, MessageTimePeriod, MongoDb, PartialMessage,
AppendMessage, DocumentId, FieldsMessage, IntoDocumentPath, Message, MessageQuery,
MessageTimePeriod, MongoDb, PartialMessage,
};
use super::AbstractMessages;
@@ -50,6 +51,10 @@ impl AbstractMessages for MongoDb {
false
};
if let Some(pinned) = query.filter.pinned {
filter.insert("pinned", pinned);
};
// 2. Find query limit
let limit = query.limit.unwrap_or(50);
@@ -177,8 +182,22 @@ impl AbstractMessages for MongoDb {
}
/// Update a given message with new information
async fn update_message(&self, id: &str, message: &PartialMessage) -> Result<()> {
query!(self, update_one_by_id, COL, id, message, vec![], None).map(|_| ())
async fn update_message(
&self,
id: &str,
message: &PartialMessage,
remove: Vec<FieldsMessage>,
) -> Result<()> {
query!(
self,
update_one_by_id,
COL,
id,
message,
remove.iter().map(|x| x as &dyn IntoDocumentPath).collect(),
None
)
.map(|_| ())
}
/// Append information to a given message
@@ -296,6 +315,14 @@ impl AbstractMessages for MongoDb {
}
}
impl IntoDocumentPath for FieldsMessage {
fn as_path(&self) -> Option<&'static str> {
Some(match self {
FieldsMessage::Pinned => "pinned",
})
}
}
impl MongoDb {
pub async fn delete_bulk_messages(&self, projection: Document) -> Result<()> {
let mut for_attachments = projection.clone();

View File

@@ -2,7 +2,7 @@ use futures::future::try_join_all;
use indexmap::IndexSet;
use revolt_result::Result;
use crate::{AppendMessage, Message, MessageQuery, PartialMessage, ReferenceDb};
use crate::{AppendMessage, FieldsMessage, Message, MessageQuery, PartialMessage, ReferenceDb};
use super::AbstractMessages;
@@ -56,6 +56,12 @@ impl AbstractMessages for ReferenceDb {
}
}
if let Some(pinned) = query.filter.pinned {
if message.pinned.unwrap_or_default() == pinned {
return false
}
}
true
})
.cloned()
@@ -183,10 +189,15 @@ impl AbstractMessages for ReferenceDb {
}
/// Update a given message with new information
async fn update_message(&self, id: &str, message: &PartialMessage) -> Result<()> {
async fn update_message(&self, id: &str, message: &PartialMessage, remove: Vec<FieldsMessage>) -> Result<()> {
let mut messages = self.messages.lock().await;
if let Some(message_data) = messages.get_mut(id) {
message_data.apply_options(message.to_owned());
for field in remove {
#[allow(clippy::disallowed_methods)]
message_data.remove_field(&field);
}
Ok(())
} else {
Err(create_error!(NotFound))

View File

@@ -5,6 +5,7 @@ mod channel_unreads;
mod channel_webhooks;
mod channels;
mod emojis;
mod file_hashes;
mod files;
mod messages;
mod ratelimit_events;
@@ -23,6 +24,7 @@ pub use channel_unreads::*;
pub use channel_webhooks::*;
pub use channels::*;
pub use emojis::*;
pub use file_hashes::*;
pub use files::*;
pub use messages::*;
pub use ratelimit_events::*;
@@ -46,6 +48,7 @@ pub trait AbstractDatabase:
+ channel_unreads::AbstractChannelUnreads
+ channel_webhooks::AbstractWebhooks
+ emojis::AbstractEmojis
+ file_hashes::AbstractAttachmentHashes
+ files::AbstractAttachments
+ messages::AbstractMessages
+ ratelimit_events::AbstractRatelimitEvents

View File

@@ -81,7 +81,7 @@ impl Member {
server: &Server,
user: &User,
channels: Option<Vec<Channel>>,
) -> Result<Vec<Channel>> {
) -> Result<(Member, Vec<Channel>)> {
if db.fetch_ban(&server.id, &user.id).await.is_ok() {
return Err(create_error!(Banned));
}
@@ -150,12 +150,12 @@ impl Member {
id: user.id.clone(),
}
.into_message(id.to_string())
.send_without_notifications(db, false, false)
.send_without_notifications(db, None, None, false, false)
.await
.ok();
}
Ok(channels)
Ok((member, channels))
}
/// Update member data
@@ -229,6 +229,7 @@ impl Member {
EventV1::ServerMemberLeave {
id: self.id.server.to_string(),
user: self.id.user.to_string(),
reason: intention.clone().into(),
}
.p(self.id.server.to_string())
.await;
@@ -250,7 +251,7 @@ impl Member {
}
.into_message(id.to_string())
// TODO: support notifications here in the future?
.send_without_notifications(db, false, false)
.send_without_notifications(db, None, None, false, false)
.await
.ok();
}

View File

@@ -175,7 +175,7 @@ impl Server {
vec![]
};
server.channels = channels.iter().map(|c| c.id()).collect();
server.channels = channels.iter().map(|c| c.id().to_string()).collect();
db.insert_server(&server).await?;
Ok((server, channels))
}

View File

@@ -214,14 +214,21 @@ impl MongoDb {
// Delete all emoji.
self.col::<Document>("emojis")
.delete_many(
.update_many(
doc! {
"parent.id": &server_id
},
doc! {
"$set": {
"parent": {
"type": "Detached"
}
}
},
None,
)
.await
.map_err(|_| create_database_error!("delete_many", "emojis"))?;
.map_err(|_| create_database_error!("update_many", "emojis"))?;
// Delete all channels.
self.col::<Document>("channels")

View File

@@ -1,13 +1,16 @@
use std::{collections::HashSet, time::Duration};
use std::{collections::HashSet, str::FromStr, time::Duration};
use crate::{events::client::EventV1, Database, File, RatelimitEvent};
use authifier::config::{EmailVerificationConfig, Template};
use iso8601_timestamp::Timestamp;
use once_cell::sync::Lazy;
use rand::seq::SliceRandom;
use revolt_config::config;
use revolt_models::v0;
use revolt_config::{config, FeaturesLimits};
use revolt_models::v0::{self, UserFlags};
use revolt_presence::filter_online;
use revolt_result::{create_error, Result};
use serde_json::json;
use ulid::Ulid;
auto_derived_partial!(
@@ -49,6 +52,10 @@ auto_derived_partial!(
/// Bot information
#[serde(skip_serializing_if = "Option::is_none")]
pub bot: Option<BotInformation>,
/// Time until user is unsuspended
#[serde(skip_serializing_if = "Option::is_none")]
pub suspended_until: Option<Timestamp>,
},
"PartialUser"
);
@@ -61,6 +68,11 @@ auto_derived!(
StatusPresence,
ProfileContent,
ProfileBackground,
DisplayName,
// internal fields
Suspension,
None,
}
/// User's relationship with another user (or themselves)
@@ -164,6 +176,7 @@ impl Default for User {
flags: Default::default(),
privileged: Default::default(),
bot: Default::default(),
suspended_until: Default::default(),
}
}
}
@@ -197,6 +210,22 @@ impl User {
Ok(user)
}
/// Get limits for this user
pub async fn limits(&self) -> FeaturesLimits {
let config = config().await;
if ulid::Ulid::from_str(&self.id)
.expect("`ulid`")
.datetime()
.elapsed()
.expect("time went backwards")
<= Duration::from_secs(86400u64 * config.features.limits.global.new_user_days as u64)
{
config.features.limits.new_user
} else {
config.features.limits.default
}
}
/// Get the relationship with another user
pub fn relationship_with(&self, user_b: &str) -> RelationshipStatus {
if self.id == user_b {
@@ -235,12 +264,11 @@ impl User {
/// Check if this user can acquire another server
pub async fn can_acquire_server(&self, db: &Database) -> Result<()> {
let config = config().await;
if db.fetch_server_count(&self.id).await? <= config.features.limits.default.servers {
if db.fetch_server_count(&self.id).await? <= self.limits().await.servers {
Ok(())
} else {
Err(create_error!(TooManyServers {
max: config.features.limits.default.servers
max: self.limits().await.servers
}))
}
}
@@ -276,23 +304,27 @@ impl User {
Ok(username)
}
/// Find a user from a given token and hint
/// Find a user and session ID from a given token and hint
#[async_recursion]
pub async fn from_token(db: &Database, token: &str, hint: UserHint) -> Result<User> {
pub async fn from_token(db: &Database, token: &str, hint: UserHint) -> Result<(User, String)> {
match hint {
UserHint::Bot => {
UserHint::Bot => Ok((
db.fetch_user(
&db.fetch_bot_by_token(token)
.await
.map_err(|_| create_error!(InvalidSession))?
.id,
)
.await
.await?,
String::new(),
)),
UserHint::User => {
let session = db.fetch_session_by_token(token).await?;
Ok((db.fetch_user(&session.user_id).await?, session.id))
}
UserHint::User => db.fetch_user_by_token(token).await,
UserHint::Any => {
if let Ok(user) = User::from_token(db, token, UserHint::User).await {
Ok(user)
if let Ok(result) = User::from_token(db, token, UserHint::User).await {
Ok(result)
} else {
User::from_token(db, token, UserHint::Bot).await
}
@@ -473,6 +505,7 @@ impl User {
RelationshipStatus::Blocked => Err(create_error!(Blocked)),
RelationshipStatus::BlockedOther => Err(create_error!(BlockedByOther)),
RelationshipStatus::Incoming => {
// Accept incoming friend request
self.apply_relationship(
db,
target,
@@ -482,6 +515,26 @@ impl User {
.await
}
RelationshipStatus::None => {
// Get this user's current count of outgoing friend requests
let count = self
.relations
.as_ref()
.map(|relations| {
relations
.iter()
.filter(|r| matches!(r.status, RelationshipStatus::Outgoing))
.count()
})
.unwrap_or_default();
// If we're over the limit, don't allow creating more requests
if count >= self.limits().await.outgoing_friend_requests {
return Err(create_error!(TooManyPendingFriendRequests {
max: self.limits().await.outgoing_friend_requests
}));
}
// Send the friend request
self.apply_relationship(
db,
target,
@@ -617,9 +670,107 @@ impl User {
x.background = None;
}
}
FieldsUser::DisplayName => self.display_name = None,
FieldsUser::Suspension => self.suspended_until = None,
FieldsUser::None => {}
}
}
/// Suspend the user
///
/// - If a duration is specified, the user will be automatically unsuspended after the given time.
/// - If a reason is specified, an email will be sent.
pub async fn suspend(
&mut self,
db: &Database,
duration_days: Option<usize>,
reason: Option<Vec<String>>,
) -> Result<()> {
let authifier = db.clone().to_authifier().await;
let mut account = authifier
.database
.find_account(&self.id)
.await
.map_err(|_| create_error!(InternalError))?;
account
.disable(&authifier)
.await
.map_err(|_| create_error!(InternalError))?;
account
.delete_all_sessions(&authifier, None)
.await
.map_err(|_| create_error!(InternalError))?;
self.update(
db,
PartialUser {
flags: Some(UserFlags::SuspendedUntil as i32),
suspended_until: duration_days.and_then(|dur| {
Timestamp::now_utc().checked_add(iso8601_timestamp::Duration::days(dur as i64))
}),
..Default::default()
},
vec![],
)
.await?;
if let Some(reason) = reason {
if let EmailVerificationConfig::Enabled { smtp, .. } =
authifier.config.email_verification
{
smtp.send_email(
account.email.clone(),
// maybe move this to common area?
&Template {
title: "Account Suspension".to_string(),
html: Some(include_str!("../../../templates/suspension.html").to_owned()),
text: include_str!("../../../templates/suspension.txt").to_owned(),
url: Default::default(),
},
json!({
"email": account.email,
"list": reason.join(", "),
"duration": duration_days,
"duration_display": if duration_days.is_some() {
"block"
} else {
"none"
}
}),
)
.map_err(|_| create_error!(InternalError))?;
}
}
Ok(())
}
/// Unsuspend the user
pub async fn unsuspend(&mut self, db: &Database) -> Result<()> {
self.update(
db,
PartialUser {
flags: Some(0),
suspended_until: None,
..Default::default()
},
vec![],
)
.await?;
unimplemented!()
}
/// Permanently ban the user
///
/// - If a reason is specified, an email will be sent.
pub async fn ban(&mut self, _db: &Database, _reason: Option<String>) -> Result<()> {
// Send ban email (if reason provided)
unimplemented!()
}
/// Mark as deleted
pub async fn mark_deleted(&mut self, db: &Database) -> Result<()> {
self.update(
@@ -635,6 +786,7 @@ impl User {
FieldsUser::StatusPresence,
FieldsUser::ProfileContent,
FieldsUser::ProfileBackground,
FieldsUser::Suspension,
],
)
.await

View File

@@ -1,3 +1,4 @@
use authifier::models::Session;
use revolt_result::Result;
use crate::{FieldsUser, PartialUser, RelationshipStatus, User};
@@ -16,8 +17,8 @@ pub trait AbstractUsers: Sync + Send {
/// Fetch a user from the database by their username
async fn fetch_user_by_username(&self, username: &str, discriminator: &str) -> Result<User>;
/// Fetch a user from the database by their session token
async fn fetch_user_by_token(&self, token: &str) -> Result<User>;
/// Fetch a session from the database by token
async fn fetch_session_by_token(&self, token: &str) -> Result<Session>;
/// Fetch multiple users by their ids
async fn fetch_users<'a>(&self, ids: &'a [String]) -> Result<Vec<User>>;
@@ -57,4 +58,7 @@ pub trait AbstractUsers: Sync + Send {
/// Delete a user by their id
async fn delete_user(&self, id: &str) -> Result<()>;
/// Remove push subscription for a session by session id (TODO: remove)
async fn remove_push_subscription_by_session_id(&self, session_id: &str) -> Result<()>;
}

View File

@@ -46,10 +46,9 @@ impl AbstractUsers for MongoDb {
.ok_or_else(|| create_error!(NotFound))
}
/// Fetch a user from the database by their session token
async fn fetch_user_by_token(&self, token: &str) -> Result<User> {
let session = self
.col::<Session>("sessions")
/// Fetch a session from the database by token
async fn fetch_session_by_token(&self, token: &str) -> Result<Session> {
self.col::<Session>("sessions")
.find_one(
doc! {
"token": token
@@ -58,9 +57,7 @@ impl AbstractUsers for MongoDb {
)
.await
.map_err(|_| create_database_error!("find_one", "sessions"))?
.ok_or_else(|| create_error!(InvalidSession))?;
self.fetch_user(&session.user_id).await
.ok_or_else(|| create_error!(InvalidSession))
}
/// Fetch multiple users by their ids
@@ -319,6 +316,25 @@ impl AbstractUsers for MongoDb {
async fn delete_user(&self, id: &str) -> Result<()> {
query!(self, delete_one_by_id, COL, id).map(|_| ())
}
/// Remove push subscription for a session by session id (TODO: remove)
async fn remove_push_subscription_by_session_id(&self, session_id: &str) -> Result<()> {
self.col::<User>("sessions")
.update_one(
doc! {
"_id": session_id
},
doc! {
"$unset": {
"subscription": 1
}
},
None,
)
.await
.map(|_| ())
.map_err(|_| create_database_error!("update_one", COL))
}
}
impl IntoDocumentPath for FieldsUser {
@@ -329,6 +345,9 @@ impl IntoDocumentPath for FieldsUser {
FieldsUser::ProfileContent => "profile.content",
FieldsUser::StatusPresence => "status.presence",
FieldsUser::StatusText => "status.text",
FieldsUser::DisplayName => "display_name",
FieldsUser::Suspension => "suspended_until",
FieldsUser::None => "none",
})
}
}

View File

@@ -1,3 +1,4 @@
use authifier::models::Session;
use revolt_result::Result;
use crate::{FieldsUser, PartialUser, RelationshipStatus, User};
@@ -40,8 +41,8 @@ impl AbstractUsers for ReferenceDb {
.ok_or_else(|| create_error!(NotFound))
}
/// Fetch a user from the database by their session token
async fn fetch_user_by_token(&self, _token: &str) -> Result<User> {
/// Fetch a session from the database by token
async fn fetch_session_by_token(&self, _token: &str) -> Result<Session> {
todo!()
}
@@ -162,4 +163,9 @@ impl AbstractUsers for ReferenceDb {
Err(create_error!(NotFound))
}
}
/// Remove push subscription for a session by session id (TODO: remove)
async fn remove_push_subscription_by_session_id(&self, _session_id: &str) -> Result<()> {
todo!()
}
}

View File

@@ -5,7 +5,9 @@ use deadqueue::limited::Queue;
use once_cell::sync::Lazy;
use std::{collections::HashMap, time::Duration};
use super::DelayedTask;
use revolt_result::Result;
use super::{apple_notifications::{self, ApnJob}, DelayedTask};
/// Enumeration of possible events
#[derive(Debug, Eq, PartialEq)]
@@ -52,8 +54,43 @@ pub async fn queue(channel: String, user: String, event: AckEvent) {
info!("Queue is using {} slots from {}.", Q.len(), Q.capacity());
}
pub async fn handle_ack_event(event: &AckEvent, db: &Database, authifier_db: &authifier::Database, user: &str, channel: &str) -> Result<()> {
match &event {
#[allow(clippy::disallowed_methods)] // event is sent by higher level function
AckEvent::AckMessage { id } => {
let unread = db.fetch_unread(user, channel).await?;
let updated = db.acknowledge_message(channel, user, id).await?;
if let (Some(before), Some(after)) = (unread, updated) {
let before_mentions = before.mentions.unwrap_or_default().len();
let after_mentions = after.mentions.unwrap_or_default().len();
let mentions_acked = before_mentions - after_mentions;
if mentions_acked > 0 {
if let Ok(sessions) = authifier_db.find_sessions(user).await {
for session in sessions {
if let Some(sub) = session.subscription {
if sub.endpoint == "apn" {
apple_notifications::queue(ApnJob::from_ack(session.id, user.to_string(), sub.auth)).await;
}
}
}
}
};
}
},
AckEvent::AddMention { ids } => {
db.add_mention_to_unread(channel, user, ids).await?;
}
};
Ok(())
}
/// Start a new worker
pub async fn worker(db: Database) {
pub async fn worker(db: Database, authifier_db: authifier::Database) {
let mut tasks = HashMap::<(String, String), DelayedTask<Task>>::new();
let mut keys = vec![];
@@ -71,13 +108,7 @@ pub async fn worker(db: Database) {
let Task { event } = task.data;
let (user, channel) = key;
if let Err(err) = match &event {
#[allow(clippy::disallowed_methods)] // event is sent by higher level function
AckEvent::AckMessage { id } => db.acknowledge_message(channel, user, id).await,
AckEvent::AddMention { ids } => {
db.add_mention_to_unread(channel, user, ids).await
}
} {
if let Err(err) = handle_ack_event(&event, &db, &authifier_db, user, channel).await {
error!("{err:?} for {event:?}. ({user}, {channel})");
} else {
info!("User {user} ack in {channel} with {event:?}");

View File

@@ -0,0 +1,312 @@
use std::io::Cursor;
use base64::{
engine::{self},
Engine as _,
};
use deadqueue::limited::Queue;
use once_cell::sync::Lazy;
use revolt_a2::{
request::{
notification::{DefaultAlert, NotificationOptions},
payload::{APSAlert, APSSound, PayloadLike, APS},
},
Client, ClientConfig, Endpoint, Error, ErrorBody, ErrorReason, Priority, PushType, Response,
};
use revolt_config::config;
use revolt_models::v0::{Message, PushNotification};
use crate::Database;
/// Payload information, before assembly
#[derive(Debug)]
pub struct ApnPayload {
message: Message,
url: String,
authorAvatar: String,
authorDisplayName: String,
channelName: String,
}
#[derive(Serialize, Debug)]
struct Payload<'a> {
aps: APS<'a>,
#[serde(skip_serializing)]
options: NotificationOptions<'a>,
#[serde(skip_serializing)]
device_token: &'a str,
message: &'a Message,
url: &'a str,
authorAvatar: &'a str,
authorDisplayName: &'a str,
channelName: &'a str,
}
impl<'a> PayloadLike for Payload<'a> {
fn get_device_token(&self) -> &'a str {
self.device_token
}
fn get_options(&self) -> &NotificationOptions {
&self.options
}
}
/// Task information
#[derive(Debug)]
pub struct AlertJob {
/// Session Id
session_id: String,
/// Device token
device_token: String,
/// User Id
user_id: String,
/// Title
title: String,
/// Body
body: String,
/// Thread Id
thread_id: String,
/// Category (informs the client what kind of notification is being sent.)
category: String,
/// Payload used by the iOS client to modify the notification
custom_payload: ApnPayload,
}
impl AlertJob {
fn format_title(notification: &PushNotification) -> String {
// ideally this changes depending on context
// in a server, it would look like "Sendername, #channelname in servername"
// in a group, it would look like "Sendername in groupname"
// in a dm it should just be "Sendername".
// not sure how feasible all those are given the PushNotification object as it currently stands.
format!(
"{} in {}",
notification.author, notification.message.channel
) // TODO: this absolutely needs a channel name
}
}
#[derive(Debug)]
pub struct BadgeJob {
/// Session Id
session_id: String,
/// Device token
device_token: String,
/// User Id
user_id: String,
}
#[derive(Debug)]
pub enum JobType {
Alert(AlertJob),
Badge(BadgeJob),
}
#[derive(Debug)]
pub struct ApnJob {
job_type: JobType,
}
impl ApnJob {
pub fn from_notification(
session_id: String,
user_id: String,
device_token: String,
notification: &PushNotification,
) -> ApnJob {
ApnJob {
job_type: JobType::Alert(AlertJob {
session_id,
device_token,
user_id,
title: AlertJob::format_title(notification),
body: notification.body.to_string(),
thread_id: notification.tag.to_string(),
category: "ALERT_MESSAGE".to_string(),
custom_payload: ApnPayload {
message: notification.message.clone(),
url: notification.url.clone(),
authorAvatar: notification.icon.clone(),
authorDisplayName: notification.author.clone(),
channelName: "#fetchchannelnamehere".to_string(), // TODO: get actual channel name
},
}),
}
}
pub fn from_ack(session_id: String, user_id: String, device_token: String) -> ApnJob {
ApnJob {
job_type: JobType::Badge(BadgeJob {
session_id,
device_token,
user_id,
}),
}
}
}
enum AssembledPayload<'a> {
Alert(Payload<'a>),
Default(revolt_a2::request::payload::Payload<'a>),
}
static Q: Lazy<Queue<ApnJob>> = Lazy::new(|| Queue::new(10_000));
/// Queue a new task for a worker
pub async fn queue(task: ApnJob) {
Q.try_push(task).ok();
info!("Queue is using {} slots from {}.", Q.len(), Q.capacity());
}
async fn get_badge_count(db: &Database, user: &str) -> Option<u32> {
if let Ok(unreads) = db.fetch_unreads(user).await {
let mut mention_count = 0;
for channel in unreads {
if let Some(mentions) = channel.mentions {
mention_count += mentions.len() as u32
}
}
return Some(mention_count);
}
None
}
/// Start a new worker
pub async fn worker(db: Database) {
let config = config().await;
if config.api.apn.pkcs8.is_empty()
|| config.api.apn.key_id.is_empty()
|| config.api.apn.team_id.is_empty()
{
eprintln!("Missing APN keys.");
return;
}
let endpoint = if config.api.apn.sandbox {
Endpoint::Sandbox
} else {
Endpoint::Production
};
let pkcs8 = engine::general_purpose::STANDARD
.decode(config.api.apn.pkcs8)
.expect("valid `pcks8`");
let client_config = ClientConfig::new(endpoint);
let client = Client::token(
&mut Cursor::new(pkcs8),
config.api.apn.key_id,
config.api.apn.team_id,
client_config,
)
.expect("could not create APN client");
let payload_options = NotificationOptions {
apns_id: None,
apns_push_type: Some(PushType::Alert),
apns_expiration: None,
apns_priority: Some(Priority::High),
apns_topic: Some("chat.revolt.app"),
apns_collapse_id: None,
};
loop {
let task = Q.pop().await;
let payload: AssembledPayload;
match task.job_type {
JobType::Alert(ref alert) => {
payload = AssembledPayload::Alert(Payload {
aps: APS {
alert: Some(APSAlert::Default(DefaultAlert {
title: Some(&alert.title),
subtitle: None,
body: Some(&alert.body),
title_loc_key: None,
title_loc_args: None,
action_loc_key: None,
loc_key: None,
loc_args: None,
launch_image: None,
})),
badge: get_badge_count(&db, &alert.user_id).await,
sound: Some(APSSound::Sound("default")),
thread_id: Some(&alert.thread_id),
content_available: None,
category: Some(&alert.category),
mutable_content: Some(1),
url_args: None,
},
device_token: &alert.device_token,
options: payload_options.clone(),
message: &alert.custom_payload.message,
url: &alert.custom_payload.url,
authorAvatar: &alert.custom_payload.authorAvatar,
authorDisplayName: &alert.custom_payload.authorDisplayName,
channelName: &alert.custom_payload.channelName,
});
}
JobType::Badge(ref alert) => {
payload = AssembledPayload::Default(revolt_a2::request::payload::Payload {
aps: APS {
alert: None,
badge: get_badge_count(&db, &alert.user_id).await,
sound: None,
thread_id: None,
content_available: None,
category: None,
mutable_content: None,
url_args: None,
},
device_token: &alert.device_token,
options: payload_options.clone(),
data: std::collections::BTreeMap::new(),
})
}
}
let resp = match payload {
AssembledPayload::Alert(p) => client.send(p).await,
AssembledPayload::Default(p) => client.send(p).await,
};
//println!("response from APNS: {:?}", resp);
if let Err(err) = resp {
match err {
Error::ResponseError(Response {
error:
Some(ErrorBody {
reason: ErrorReason::BadDeviceToken | ErrorReason::Unregistered,
..
}),
..
}) => {
if let Err(err) = db
.remove_push_subscription_by_session_id(match task.job_type {
JobType::Alert(ref a) => &a.session_id.as_str(),
JobType::Badge(ref a) => &a.session_id.as_str(),
})
.await
{
revolt_config::capture_error(&err);
}
}
err => {
revolt_config::capture_error(&err);
}
}
}
}
}

View File

@@ -0,0 +1,29 @@
use async_std::channel::{unbounded, Receiver, Sender};
use authifier::AuthifierEvent;
use once_cell::sync::Lazy;
use crate::events::client::EventV1;
static Q: Lazy<(Sender<AuthifierEvent>, Receiver<AuthifierEvent>)> = Lazy::new(|| unbounded());
/// Get sender
pub fn sender() -> Sender<AuthifierEvent> {
Q.0.clone()
}
/// Start a new worker
pub async fn worker() {
loop {
let event = Q.1.recv().await.unwrap();
match &event {
AuthifierEvent::CreateSession { .. } | AuthifierEvent::CreateAccount { .. } => {
EventV1::Auth(event).global().await
}
AuthifierEvent::DeleteSession { user_id, .. }
| AuthifierEvent::DeleteAllSessions { user_id, .. } => {
let id = user_id.to_string();
EventV1::Auth(event).private(id).await
}
}
}
}

View File

@@ -8,17 +8,22 @@ use std::time::Instant;
const WORKER_COUNT: usize = 5;
pub mod ack;
pub mod apple_notifications;
pub mod authifier_relay;
pub mod last_message_id;
pub mod process_embeds;
pub mod web_push;
/// Spawn background workers
pub async fn start_workers(db: Database, authifier_db: authifier::Database) {
pub fn start_workers(db: Database, authifier_db: authifier::Database) {
task::spawn(authifier_relay::worker());
task::spawn(apple_notifications::worker(db.clone()));
for _ in 0..WORKER_COUNT {
task::spawn(ack::worker(db.clone()));
task::spawn(ack::worker(db.clone(), authifier_db.clone()));
task::spawn(last_message_id::worker(db.clone()));
task::spawn(process_embeds::worker(db.clone()));
task::spawn(web_push::worker(authifier_db.clone()));
task::spawn(web_push::worker(db.clone(), authifier_db.clone()));
}
}

View File

@@ -56,7 +56,7 @@ pub async fn worker(db: Database) {
let embeds = generate(
task.content,
&config.hosts.january,
config.features.limits.default.message_embeds,
config.features.limits.global.message_embeds,
semaphore,
)
.await;

View File

@@ -1,11 +1,15 @@
use std::collections::HashSet;
use std::{
collections::{HashMap, HashSet},
time::Duration,
};
use authifier::Database;
use authifier::Database as AuthifierDatabase;
use base64::{
engine::{self},
Engine as _,
};
use deadqueue::limited::Queue;
use fcm_v1::auth::{Authenticator, ServiceAccountKey};
use once_cell::sync::Lazy;
use revolt_config::config;
use revolt_models::v0::PushNotification;
@@ -16,6 +20,10 @@ use web_push::{
WebPushClient, WebPushMessageBuilder,
};
use crate::Database;
use super::apple_notifications;
/// Task information
#[derive(Debug)]
struct PushTask {
@@ -48,14 +56,32 @@ pub async fn queue(recipients: Vec<String>, payload: PushNotification) {
}
/// Start a new worker
pub async fn worker(db: Database) {
pub async fn worker(db: Database, authifier_db: AuthifierDatabase) {
let config = config().await;
let web_push_client = IsahcWebPushClient::new().unwrap();
let fcm_client = if config.api.fcm.api_key.is_empty() {
let fcm_client = if config.api.fcm.key_type.is_empty() {
None
} else {
Some(fcm::Client::new())
Some(fcm_v1::Client::new(
Authenticator::service_account::<&str>(ServiceAccountKey {
key_type: Some(config.api.fcm.key_type),
project_id: Some(config.api.fcm.project_id.clone()),
private_key_id: Some(config.api.fcm.private_key_id),
private_key: config.api.fcm.private_key,
client_email: config.api.fcm.client_email,
client_id: Some(config.api.fcm.client_id),
auth_uri: Some(config.api.fcm.auth_uri),
token_uri: config.api.fcm.token_uri,
auth_provider_x509_cert_url: Some(config.api.fcm.auth_provider_x509_cert_url),
client_x509_cert_url: Some(config.api.fcm.client_x509_cert_url),
})
.await
.unwrap(),
config.api.fcm.project_id,
false,
Duration::from_secs(5),
))
};
let web_push_private_key = engine::general_purpose::URL_SAFE_NO_PAD
@@ -65,42 +91,53 @@ pub async fn worker(db: Database) {
loop {
let task = Q.pop().await;
if let Ok(sessions) = db.find_sessions_with_subscription(&task.recipients).await {
if let Ok(sessions) = authifier_db
.find_sessions_with_subscription(&task.recipients)
.await
{
for session in sessions {
if let Some(sub) = session.subscription {
if sub.endpoint == "fcm" {
// Use Firebase Cloud Messaging
if let Some(client) = &fcm_client {
let PushNotification {
author,
icon,
image: _,
body,
tag,
timestamp: _,
url: _,
} = &task.payload;
let message = fcm_v1::message::Message {
token: Some(sub.auth),
data: Some(HashMap::from([(
"payload".to_owned(),
serde_json::Value::String(json!(&task.payload).to_string()),
)])),
..Default::default()
};
let mut notification = fcm::NotificationBuilder::new();
notification.title(author);
notification.icon(icon);
notification.body(body);
notification.tag(tag);
// TODO: expand support for fields
let notification = notification.finalize();
let mut message_builder =
fcm::MessageBuilder::new(&config.api.fcm.api_key, &sub.auth);
message_builder.notification(notification);
if let Err(err) = client.send(message_builder.finalize()).await {
if let Err(err) = client.send(&message).await {
error!("Failed to send FCM notification! {:?}", err);
if let fcm_v1::Error::FCM(fcm_error) = err {
if fcm_error.contains("404 (Not Found)") {
println!("Unregistering {:?}", session.id);
if let Err(err) = db
.remove_push_subscription_by_session_id(&session.id)
.await
{
revolt_config::capture_error(&err);
}
}
}
} else {
info!("Sent FCM notification to {:?}.", session.id);
}
} else {
info!("No FCM token was specified!");
}
} else if sub.endpoint == "apn" {
apple_notifications::queue(apple_notifications::ApnJob::from_notification(
session.id,
session.user_id,
sub.auth,
&task.payload,
))
.await;
} else {
// Use Web Push Standard
let subscription = SubscriptionInfo {

View File

@@ -1,7 +1,7 @@
use revolt_models::v0::*;
use revolt_permissions::{calculate_user_permissions, UserPermission};
use crate::{util::permissions::DatabasePermissionQuery, Database};
use crate::{util::permissions::DatabasePermissionQuery, Database, FileUsedFor};
impl crate::Bot {
pub fn into_public_bot(self, user: crate::User) -> PublicBot {
@@ -419,6 +419,10 @@ impl From<File> for crate::File {
user_id: value.user_id,
server_id: value.server_id,
object_id: value.object_id,
hash: None,
uploaded_at: None,
uploaded_id: None,
used_for: None,
}
}
}
@@ -459,26 +463,30 @@ impl From<Metadata> for crate::Metadata {
}
}
impl From<crate::Message> for Message {
fn from(value: crate::Message) -> Self {
impl crate::Message {
pub fn into_model(self, user: Option<User>, member: Option<Member>) -> Message {
Message {
id: value.id,
nonce: value.nonce,
channel: value.channel,
author: value.author,
webhook: value.webhook,
content: value.content,
system: value.system.map(|system| system.into()),
attachments: value
id: self.id,
nonce: self.nonce,
channel: self.channel,
author: self.author,
user,
member,
webhook: self.webhook,
content: self.content,
system: self.system.map(Into::into),
attachments: self
.attachments
.map(|v| v.into_iter().map(|f| f.into()).collect()),
edited: value.edited,
embeds: value.embeds,
mentions: value.mentions,
replies: value.replies,
reactions: value.reactions,
interactions: value.interactions.into(),
masquerade: value.masquerade.map(|masq| masq.into()),
edited: self.edited,
embeds: self.embeds,
mentions: self.mentions,
replies: self.replies,
reactions: self.reactions,
interactions: self.interactions.into(),
masquerade: self.masquerade.map(Into::into),
flags: self.flags.map(|flags| flags as u32).unwrap_or_default(),
pinned: self.pinned,
}
}
}
@@ -490,9 +498,11 @@ impl From<crate::PartialMessage> for PartialMessage {
nonce: value.nonce,
channel: value.channel,
author: value.author,
user: None,
member: None,
webhook: value.webhook,
content: value.content,
system: value.system.map(|system| system.into()),
system: value.system.map(Into::into),
attachments: value
.attachments
.map(|v| v.into_iter().map(|f| f.into()).collect()),
@@ -501,8 +511,10 @@ impl From<crate::PartialMessage> for PartialMessage {
mentions: value.mentions,
replies: value.replies,
reactions: value.reactions,
interactions: value.interactions.map(|interactions| interactions.into()),
masquerade: value.masquerade.map(|masq| masq.into()),
interactions: value.interactions.map(Into::into),
masquerade: value.masquerade.map(Into::into),
flags: value.flags.map(|flags| flags as u32),
pinned: value.pinned,
}
}
}
@@ -525,6 +537,8 @@ impl From<crate::SystemMessage> for SystemMessage {
crate::SystemMessage::UserKicked { id } => Self::UserKicked { id },
crate::SystemMessage::UserLeft { id } => Self::UserLeft { id },
crate::SystemMessage::UserRemove { id, by } => Self::UserRemove { id, by },
crate::SystemMessage::MessagePinned { id, by } => Self::MessagePinned { id, by },
crate::SystemMessage::MessageUnpinned { id, by } => Self::MessageUnpinned { id, by },
}
}
}
@@ -995,26 +1009,31 @@ impl crate::User {
vec![]
},
badges: self.badges.unwrap_or_default() as u32,
online: can_see_profile
&& revolt_presence::is_online(&self.id).await
&& !matches!(
self.status,
Some(crate::UserStatus {
presence: Some(crate::Presence::Invisible),
..
})
),
status: if can_see_profile {
self.status.map(|status| status.into())
} else {
None
},
profile: if can_see_profile {
self.profile.map(|profile| profile.into())
} else {
None
},
flags: self.flags.unwrap_or_default() as u32,
privileged: self.privileged,
bot: self.bot.map(|bot| bot.into()),
relationship,
online: can_see_profile && revolt_presence::is_online(&self.id).await,
id: self.id,
}
}
/// Convert user object into user model assuming mutual connection
///
/// Relations will never be included, i.e. when we process ourselves
pub fn into_known<'a, P>(self, perspective: P, is_online: bool) -> User
where
P: Into<Option<&'a crate::User>>,
@@ -1050,40 +1069,57 @@ impl crate::User {
discriminator: self.discriminator,
display_name: self.display_name,
avatar: self.avatar.map(|file| file.into()),
relations: if let Some(crate::User { id, .. }) = perspective {
if id == &self.id {
self.relations
.unwrap_or_default()
.into_iter()
.map(|relation| relation.into())
.collect()
} else {
vec![]
}
} else {
vec![]
},
relations: vec![],
badges: self.badges.unwrap_or_default() as u32,
online: can_see_profile
&& is_online
&& !matches!(
self.status,
Some(crate::UserStatus {
presence: Some(crate::Presence::Invisible),
..
})
),
status: if can_see_profile {
self.status.map(|status| status.into())
} else {
None
},
profile: if can_see_profile {
self.profile.map(|profile| profile.into())
} else {
None
},
flags: self.flags.unwrap_or_default() as u32,
privileged: self.privileged,
bot: self.bot.map(|bot| bot.into()),
relationship,
online: can_see_profile && is_online,
id: self.id,
}
}
pub async fn into_self(self) -> User {
/// Convert user object into user model without presence information
pub fn into_known_static<'a>(self, is_online: bool) -> User {
User {
username: self.username,
discriminator: self.discriminator,
display_name: self.display_name,
avatar: self.avatar.map(|file| file.into()),
relations: vec![],
badges: self.badges.unwrap_or_default() as u32,
online: is_online
&& !matches!(
self.status,
Some(crate::UserStatus {
presence: Some(crate::Presence::Invisible),
..
})
),
status: self.status.map(|status| status.into()),
flags: self.flags.unwrap_or_default() as u32,
privileged: self.privileged,
bot: self.bot.map(|bot| bot.into()),
relationship: RelationshipStatus::None, // events client will populate this from cache
id: self.id,
}
}
pub async fn into_self(self, force_online: bool) -> User {
User {
username: self.username,
discriminator: self.discriminator,
@@ -1099,13 +1135,19 @@ impl crate::User {
})
.unwrap_or_default(),
badges: self.badges.unwrap_or_default() as u32,
online: (force_online || revolt_presence::is_online(&self.id).await)
&& !matches!(
self.status,
Some(crate::UserStatus {
presence: Some(crate::Presence::Invisible),
..
})
),
status: self.status.map(|status| status.into()),
profile: self.profile.map(|profile| profile.into()),
flags: self.flags.unwrap_or_default() as u32,
privileged: self.privileged,
bot: self.bot.map(|bot| bot.into()),
relationship: RelationshipStatus::User,
online: revolt_presence::is_online(&self.id).await,
id: self.id,
}
}
@@ -1129,10 +1171,11 @@ impl From<User> for crate::User {
relations: None,
badges: Some(value.badges as i32),
status: value.status.map(Into::into),
profile: value.profile.map(Into::into),
profile: None,
flags: Some(value.flags as i32),
privileged: value.privileged,
bot: value.bot.map(Into::into),
suspended_until: None,
}
}
}
@@ -1152,7 +1195,6 @@ impl From<crate::PartialUser> for PartialUser {
}),
badges: value.badges.map(|badges| badges as u32),
status: value.status.map(|status| status.into()),
profile: value.profile.map(|profile| profile.into()),
flags: value.flags.map(|flags| flags as u32),
privileged: value.privileged,
bot: value.bot.map(|bot| bot.into()),
@@ -1171,6 +1213,9 @@ impl From<FieldsUser> for crate::FieldsUser {
FieldsUser::ProfileContent => crate::FieldsUser::ProfileContent,
FieldsUser::StatusPresence => crate::FieldsUser::StatusPresence,
FieldsUser::StatusText => crate::FieldsUser::StatusText,
FieldsUser::DisplayName => crate::FieldsUser::DisplayName,
FieldsUser::Internal => crate::FieldsUser::None,
}
}
}
@@ -1183,6 +1228,10 @@ impl From<crate::FieldsUser> for FieldsUser {
crate::FieldsUser::ProfileContent => FieldsUser::ProfileContent,
crate::FieldsUser::StatusPresence => FieldsUser::StatusPresence,
crate::FieldsUser::StatusText => FieldsUser::StatusText,
crate::FieldsUser::DisplayName => FieldsUser::DisplayName,
crate::FieldsUser::Suspension => FieldsUser::Internal,
crate::FieldsUser::None => FieldsUser::Internal,
}
}
}
@@ -1285,3 +1334,18 @@ impl From<BotInformation> for crate::BotInformation {
}
}
}
impl From<crate::FieldsMessage> for FieldsMessage {
fn from(value: crate::FieldsMessage) -> Self {
match value {
crate::FieldsMessage::Pinned => FieldsMessage::Pinned,
}
}
}
impl From<FieldsMessage> for crate::FieldsMessage {
fn from(value: FieldsMessage) -> Self {
match value {
FieldsMessage::Pinned => crate::FieldsMessage::Pinned,
}
}
}

View File

@@ -18,6 +18,10 @@ static TOKEN_CACHE: Lazy<Mutex<lru::LruCache<String, ()>>> =
Lazy::new(|| Mutex::new(lru::LruCache::new(NonZeroUsize::new(1000).unwrap())));
impl IdempotencyKey {
pub fn unchecked_from_string(key: String) -> Self {
Self { key }
}
// Backwards compatibility.
// Issue #109
pub async fn consume_nonce(&mut self, v: Option<String>) -> Result<()> {

View File

@@ -53,6 +53,12 @@ impl PermissionQuery for DatabasePermissionQuery<'_> {
if let Some(other_user) = &self.user {
if self.perspective.id == other_user.id {
return RelationshipStatus::User;
} else if let Some(bot) = &other_user.bot {
// For the purposes of permissions checks,
// assume owner is the same as bot
if self.perspective.id == bot.owner {
return RelationshipStatus::User;
}
}
if let Some(relations) = &self.perspective.relations {

View File

@@ -1,3 +1,5 @@
use std::str::FromStr;
use revolt_result::Result;
#[cfg(feature = "rocket-impl")]
use rocket::request::FromParam;
@@ -44,9 +46,27 @@ impl Reference {
db.fetch_channel(&self.id).await
}
/// Fetch invite from Ref
/// Fetch invite from Ref or create invite to server if discoverable
pub async fn as_invite(&self, db: &Database) -> Result<Invite> {
db.fetch_invite(&self.id).await
if ulid::Ulid::from_str(&self.id).is_ok() {
let server = self.as_server(db).await?;
if !server.discoverable {
return Err(create_error!(NotFound));
}
Ok(Invite::Server {
code: self.id.to_string(),
server: server.id,
creator: server.owner,
channel: server
.channels
.into_iter()
.next()
.ok_or(create_error!(NotFound))?,
})
} else {
db.fetch_invite(&self.id).await
}
}
/// Fetch message from Ref

View File

@@ -0,0 +1,554 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
<head>
<!-- Compiled with Bootstrap Email version: 1.3.1 -->
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<meta name="x-apple-disable-message-reformatting" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="format-detection"
content="telephone=no, date=no, address=no, email=no"
/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<style type="text/css">
body,
table,
td {
font-family: Helvetica, Arial, sans-serif !important;
}
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 150%;
}
a {
text-decoration: none;
}
* {
color: inherit;
}
a[x-apple-data-detectors],
u + #body a,
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
img {
-ms-interpolation-mode: bicubic;
}
table:not([class^="s-"]) {
font-family: Helvetica, Arial, sans-serif;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
border-spacing: 0px;
border-collapse: collapse;
}
table:not([class^="s-"]) td {
border-spacing: 0px;
border-collapse: collapse;
}
@media screen and (max-width: 600px) {
.w-full,
.w-full > tbody > tr > td {
width: 100% !important;
}
.w-24,
.w-24 > tbody > tr > td {
width: 96px !important;
}
.p-lg-10:not(table),
.p-lg-10:not(.btn) > tbody > tr > td,
.p-lg-10.btn td a {
padding: 0 !important;
}
.p-3:not(table),
.p-3:not(.btn) > tbody > tr > td,
.p-3.btn td a {
padding: 12px !important;
}
.p-6:not(table),
.p-6:not(.btn) > tbody > tr > td,
.p-6.btn td a {
padding: 24px !important;
}
*[class*="s-lg-"] > tbody > tr > td {
font-size: 0 !important;
line-height: 0 !important;
height: 0 !important;
}
.s-4 > tbody > tr > td {
font-size: 16px !important;
line-height: 16px !important;
height: 16px !important;
}
.s-6 > tbody > tr > td {
font-size: 24px !important;
line-height: 24px !important;
height: 24px !important;
}
.s-10 > tbody > tr > td {
font-size: 40px !important;
line-height: 40px !important;
height: 40px !important;
}
}
</style>
</head>
<body
class="bg-light"
style="
outline: 0;
width: 100%;
min-width: 100%;
height: 100%;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
font-family: Helvetica, Arial, sans-serif;
line-height: 24px;
font-weight: normal;
font-size: 16px;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: #000000;
margin: 0;
padding: 0;
border-width: 0;
"
bgcolor="#f7fafc"
>
<table
class="bg-light body"
valign="top"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="
outline: 0;
width: 100%;
min-width: 100%;
height: 100%;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
font-family: Helvetica, Arial, sans-serif;
line-height: 24px;
font-weight: normal;
font-size: 16px;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: #000000;
margin: 0;
padding: 0;
border-width: 0;
"
bgcolor="#f7fafc"
>
<tbody>
<tr>
<td
valign="top"
style="line-height: 24px; font-size: 16px; margin: 0"
align="left"
bgcolor="#f7fafc"
>
<table
class="container"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
>
<tbody>
<tr>
<td
align="center"
style="
line-height: 24px;
font-size: 16px;
margin: 0;
padding: 0 16px;
"
>
<!--[if (gte mso 9)|(IE)]>
<table align="center" role="presentation">
<tbody>
<tr>
<td width="600">
<![endif]-->
<table
align="center"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%; max-width: 600px; margin: 0 auto"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 16px;
margin: 0;
"
align="left"
>
<table
class="s-10 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 40px;
font-size: 40px;
width: 100%;
height: 40px;
margin: 0;
"
align="left"
width="100%"
height="40"
>
&#160;
</td>
</tr>
</tbody>
</table>
<table
class="ax-center"
role="presentation"
align="center"
border="0"
cellpadding="0"
cellspacing="0"
style="margin: 0 auto"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 16px;
margin: 0;
"
align="left"
>
<img
alt="Revolt Logo"
class="w-24"
src="https://app.revolt.chat/assets/logo_round.png"
style="
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
display: block;
width: 96px;
border-style: none;
border-width: 0;
"
width="96"
/>
</td>
</tr>
</tbody>
</table>
<table
class="s-10 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 40px;
font-size: 40px;
width: 100%;
height: 40px;
margin: 0;
"
align="left"
width="100%"
height="40"
>
&#160;
</td>
</tr>
</tbody>
</table>
<table
class="card p-6 p-lg-10 space-y-4"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="
border-radius: 6px;
border-collapse: separate !important;
width: 100%;
overflow: hidden;
border: 1px solid #e2e8f0;
"
bgcolor="#ffffff"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 16px;
width: 100%;
margin: 0;
padding: 40px;
"
align="left"
bgcolor="#ffffff"
>
<h1
class="h3 fw-700"
style="
padding-top: 0;
padding-bottom: 0;
font-weight: 700 !important;
vertical-align: baseline;
font-size: 28px;
line-height: 33.6px;
margin: 0;
"
align="left"
>
Account Deletion
</h1>
<table
class="s-4 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 16px;
font-size: 16px;
width: 100%;
height: 16px;
margin: 0;
"
align="left"
width="100%"
height="16"
>
&#160;
</td>
</tr>
</tbody>
</table>
<p
class=""
style="
line-height: 24px;
font-size: 16px;
width: 100%;
margin: 0;
"
align="left"
>
You requested to have your account
deleted, if you did not perform this
action please take measures to secure your
account immediately.
</p>
<table
class="s-4 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 16px;
font-size: 16px;
width: 100%;
height: 16px;
margin: 0;
"
align="left"
width="100%"
height="16"
>
&#160;
</td>
</tr>
</tbody>
</table>
<table
class="btn btn-primary p-3 fw-700"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="
border-radius: 6px;
border-collapse: separate !important;
font-weight: 700 !important;
"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 16px;
border-radius: 6px;
font-weight: 700 !important;
margin: 0;
"
align="center"
bgcolor="#0d6efd"
>
<a
href="{{url}}"
style="
color: #ffffff;
font-size: 16px;
font-family: Helvetica, Arial,
sans-serif;
text-decoration: none;
border-radius: 6px;
line-height: 20px;
display: block;
font-weight: 700 !important;
white-space: nowrap;
background-color: #0d6efd;
padding: 12px;
border: 1px solid #0d6efd;
"
>Confirm</a
>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table
class="s-6 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 24px;
width: 100%;
height: 24px;
margin: 0;
"
align="left"
width="100%"
height="24"
>
&#160;
</td>
</tr>
</tbody>
</table>
<div
class="text-muted text-center"
style="color: #718096"
align="center"
>
This email is intended for {{email}}<br />
Sent from Revolt<br />
Made in Europe
</div>
<table
class="s-6 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 24px;
width: 100%;
height: 24px;
margin: 0;
"
align="left"
width="100%"
height="24"
>
&#160;
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</tbody>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -23,7 +23,7 @@
<div class="text-muted text-center my-6">
This email is intended for {{email}}<br />
Sent from Revolt<br />
Made in the EU
Made in Europe
</div>
</div>
</body>

View File

@@ -4,4 +4,4 @@ Please navigate to: {{url}}
This email is intended for {{email}}
Sent by Revolt
Made in the EU
Made in Europe

View File

@@ -0,0 +1,552 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
<head>
<!-- Compiled with Bootstrap Email version: 1.3.1 -->
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<meta name="x-apple-disable-message-reformatting" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="format-detection"
content="telephone=no, date=no, address=no, email=no"
/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<style type="text/css">
body,
table,
td {
font-family: Helvetica, Arial, sans-serif !important;
}
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 150%;
}
a {
text-decoration: none;
}
* {
color: inherit;
}
a[x-apple-data-detectors],
u + #body a,
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
img {
-ms-interpolation-mode: bicubic;
}
table:not([class^="s-"]) {
font-family: Helvetica, Arial, sans-serif;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
border-spacing: 0px;
border-collapse: collapse;
}
table:not([class^="s-"]) td {
border-spacing: 0px;
border-collapse: collapse;
}
@media screen and (max-width: 600px) {
.w-full,
.w-full > tbody > tr > td {
width: 100% !important;
}
.w-24,
.w-24 > tbody > tr > td {
width: 96px !important;
}
.p-lg-10:not(table),
.p-lg-10:not(.btn) > tbody > tr > td,
.p-lg-10.btn td a {
padding: 0 !important;
}
.p-3:not(table),
.p-3:not(.btn) > tbody > tr > td,
.p-3.btn td a {
padding: 12px !important;
}
.p-6:not(table),
.p-6:not(.btn) > tbody > tr > td,
.p-6.btn td a {
padding: 24px !important;
}
*[class*="s-lg-"] > tbody > tr > td {
font-size: 0 !important;
line-height: 0 !important;
height: 0 !important;
}
.s-4 > tbody > tr > td {
font-size: 16px !important;
line-height: 16px !important;
height: 16px !important;
}
.s-6 > tbody > tr > td {
font-size: 24px !important;
line-height: 24px !important;
height: 24px !important;
}
.s-10 > tbody > tr > td {
font-size: 40px !important;
line-height: 40px !important;
height: 40px !important;
}
}
</style>
</head>
<body
class="bg-light"
style="
outline: 0;
width: 100%;
min-width: 100%;
height: 100%;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
font-family: Helvetica, Arial, sans-serif;
line-height: 24px;
font-weight: normal;
font-size: 16px;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: #000000;
margin: 0;
padding: 0;
border-width: 0;
"
bgcolor="#f7fafc"
>
<table
class="bg-light body"
valign="top"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="
outline: 0;
width: 100%;
min-width: 100%;
height: 100%;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
font-family: Helvetica, Arial, sans-serif;
line-height: 24px;
font-weight: normal;
font-size: 16px;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: #000000;
margin: 0;
padding: 0;
border-width: 0;
"
bgcolor="#f7fafc"
>
<tbody>
<tr>
<td
valign="top"
style="line-height: 24px; font-size: 16px; margin: 0"
align="left"
bgcolor="#f7fafc"
>
<table
class="container"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
>
<tbody>
<tr>
<td
align="center"
style="
line-height: 24px;
font-size: 16px;
margin: 0;
padding: 0 16px;
"
>
<!--[if (gte mso 9)|(IE)]>
<table align="center" role="presentation">
<tbody>
<tr>
<td width="600">
<![endif]-->
<table
align="center"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%; max-width: 600px; margin: 0 auto"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 16px;
margin: 0;
"
align="left"
>
<table
class="s-10 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 40px;
font-size: 40px;
width: 100%;
height: 40px;
margin: 0;
"
align="left"
width="100%"
height="40"
>
&#160;
</td>
</tr>
</tbody>
</table>
<table
class="ax-center"
role="presentation"
align="center"
border="0"
cellpadding="0"
cellspacing="0"
style="margin: 0 auto"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 16px;
margin: 0;
"
align="left"
>
<img
alt="Revolt Logo"
class="w-24"
src="https://app.revolt.chat/assets/logo_round.png"
style="
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
display: block;
width: 96px;
border-style: none;
border-width: 0;
"
width="96"
/>
</td>
</tr>
</tbody>
</table>
<table
class="s-10 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 40px;
font-size: 40px;
width: 100%;
height: 40px;
margin: 0;
"
align="left"
width="100%"
height="40"
>
&#160;
</td>
</tr>
</tbody>
</table>
<table
class="card p-6 p-lg-10 space-y-4"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="
border-radius: 6px;
border-collapse: separate !important;
width: 100%;
overflow: hidden;
border: 1px solid #e2e8f0;
"
bgcolor="#ffffff"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 16px;
width: 100%;
margin: 0;
padding: 40px;
"
align="left"
bgcolor="#ffffff"
>
<h1
class="h3 fw-700"
style="
padding-top: 0;
padding-bottom: 0;
font-weight: 700 !important;
vertical-align: baseline;
font-size: 28px;
line-height: 33.6px;
margin: 0;
"
align="left"
>
Password Reset
</h1>
<table
class="s-4 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 16px;
font-size: 16px;
width: 100%;
height: 16px;
margin: 0;
"
align="left"
width="100%"
height="16"
>
&#160;
</td>
</tr>
</tbody>
</table>
<p
class=""
style="
line-height: 24px;
font-size: 16px;
width: 100%;
margin: 0;
"
align="left"
>
You requested a password reset, click
below to continue.
</p>
<table
class="s-4 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 16px;
font-size: 16px;
width: 100%;
height: 16px;
margin: 0;
"
align="left"
width="100%"
height="16"
>
&#160;
</td>
</tr>
</tbody>
</table>
<table
class="btn btn-primary p-3 fw-700"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="
border-radius: 6px;
border-collapse: separate !important;
font-weight: 700 !important;
"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 16px;
border-radius: 6px;
font-weight: 700 !important;
margin: 0;
"
align="center"
bgcolor="#0d6efd"
>
<a
href="{{url}}"
style="
color: #ffffff;
font-size: 16px;
font-family: Helvetica, Arial,
sans-serif;
text-decoration: none;
border-radius: 6px;
line-height: 20px;
display: block;
font-weight: 700 !important;
white-space: nowrap;
background-color: #0d6efd;
padding: 12px;
border: 1px solid #0d6efd;
"
>Reset</a
>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table
class="s-6 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 24px;
width: 100%;
height: 24px;
margin: 0;
"
align="left"
width="100%"
height="24"
>
&#160;
</td>
</tr>
</tbody>
</table>
<div
class="text-muted text-center"
style="color: #718096"
align="center"
>
This email is intended for {{email}}<br />
Sent from Revolt<br />
Made in Europe
</div>
<table
class="s-6 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 24px;
width: 100%;
height: 24px;
margin: 0;
"
align="left"
width="100%"
height="24"
>
&#160;
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</tbody>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -19,7 +19,7 @@
<div class="text-muted text-center my-6">
This email is intended for {{email}}<br />
Sent from Revolt<br />
Made in the EU
Made in Europe
</div>
</div>
</body>

View File

@@ -4,4 +4,4 @@ Please navigate to: {{url}}
This email is intended for {{email}}
Sent by Revolt
Made in the EU
Made in Europe

View File

@@ -0,0 +1,625 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
<head>
<!-- Compiled with Bootstrap Email version: 1.5.1 -->
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<meta name="x-apple-disable-message-reformatting" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="format-detection"
content="telephone=no, date=no, address=no, email=no"
/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<style type="text/css">
body,
table,
td {
font-family: Helvetica, Arial, sans-serif !important;
}
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 150%;
}
a {
text-decoration: none;
}
* {
color: inherit;
}
a[x-apple-data-detectors],
u + #body a,
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
img {
-ms-interpolation-mode: bicubic;
}
table:not([class^="s-"]) {
font-family: Helvetica, Arial, sans-serif;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
border-spacing: 0px;
border-collapse: collapse;
}
table:not([class^="s-"]) td {
border-spacing: 0px;
border-collapse: collapse;
}
@media screen and (max-width: 600px) {
.w-full,
.w-full > tbody > tr > td {
width: 100% !important;
}
.w-24,
.w-24 > tbody > tr > td {
width: 96px !important;
}
.p-lg-10:not(table),
.p-lg-10:not(.btn) > tbody > tr > td,
.p-lg-10.btn td a {
padding: 0 !important;
}
.p-6:not(table),
.p-6:not(.btn) > tbody > tr > td,
.p-6.btn td a {
padding: 24px !important;
}
*[class*="s-lg-"] > tbody > tr > td {
font-size: 0 !important;
line-height: 0 !important;
height: 0 !important;
}
.s-4 > tbody > tr > td {
font-size: 16px !important;
line-height: 16px !important;
height: 16px !important;
}
.s-6 > tbody > tr > td {
font-size: 24px !important;
line-height: 24px !important;
height: 24px !important;
}
.s-10 > tbody > tr > td {
font-size: 40px !important;
line-height: 40px !important;
height: 40px !important;
}
}
</style>
</head>
<body
class="bg-light"
style="
outline: 0;
width: 100%;
min-width: 100%;
height: 100%;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
font-family: Helvetica, Arial, sans-serif;
line-height: 24px;
font-weight: normal;
font-size: 16px;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: #000000;
margin: 0;
padding: 0;
border-width: 0;
"
bgcolor="#f7fafc"
>
<table
class="bg-light body"
valign="top"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="
outline: 0;
width: 100%;
min-width: 100%;
height: 100%;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
font-family: Helvetica, Arial, sans-serif;
line-height: 24px;
font-weight: normal;
font-size: 16px;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: #000000;
margin: 0;
padding: 0;
border-width: 0;
"
bgcolor="#f7fafc"
>
<tbody>
<tr>
<td
valign="top"
style="line-height: 24px; font-size: 16px; margin: 0"
align="left"
bgcolor="#f7fafc"
>
<table
class="container"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
>
<tbody>
<tr>
<td
align="center"
style="
line-height: 24px;
font-size: 16px;
margin: 0;
padding: 0 16px;
"
>
<!--[if (gte mso 9)|(IE)]>
<table align="center" role="presentation">
<tbody>
<tr>
<td width="600">
<![endif]-->
<table
align="center"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%; max-width: 600px; margin: 0 auto"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 16px;
margin: 0;
"
align="left"
>
<table
class="s-10 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 40px;
font-size: 40px;
width: 100%;
height: 40px;
margin: 0;
"
align="left"
width="100%"
height="40"
>
&#160;
</td>
</tr>
</tbody>
</table>
<table
class="ax-center"
role="presentation"
align="center"
border="0"
cellpadding="0"
cellspacing="0"
style="margin: 0 auto"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 16px;
margin: 0;
"
align="left"
>
<img
alt="Revolt Logo"
class="w-24"
src="https://app.revolt.chat/assets/logo_round.png"
style="
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
display: block;
width: 96px;
border-style: none;
border-width: 0;
"
width="96"
/>
</td>
</tr>
</tbody>
</table>
<table
class="s-10 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 40px;
font-size: 40px;
width: 100%;
height: 40px;
margin: 0;
"
align="left"
width="100%"
height="40"
>
&#160;
</td>
</tr>
</tbody>
</table>
<table
class="card p-6 p-lg-10 space-y-4"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="
border-radius: 6px;
border-collapse: separate !important;
width: 100%;
overflow: hidden;
border: 1px solid #e2e8f0;
"
bgcolor="#ffffff"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 16px;
width: 100%;
margin: 0;
padding: 40px;
"
align="left"
bgcolor="#ffffff"
>
<h1
class="h3 fw-700"
style="
padding-top: 0;
padding-bottom: 0;
font-weight: 700 !important;
vertical-align: baseline;
font-size: 28px;
line-height: 33.6px;
margin: 0;
"
align="left"
>
Account Suspended
</h1>
<table
class="s-4 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 16px;
font-size: 16px;
width: 100%;
height: 16px;
margin: 0;
"
align="left"
width="100%"
height="16"
>
&#160;
</td>
</tr>
</tbody>
</table>
<p
class=""
style="
line-height: 24px;
font-size: 16px;
width: 100%;
margin: 0;
"
align="left"
>
Your account has been suspended, for one
or more reasons:
</p>
<table
class="s-4 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 16px;
font-size: 16px;
width: 100%;
height: 16px;
margin: 0;
"
align="left"
width="100%"
height="16"
>
&#160;
</td>
</tr>
</tbody>
</table>
<ul class="">
{{list}}
</ul>
<table
class="s-4 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 16px;
font-size: 16px;
width: 100%;
height: 16px;
margin: 0;
"
align="left"
width="100%"
height="16"
>
&#160;
</td>
</tr>
</tbody>
</table>
<p
style="display: {{duration_display}}; line-height: 24px; font-size: 16px; width: 100%; margin: 0;"
class=""
align="left"
>
You will be able to use your account again
in {{duration}} days.
</p>
<table
class="s-4 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 16px;
font-size: 16px;
width: 100%;
height: 16px;
margin: 0;
"
align="left"
width="100%"
height="16"
>
&#160;
</td>
</tr>
</tbody>
</table>
<p
class=""
style="
line-height: 24px;
font-size: 16px;
width: 100%;
margin: 0;
"
align="left"
>
Further violations may result in a
permanent ban depending on severity,
please abide by the
<a
href="https://revolt.chat/aup"
style="color: #0d6efd"
>Acceptable Usage Policy</a
>.
</p>
<table
class="s-4 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 16px;
font-size: 16px;
width: 100%;
height: 16px;
margin: 0;
"
align="left"
width="100%"
height="16"
>
&#160;
</td>
</tr>
</tbody>
</table>
<p
style="
line-height: 24px;
font-size: 16px;
width: 100%;
margin: 0;
"
align="left"
>
Ban evasion is prohibited and will be
dealt with accordingly.
</p>
</td>
</tr>
</tbody>
</table>
<table
class="s-6 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 24px;
width: 100%;
height: 24px;
margin: 0;
"
align="left"
width="100%"
height="24"
>
&#160;
</td>
</tr>
</tbody>
</table>
<div
class="text-muted text-center"
style="color: #718096"
align="center"
>
This email is intended for {{email}}<br />
Sent from Revolt<br />
Made in Europe
</div>
<table
class="s-6 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 24px;
width: 100%;
height: 24px;
margin: 0;
"
align="left"
width="100%"
height="24"
>
&#160;
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</tbody>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1,38 @@
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<style>
/* Add custom classes and styles that you want inlined here */
</style>
</head>
<body class="bg-light">
<div class="container">
<img
alt="Revolt Logo"
class="ax-center my-10 w-24"
src="https://app.revolt.chat/assets/logo_round.png"
/>
<div class="card p-6 p-lg-10 space-y-4">
<h1 class="h3 fw-700">Account Suspended</h1>
<p>Your account has been suspended, for one or more reasons:</p>
<ul>
{{list}}
</ul>
<p style="display:{{duration_display}}">
You will be able to use your account again in {{duration}} days.
</p>
<p>
Further violations may result in a permanent ban depending on
severity, please abide by the
<a href="https://revolt.chat/aup">Acceptable Usage Policy</a>.
</p>
<p>Ban evasion is prohibited and will be dealt with accordingly.</p>
</div>
<div class="text-muted text-center my-6">
This email is intended for {{email}}<br />
Sent from Revolt<br />
Made in Europe
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,12 @@
Your account has been suspended, for one or more reasons:
{{list}}
You will be able to use your account again in {{duration}} days.
Further violations may result in a permanent ban depending on severity, please abide by the Acceptable Usage Policy (https://revolt.chat/aup).
Ban evasion is prohibited and will be dealt with accordingly.
This email is intended for {{email}}
Sent by Revolt
Made in Europe

View File

@@ -0,0 +1,552 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
<head>
<!-- Compiled with Bootstrap Email version: 1.3.1 -->
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<meta name="x-apple-disable-message-reformatting" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="format-detection"
content="telephone=no, date=no, address=no, email=no"
/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<style type="text/css">
body,
table,
td {
font-family: Helvetica, Arial, sans-serif !important;
}
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 150%;
}
a {
text-decoration: none;
}
* {
color: inherit;
}
a[x-apple-data-detectors],
u + #body a,
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
img {
-ms-interpolation-mode: bicubic;
}
table:not([class^="s-"]) {
font-family: Helvetica, Arial, sans-serif;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
border-spacing: 0px;
border-collapse: collapse;
}
table:not([class^="s-"]) td {
border-spacing: 0px;
border-collapse: collapse;
}
@media screen and (max-width: 600px) {
.w-full,
.w-full > tbody > tr > td {
width: 100% !important;
}
.w-24,
.w-24 > tbody > tr > td {
width: 96px !important;
}
.p-lg-10:not(table),
.p-lg-10:not(.btn) > tbody > tr > td,
.p-lg-10.btn td a {
padding: 0 !important;
}
.p-3:not(table),
.p-3:not(.btn) > tbody > tr > td,
.p-3.btn td a {
padding: 12px !important;
}
.p-6:not(table),
.p-6:not(.btn) > tbody > tr > td,
.p-6.btn td a {
padding: 24px !important;
}
*[class*="s-lg-"] > tbody > tr > td {
font-size: 0 !important;
line-height: 0 !important;
height: 0 !important;
}
.s-4 > tbody > tr > td {
font-size: 16px !important;
line-height: 16px !important;
height: 16px !important;
}
.s-6 > tbody > tr > td {
font-size: 24px !important;
line-height: 24px !important;
height: 24px !important;
}
.s-10 > tbody > tr > td {
font-size: 40px !important;
line-height: 40px !important;
height: 40px !important;
}
}
</style>
</head>
<body
class="bg-light"
style="
outline: 0;
width: 100%;
min-width: 100%;
height: 100%;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
font-family: Helvetica, Arial, sans-serif;
line-height: 24px;
font-weight: normal;
font-size: 16px;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: #000000;
margin: 0;
padding: 0;
border-width: 0;
"
bgcolor="#f7fafc"
>
<table
class="bg-light body"
valign="top"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="
outline: 0;
width: 100%;
min-width: 100%;
height: 100%;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
font-family: Helvetica, Arial, sans-serif;
line-height: 24px;
font-weight: normal;
font-size: 16px;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: #000000;
margin: 0;
padding: 0;
border-width: 0;
"
bgcolor="#f7fafc"
>
<tbody>
<tr>
<td
valign="top"
style="line-height: 24px; font-size: 16px; margin: 0"
align="left"
bgcolor="#f7fafc"
>
<table
class="container"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
>
<tbody>
<tr>
<td
align="center"
style="
line-height: 24px;
font-size: 16px;
margin: 0;
padding: 0 16px;
"
>
<!--[if (gte mso 9)|(IE)]>
<table align="center" role="presentation">
<tbody>
<tr>
<td width="600">
<![endif]-->
<table
align="center"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%; max-width: 600px; margin: 0 auto"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 16px;
margin: 0;
"
align="left"
>
<table
class="s-10 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 40px;
font-size: 40px;
width: 100%;
height: 40px;
margin: 0;
"
align="left"
width="100%"
height="40"
>
&#160;
</td>
</tr>
</tbody>
</table>
<table
class="ax-center"
role="presentation"
align="center"
border="0"
cellpadding="0"
cellspacing="0"
style="margin: 0 auto"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 16px;
margin: 0;
"
align="left"
>
<img
alt="Revolt Logo"
class="w-24"
src="https://app.revolt.chat/assets/logo_round.png"
style="
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
display: block;
width: 96px;
border-style: none;
border-width: 0;
"
width="96"
/>
</td>
</tr>
</tbody>
</table>
<table
class="s-10 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 40px;
font-size: 40px;
width: 100%;
height: 40px;
margin: 0;
"
align="left"
width="100%"
height="40"
>
&#160;
</td>
</tr>
</tbody>
</table>
<table
class="card p-6 p-lg-10 space-y-4"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="
border-radius: 6px;
border-collapse: separate !important;
width: 100%;
overflow: hidden;
border: 1px solid #e2e8f0;
"
bgcolor="#ffffff"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 16px;
width: 100%;
margin: 0;
padding: 40px;
"
align="left"
bgcolor="#ffffff"
>
<h1
class="h3 fw-700"
style="
padding-top: 0;
padding-bottom: 0;
font-weight: 700 !important;
vertical-align: baseline;
font-size: 28px;
line-height: 33.6px;
margin: 0;
"
align="left"
>
Almost there!
</h1>
<table
class="s-4 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 16px;
font-size: 16px;
width: 100%;
height: 16px;
margin: 0;
"
align="left"
width="100%"
height="16"
>
&#160;
</td>
</tr>
</tbody>
</table>
<p
class=""
style="
line-height: 24px;
font-size: 16px;
width: 100%;
margin: 0;
"
align="left"
>
To complete your sign up, we just need to
verify your email.
</p>
<table
class="s-4 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 16px;
font-size: 16px;
width: 100%;
height: 16px;
margin: 0;
"
align="left"
width="100%"
height="16"
>
&#160;
</td>
</tr>
</tbody>
</table>
<table
class="btn btn-primary p-3 fw-700"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="
border-radius: 6px;
border-collapse: separate !important;
font-weight: 700 !important;
"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 16px;
border-radius: 6px;
font-weight: 700 !important;
margin: 0;
"
align="center"
bgcolor="#0d6efd"
>
<a
href="{{url}}"
style="
color: #ffffff;
font-size: 16px;
font-family: Helvetica, Arial,
sans-serif;
text-decoration: none;
border-radius: 6px;
line-height: 20px;
display: block;
font-weight: 700 !important;
white-space: nowrap;
background-color: #0d6efd;
padding: 12px;
border: 1px solid #0d6efd;
"
>Confirm</a
>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table
class="s-6 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 24px;
width: 100%;
height: 24px;
margin: 0;
"
align="left"
width="100%"
height="24"
>
&#160;
</td>
</tr>
</tbody>
</table>
<div
class="text-muted text-center"
style="color: #718096"
align="center"
>
This email is intended for {{email}}<br />
Sent from Revolt<br />
Made in Europe
</div>
<table
class="s-6 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 24px;
width: 100%;
height: 24px;
margin: 0;
"
align="left"
width="100%"
height="24"
>
&#160;
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</tbody>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -20,7 +20,7 @@
<div class="text-muted text-center my-6">
This email is intended for {{email}}<br />
Sent from Revolt<br />
Made in the EU
Made in Europe
</div>
</div>
</body>

View File

@@ -5,4 +5,4 @@ Please navigate to: {{url}}
This email is intended for {{email}}
Sent by Revolt
Made in the EU
Made in Europe

View File

@@ -0,0 +1,18 @@
[package]
name = "revolt-files"
version = "0.7.16"
edition = "2021"
license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"]
description = "Revolt Backend: S3 and encryption subroutines"
[dependencies]
base64 = "0.22.1"
aes-gcm = "0.10.3"
typenum = "1.17.0"
aws-config = "1.5.5"
aws-sdk-s3 = { version = "1.46.0", features = ["behavior-version-latest"] }
revolt-config = { version = "0.7.16", path = "../config" }
revolt-result = { version = "0.7.16", path = "../result" }

View File

@@ -0,0 +1,80 @@
use std::io::Write;
use aes_gcm::{aead::AeadMutInPlace, Aes256Gcm, Key, KeyInit, Nonce};
use revolt_config::{config, FilesS3};
use revolt_result::{create_error, Result};
use aws_sdk_s3::{
config::{Credentials, Region},
Client, Config,
};
use base64::prelude::*;
pub static AUTHENTICATION_TAG_SIZE_BYTES: usize = 16;
/// Create an S3 client
pub fn create_client(s3_config: FilesS3) -> Client {
let provider_name = "my-creds";
let creds = Credentials::new(
s3_config.access_key_id,
s3_config.secret_access_key,
None,
None,
provider_name,
);
let config = Config::builder()
.region(Region::new(s3_config.region))
.endpoint_url(s3_config.endpoint)
.credentials_provider(creds)
.build();
Client::from_conf(config)
}
/// Create an AES-256-GCM cipher
pub fn create_cipher(key: &str) -> Aes256Gcm {
let key = &BASE64_STANDARD.decode(key).unwrap()[..];
let key: &Key<Aes256Gcm> = key.into();
Aes256Gcm::new(key)
}
/// Fetch a file from S3 (and decrypt it)
pub async fn fetch_from_s3(bucket_id: &str, path: &str, nonce: &str) -> Result<Vec<u8>> {
let config = config().await;
let client = create_client(config.files.s3);
// Send a request for the file
let mut obj = client
.get_object()
.bucket(bucket_id)
.key(path)
.send()
.await
.inspect_err(|err| {
revolt_config::capture_error(err);
})
.map_err(|_| create_error!(InternalError))?;
// Read the file from remote
let mut buf = vec![];
while let Some(bytes) = obj.body.next().await {
let data = bytes.map_err(|_| create_error!(InternalError))?;
buf.write_all(&data)
.map_err(|_| create_error!(InternalError))?;
// is there a more efficient way to do this?
// we just want the Vec<u8>
}
// Recover nonce as bytes
let nonce = &BASE64_STANDARD.decode(nonce).unwrap()[..];
let nonce: &Nonce<typenum::consts::U12> = nonce.into();
// Decrypt the file
create_cipher(&config.files.encryption_key)
.decrypt_in_place(nonce, b"", &mut buf)
.map_err(|_| create_error!(InternalError))?;
Ok(buf)
}

View File

@@ -1,8 +1,8 @@
[package]
name = "revolt-models"
version = "0.7.1"
version = "0.7.16"
edition = "2021"
license = "AGPL-3.0-or-later"
license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"]
description = "Revolt Backend: API Models"
@@ -11,16 +11,17 @@ description = "Revolt Backend: API Models"
[features]
serde = ["dep:serde", "revolt-permissions/serde", "indexmap/serde"]
schemas = ["dep:schemars", "revolt-permissions/schemas"]
utoipa = ["dep:utoipa"]
validator = ["dep:validator"]
rocket = ["dep:rocket"]
partials = ["dep:revolt_optional_struct", "serde", "schemas"]
partials = ["dep:revolt_optional_struct", "serde", "schemas", "utoipa"]
default = ["serde", "partials", "rocket"]
[dependencies]
# Core
revolt-config = { version = "0.7.1", path = "../config" }
revolt-permissions = { version = "0.7.1", path = "../permissions" }
revolt-config = { version = "0.7.16", path = "../config" }
revolt-permissions = { version = "0.7.16", path = "../permissions" }
# Utility
regex = "1"
@@ -37,6 +38,7 @@ iso8601-timestamp = { version = "0.2.11", features = ["schema", "bson"] }
# Spec Generation
schemars = { version = "0.8.8", optional = true, features = ["indexmap1"] }
utoipa = { version = "4.2.3", optional = true }
# Validation
validator = { version = "0.16.0", optional = true, features = ["derive"] }

View File

@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2024 Pawel Makles
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -6,6 +6,10 @@ extern crate serde;
#[macro_use]
extern crate schemars;
#[cfg(feature = "utoipa")]
#[macro_use]
extern crate utoipa;
#[cfg(feature = "partials")]
#[macro_use]
extern crate revolt_optional_struct;
@@ -18,6 +22,7 @@ macro_rules! auto_derived {
$(
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "schemas", derive(JsonSchema))]
#[cfg_attr(feature = "utoipa", derive(ToSchema))]
#[derive(Debug, Clone, Eq, PartialEq)]
$item
)+
@@ -66,3 +71,8 @@ pub fn if_false(t: &bool) -> bool {
pub fn if_zero_u32(t: &u32) -> bool {
t == &0
}
/// Utility function to check if an option doesnt contain true
pub fn if_option_false(t: &Option<bool>) -> bool {
t != &Some(true)
}

View File

@@ -162,4 +162,11 @@ auto_derived!(
/// User objects
pub users: Vec<User>,
}
/// Bot with user response
pub struct BotWithUserResponse {
#[serde(flatten)]
pub bot: Bot,
pub user: User,
}
);

View File

@@ -2,6 +2,7 @@ use super::{Channel, File, Server, User};
auto_derived!(
/// Invite
#[serde(tag = "type")]
pub enum Invite {
/// Invite to a specific server channel
Server {

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