Compare commits

...

147 Commits

Author SHA1 Message Date
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
Paul Makles
7703475868 chore: split sentry dsn into separate options per-app 2024-04-08 23:25:53 +01:00
Paul Makles
4be3bdc4c3 fix: return more appropriate error on token fetch fail 2024-04-08 23:24:49 +01:00
Paul Makles
3e26e7e89d fix: use correct id to lookup user from session 2024-04-08 23:24:33 +01:00
Paul Makles
13b95d383e fix: import std::env in config 2024-04-08 22:46:35 +01:00
Paul Makles
3c76005c33 ci: strip references to quark 2024-04-08 22:00:33 +01:00
Paul Makles
3065e153d5 refactor: delete quark crate (closes #239) 2024-04-08 21:56:35 +01:00
Paul Makles
a1eeedabb9 refactor(bonfire): remove quark dependency 2024-04-08 21:49:19 +01:00
Paul Makles
0fc59b8e2a fix: add validator dependency to database 2024-04-07 23:49:09 +01:00
Paul Makles
265d3ba8ef chore: bump version to 0.7.0 2024-04-07 23:45:29 +01:00
Paul Makles
20d398d02c chore: remove quark dependency from delta 🎉
closes #283

fix: allow setting port and use_tls from config
closes #143
2024-04-07 23:41:31 +01:00
Paul Makles
6bc74749d2 refactor(quark): port message_query, message_search
#283
2024-04-07 23:13:45 +01:00
Paul Makles
301676fb54 refactor(quark): port report_content & code clean-up
#283
2024-04-07 22:57:55 +01:00
Paul Makles
6b488f347e feat(core/database): implement reports & snapshots 2024-04-07 22:42:46 +01:00
Paul Makles
1a8e43d280 ci: also update the first job 2024-04-07 22:29:24 +01:00
Paul Makles
acb4190f70 fix: delete associated server objects 2024-04-07 22:26:56 +01:00
Paul Makles
9e42567050 refactor(quark): port roles_create, roles_delete, roles_edit, roles_fetch, server_ack, server_delete, server_edit, server_fetch
#283
2024-04-07 22:26:56 +01:00
Sophie L
78fb6e1982 fix(ci): remnants from branch rename 2024-04-07 22:26:31 +01:00
Paul Makles
9de963fb56 refactor(quark): port member_edit, member_experimental_query, member_fetch_all, member_remove, permissions_set_default, permissions_set
#283
2024-04-07 21:35:45 +01:00
Paul Makles
03340a2b5a refactor(quark): port ban_create, ban_list, ban_remove, emoji_list, invites_fetch
#283
2024-04-07 20:48:36 +01:00
Paul Makles
b2d3344ddd refactor(quark): port edit_user, fetch_dms, fetch_profile, fetch_self, fetch_user_flags, fetch_user, find_mutual, open_dm
#283
2024-04-07 19:38:08 +01:00
Paul Makles
49bb235938 refactor(quark): port add_friend, block_user, change_username, remove_friend, send_friend_request, unblock_user
#283
2024-04-07 18:30:59 +01:00
Paul Makles
4e7049d51e fix: fully qualify create_error in create_database_error macro 2024-04-07 18:11:37 +01:00
Paul Makles
2de7598f8d refactor(quark): port get/set_settings, get_unreads
#283
2024-04-07 18:10:11 +01:00
Paul Makles
f6a565385e refactor(quark): port invite_delete, invite_fetch, invite_join 2024-04-07 17:47:59 +01:00
Paul Makles
569bd1d5e1 refactor(quark): port message_react, message_send, message_unreact, permissions_set_default, permissions_set
#283
2024-04-07 17:12:59 +01:00
Paul Makles
aca1fe6dff refactor(quark): port message_edit, message_fetch, voice_join 2024-04-07 16:47:46 +01:00
Paul Makles
aded2e3239 fix: add elevation check to role edit route 2024-04-07 15:52:14 +01:00
Paul Makles
54a4eff623 refactor(quark): port channel_ack, channel_delete, channel_ edit, group_remove_member, invite_create, members_fetch, message_bulk_delete, message_clear_reactions, message_delete
#283
2024-04-07 15:41:43 +01:00
Paul Makles
50c36dcefd docs: update environment variables [skip ci] 2024-03-29 11:06:09 +00:00
insertish
f16c275bcb ci: synced local '.github/workflows/triage_pr.yml' with remote 'workflows/triage_pr.yml'
[skip ci]
2024-03-28 13:37:54 +00:00
Paul Makles
74ff072199 docs: deployment lifecycle added to README 2024-03-13 22:43:49 +00:00
Paul Makles
52431bd1dd docs: update README [skip ci] 2024-03-13 22:08:31 +00:00
Paul Makles
fa67e87467 chore: switch branch name to main [skip ci] 2024-03-13 22:08:23 +00:00
Bob Bobs
8f9b18444a chore(bonfire): log errors from websocket stream 2024-03-13 21:47:34 +00:00
Paul Makles
7b18e983c9 chore: faster tag and release procedure 2024-02-12 21:40:51 +00:00
Paul Makles
c0e64c9052 chore: bump version 2024-02-12 21:34:49 +00:00
Paul Makles
0d54ccd342 chore: update justfile for faster releases 2024-02-12 21:33:01 +00:00
Paul Makles
d4d5b23c7d fix: use a valid default permission set for groups 2024-02-12 21:23:38 +00:00
Paul Makles
a641034895 test: write a unit test for group chat permissions 2024-02-12 21:19:36 +00:00
Paul Makles
9dd24526ef chore: clippy recommendations 2024-02-12 21:13:31 +00:00
Bob Bobs
6d4f772d78 fix(ci): only start run command in background 2024-02-12 21:12:24 +00:00
Bob Bobs
4c4dada3da fix(ci): build API then run in background to prevent timeout 2024-02-11 14:16:31 +00:00
Paul Makles
cf82972a80 chore: bump version 2024-02-10 23:51:04 +00:00
Bob Bobs
ea00f0fec1 fix(delta): set ratelimit headers on 429 responses 2024-02-10 23:49:15 +00:00
Bob Bobs
226dbca6e0 fix(core/models): use IndexMap for reactions 2024-02-10 23:45:34 +00:00
Bob Bobs
e4b5a23f71 fix(delta): MemberResponse only include roles member has 2024-02-10 23:44:20 +00:00
Bob Bobs
9663caa1ff fix(delta): use untagged enum for MemberResponse 2024-02-10 23:44:20 +00:00
Paul Makles
f9f0f409a8 chore: bump version to 0.6.11 2024-02-10 01:05:08 +00:00
Paul Makles
8f4d6c864b feat: block mentions from low trust users (account created < 12 hours) 2024-02-10 01:04:52 +00:00
Paul Makles
513461c99a chore: bump version 2024-02-06 13:43:00 +00:00
ToastXC
41ef76ff3e feat: member fetching with roles (#299) 2024-02-06 13:20:35 +00:00
Paul Makles
c451e431cd merge: pull request #301 from TheBobBobs/bonfire-redis
fix(bonfire): random disconnects from redis
2024-02-06 13:18:55 +00:00
Amy
09a848f5a6 fix: don't exceed the max emoji limit by one
closes #295
2024-02-06 13:17:13 +00:00
Bob Bobs
308193377d fix(bonfire): use REDIS_URI env var 2024-02-02 10:31:11 -07:00
Bob Bobs
3d3326e68a (bonfire): parse correct PayloadType 2024-02-01 20:07:12 -07:00
Bob Bobs
88c2232a6c fix(bonfire): use fred for redis
https://github.com/aembke/fred.rs
2024-02-01 19:00:45 -07:00
Bob Bobs
25016efbc2 fix(database): fetch all server emojis instead of one 2023-12-02 11:46:18 +00:00
Bob Bobs
db63ac4064 fix: add emojis to ServerCreate event 2023-12-02 11:46:18 +00:00
TheBobBobs
1f593a1708 fix: allow reacting with existing emojis when at limit 2023-12-02 11:45:23 +00:00
TheBobBobs
219c16a69c fix: set relationship for users in BulkMessageResponse 2023-12-01 20:54:59 +00:00
Paul Makles
aed8f69f34 fix(database): set channel ids for new server object 2023-11-20 10:54:48 +00:00
Paul Makles
196a856c68 refactor: move and update fixme / todo comments 2023-10-28 14:53:39 +01:00
Paul Makles
9bf60e87e9 fix: validate masquerade on new message models 2023-10-28 14:53:28 +01:00
Paul Makles
b90e7a4412 fix: prevent potential double fetching of cached server 2023-10-28 14:53:12 +01:00
Paul Makles
cb813e91f0 chore: cut new 0.6.9 release 2023-10-28 14:39:10 +01:00
Paul Makles
a6bc9fcbb3 refactor: implement Server::create 2023-10-28 14:25:29 +01:00
Paul Makles
cd0737911b refactor: use fixtures to populate db
refactor: method for creating server channels
2023-10-27 16:31:37 +01:00
Paul Makles
7705354d4d ci: use reference db for generating spec 2023-10-26 15:07:03 +01:00
Paul Makles
39ac8b2278 refactor: don't create unnecessary reference 2023-10-26 14:12:28 +01:00
arslee07
c5494aa1ca fix: respect Permission::SendEmbeds when editing a message 2023-10-26 14:12:28 +01:00
Paul Makles
1a96bf5a4b chore: remove redundant file [skip ci] 2023-10-26 14:05:01 +01:00
Paul Makles
37a1244751 test: ignore hello world test because it breaks cargo test? 2023-10-26 14:05:01 +01:00
Paul Makles
bf39f18f8d fix: always generate new test database 2023-10-26 14:05:01 +01:00
Paul Makles
95be7dc87a ci: use nextest because cargo test sig sevs 2023-10-26 14:05:01 +01:00
Paul Makles
9be171c7b6 fix: don't copy the old test file 2023-10-26 14:05:01 +01:00
Paul Makles
786fbb8ae4 chore: disable broken tests 2023-10-26 14:05:01 +01:00
Paul Makles
6c0210e8a1 feat: always use test configuration during tests 2023-10-26 14:05:01 +01:00
Paul Makles
456bf7b42a fix: check server ownership for text channel
refactor: minor cleanup of code
2023-10-26 14:05:01 +01:00
Paul Makles
21ffea0f9f fix(core/database): store member during permission query 2023-10-26 14:05:01 +01:00
Paul Makles
96d90215d2 fix(core/models): validator feature flag not properly gatekept 2023-10-26 14:05:01 +01:00
Paul Makles
29dcf714f2 chore: start work on test for permission issue 2023-10-26 14:05:01 +01:00
Paul Makles
cb97004d3f feat(core): provide user profile where appropriate 2023-10-16 09:33:36 +01:00
Paul Makles
0b6e5dd9db chore: add nix file to vscode config 2023-10-16 09:23:11 +01:00
Paul Makles
7132877201 feat(core): validation for files in reference db 2023-10-16 09:22:53 +01:00
Paul Makles
4d42fb74e7 fix(core): publish user settings event 2023-10-16 09:22:17 +01:00
Paul Makles
866ace3381 refactor: use config for max bot count 2023-10-16 09:21:48 +01:00
Paul Makles
0798e81862 feat(core): implement ratelimit events for reference db 2023-10-16 09:21:34 +01:00
Paul Makles
2fa5ac41ac refactor: tests for more group routes 2023-09-22 19:04:17 +01:00
Paul Makles
25ae1555a2 chore: add nextest and fmt to nix 2023-09-22 19:03:49 +01:00
Paul Makles
881888bd1c chore: add timeout for nextest runs 2023-09-22 19:03:29 +01:00
Paul Makles
41f20c2239 ci: use custom Revolt.toml for tests 2023-09-10 15:46:00 +01:00
Paul Makles
38f1381fea chore: switch to resolver=2 2023-09-10 15:19:23 +01:00
Paul Makles
6838b0b1f0 refactor: ensure in-memory database consistency
test: update authifier to allow for in-memory tests
ci: explicitly run in-memory and mongo tests
2023-09-10 14:49:27 +01:00
Paul Makles
055deab105 fix: allow bots to authenticate with new routes 2023-09-10 14:48:13 +01:00
Paul Makles
6d2073120d fix: carry over send embed permission when sending [skip ci] 2023-09-10 12:41:23 +01:00
Paul Makles
9789909061 feat(delta): rewrite emoji routes w/o quark
#283
2023-09-05 12:36:18 +01:00
Paul Makles
895de86f1e fix(core/config): use uppercase filename 2023-09-03 19:00:17 +01:00
Paul Makles
98b8c5403b feat(delta): rewrite channel / message fetch 2023-09-03 18:27:21 +01:00
Paul Makles
aa7c75c60e chore: ignore Revolt.toml 2023-09-03 18:12:15 +01:00
Paul Makles
4270f0c5d4 feat(core): implement create group 2023-09-03 18:12:08 +01:00
Paul Makles
eb1f45d208 fix(core/database): use attachments collection name 2023-09-03 17:43:13 +01:00
Paul Makles
9929f7499a refactor(core): use core crates for sending messages 2023-09-03 16:31:39 +01:00
Paul Makles
279d9ef1b5 refactor(core): remove quark references from webhook routes 2023-09-03 15:52:18 +01:00
Paul Makles
5a9bb9e68d fix: convert index map/set to hash map/set
fix: idempotency token should use correct crate cfg
2023-08-27 15:51:20 +01:00
Paul Makles
a8fc9e928a fix: import validator crate directly in derive 2023-08-27 15:45:23 +01:00
Paul Makles
da231ae390 fix: remove indexmap from models 2023-08-27 15:43:57 +01:00
Paul Makles
7a31da4189 fix: add indexmap/serde to model depends 2023-08-27 15:40:05 +01:00
Paul Makles
7d0b5392b5 fix: should include crate in build script too 2023-08-27 15:33:28 +01:00
Paul Makles
1e1d44c0e4 fix: add config crate to Dockerfile
chore: bump release version
2023-08-27 15:30:53 +01:00
344 changed files with 8036 additions and 15589 deletions

2
.config/nextest.toml Normal file
View File

@@ -0,0 +1,2 @@
[profile.default]
slow-timeout = { period = "3s", terminate-after = 2 }

View File

@@ -1,5 +1,7 @@
# MongoDB URI
MONGODB=mongodb://localhost
AUTUMN_MONGO_URI=mongodb://database
REDIS_URI=redis://localhost/
# URL to where the Revolt app is publicly accessible
REVOLT_APP_URL=http://local.revolt.chat:5000
@@ -80,3 +82,26 @@ REVOLT_VAPID_PUBLIC_KEY=BGcvgR-i2z4IQ5Mw841vJvkLjt8wY-FjmWrw83jOLCY52qcGZS0OF7nf
##
# VOSO_MANAGE_TOKEN=CHANGEME
##
## Autumn configuration
##
# S3 Region
AUTUMN_S3_REGION=minio
# S3 Endpoint
AUTUMN_S3_ENDPOINT=http://minio:9000
# MinIO Root User
MINIO_ROOT_USER=minioautumn
# MinIO Root Password
MINIO_ROOT_PASSWORD=minioautumn
# AWS Access Key ID
AWS_ACCESS_KEY_ID=minioautumn
# AWS Secret Key
AWS_SECRET_ACCESS_KEY=minioautumn

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

@@ -3,19 +3,20 @@ name: Docker Test & Publish
on:
push:
branches:
- "master"
- "main"
tags:
- "*"
paths-ignore:
- ".github/**"
- "!.github/workflows/docker.yml"
- ".vscode/**"
- "doc/**"
- ".gitignore"
- "LICENSE"
- "README"
pull_request:
branches:
- "master"
- "main"
paths:
- "Dockerfile"
workflow_dispatch:

View File

@@ -2,8 +2,16 @@ name: Rust build, test, and generate specification
on:
push:
paths-ignore:
- ".github/**"
- "!.github/workflows/rust.yaml"
- ".vscode/**"
- "doc/**"
- ".gitignore"
- "LICENSE"
- "README"
pull_request:
branches: [master]
branches: [main]
env:
CARGO_TERM_COLOR: always
@@ -20,6 +28,11 @@ jobs:
toolchain: stable
override: true
components: rustfmt, clippy
- name: Install cargo-nextest
uses: baptiste0928/cargo-install@v1
with:
crate: cargo-nextest
locked: true
- name: Run cargo build
uses: actions-rs/cargo@v1
@@ -30,36 +43,38 @@ jobs:
run: |
docker-compose -f docker-compose.db.yml up -d
- name: Run cargo test
uses: actions-rs/cargo@v1
with:
command: test
- name: Run cargo test (with MongoDB)
uses: actions-rs/cargo@v1
env:
MONGODB: mongodb://localhost
with:
command: test
- name: Copy .env.example
if: github.event_name != 'pull_request' && github.ref_name == 'master'
run: |
cp .env.example .env
- name: Start API in background
if: github.event_name != 'pull_request' && github.ref_name == 'master'
- name: Run cargo test
env:
TEST_DB: REFERENCE
run: |
cargo run --bin revolt-delta &
cargo nextest run
- name: Run cargo test (with MongoDB)
env:
TEST_DB: MONGODB
MONGODB: mongodb://localhost
run: |
cargo nextest run
- name: Start API in background
if: github.event_name != 'pull_request' && github.ref_name == 'main'
env:
TEST_DB: REFERENCE
run: |
cargo build --bin revolt-delta && (cargo run --bin revolt-delta &)
- name: Wait for API to go up
if: github.event_name != 'pull_request' && github.ref_name == 'master'
if: github.event_name != 'pull_request' && github.ref_name == 'main'
uses: nev7n/wait_for_response@v1
with:
url: "http://localhost:8000/"
- name: Checkout API repository
if: github.event_name != 'pull_request' && github.ref_name == 'master'
if: github.event_name != 'pull_request' && github.ref_name == 'main'
uses: actions/checkout@v3
with:
repository: revoltchat/api
@@ -67,11 +82,11 @@ jobs:
token: ${{ secrets.PAT }}
- name: Download OpenAPI specification
if: github.event_name != 'pull_request' && github.ref_name == 'master'
if: github.event_name != 'pull_request' && github.ref_name == 'main'
run: curl http://localhost:8000/openapi.json -o api/OpenAPI.json
- name: Commit changes
if: github.event_name != 'pull_request' && github.ref_name == 'master'
if: github.event_name != 'pull_request' && github.ref_name == 'main'
uses: EndBug/add-and-commit@v4
with:
cwd: "api"

View File

@@ -15,7 +15,7 @@ jobs:
gh api graphql -f query='
query {
organization(login: "revoltchat"){
projectV2(number: 3) {
projectV2(number: 5) {
id
fields(first:20) {
nodes {
@@ -35,7 +35,7 @@ jobs:
echo 'PROJECT_ID='$(jq '.data.organization.projectV2.id' project_data.json) >> $GITHUB_ENV
echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV
echo 'INCOMING_OPTION_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="Incoming PRs") |.id' project_data.json) >> $GITHUB_ENV
echo 'INCOMING_OPTION_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="🆕 Untriaged") |.id' project_data.json) >> $GITHUB_ENV
- name: Add PR to project
env:

2
.gitignore vendored
View File

@@ -1,4 +1,6 @@
Rocket.toml
Revolt.toml
target
.data
.env

View File

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

990
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,10 @@
[workspace]
members = ["crates/delta", "crates/bonfire", "crates/quark", "crates/core/*"]
resolver = "2"
members = ["crates/delta", "crates/bonfire", "crates/core/*"]
[patch.crates-io]
# mobc-redis = { git = "https://github.com/insertish/mobc", rev = "8b880bb59f2ba80b4c7bc40c649c113d8857a186" }
redis22 = { package = "redis", version = "0.22.3", git = "https://github.com/revoltchat/redis-rs", rev = "1a41faf356fd21aebba71cea7eb7eb2653e5f0ef" }
redis23 = { package = "redis", version = "0.23.1", git = "https://github.com/revoltchat/redis-rs", rev = "f8ca28ab85da59d2ccde526b4d2fb390eff5a5f9" }
# authifier = { package = "authifier", version = "1.0.8", path = "../authifier/crates/authifier" }
# rocket_authifier = { package = "rocket_authifier", version = "1.0.8", path = "../authifier/crates/rocket_authifier" }

View File

@@ -19,7 +19,7 @@ RUN sh /tmp/build-image-layer.sh tools
COPY Cargo.toml Cargo.lock ./
COPY crates/bonfire/Cargo.toml ./crates/bonfire/
COPY crates/delta/Cargo.toml ./crates/delta/
COPY crates/quark/Cargo.toml ./crates/quark/
COPY crates/core/config/Cargo.toml ./crates/core/config/
COPY crates/core/database/Cargo.toml ./crates/core/database/
COPY crates/core/models/Cargo.toml ./crates/core/models/
COPY crates/core/permissions/Cargo.toml ./crates/core/permissions/

View File

@@ -15,7 +15,7 @@ COPY scripts/build-image-layer.sh /tmp/
COPY Cargo.toml Cargo.lock ./
COPY crates/bonfire/Cargo.toml ./crates/bonfire/
COPY crates/delta/Cargo.toml ./crates/delta/
COPY crates/quark/Cargo.toml ./crates/quark/
COPY crates/core/config/Cargo.toml ./crates/core/config/
COPY crates/core/database/Cargo.toml ./crates/core/database/
COPY crates/core/models/Cargo.toml ./crates/core/models/
COPY crates/core/permissions/Cargo.toml ./crates/core/permissions/

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/>

156
README.md
View File

@@ -2,34 +2,150 @@
This is a monorepo for the Revolt backend.
| Crate | Path | Description |
| ---------------- | ---------------------------------------------- | ------------------------------------ |
| `delta` | [crates/delta](crates/delta) | REST API server |
| `bonfire` | [crates/bonfire](crates/bonfire) | WebSocket events server |
| `quark` | [crates/quark](crates/quark) | Models and logic |
<!--| `revcord/api` | [crates/revcord/api](crates/revcord/api) | Discord REST translation layer |
| `revcord/ws` | [crates/revcord/ws](crates/revcord/ws) | Discord gateway translation layer |
| `revcord/models` | [crates/revcord/models](crates/revcord/models) | Discord models and quark translation |-->
| Crate | Path | Description |
| ------------------ | -------------------------------------------------- | ----------------------------- |
| `core/config` | [crates/core/config](crates/core/config) | Core: Configuration |
| `core/database` | [crates/core/database](crates/core/database) | Core: Database Implementation |
| `core/models` | [crates/core/models](crates/core/models) | Core: API Models |
| `core/permissions` | [crates/core/permissions](crates/core/permissions) | Core: Permission Logic |
| `core/presence` | [crates/core/presence](crates/core/presence) | Core: User Presence |
| `core/result` | [crates/core/result](crates/core/result) | Core: Result and Error types |
| `delta` | [crates/delta](crates/delta) | REST API server |
| `bonfire` | [crates/bonfire](crates/bonfire) | WebSocket events server |
Note: `january`, `autumn`, and `vortex` are yet to be moved into this monorepo.
## Resources
### Revolt
- [Revolt Project Board](https://github.com/revoltchat/revolt/discussions) (Submit feature requests here)
- [Revolt Testers Server](https://app.revolt.chat/invite/Testers)
- [Contribution Guide](https://developers.revolt.chat/contributing)
## Minimum Supported Rust Version
Rust 1.70 or higher.
## Contributing
## Development Guide
The contribution guide is located at [developers.revolt.chat/contributing](https://developers.revolt.chat/contributing).
Please note that a pull request should only take care of one issue so that we can review it quickly.
Before getting started, you'll want to install:
- Rust toolchain (rustup recommended)
- Docker
- Git
- mold (optional, faster compilation)
> A **default.nix** is available for Nix users!
> Just run `nix-shell` and continue.
Now you can clone and build the project:
```bash
git clone https://github.com/revoltchat/backend revolt-backend
cd revolt-backend
cargo build
```
If you want to run the API and event servers:
```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...
```
You may want to copy the following configuration:
```toml
# Revolt.toml
[database]
mongodb = "mongodb://localhost"
redis = "redis://localhost"
[hosts]
app = "http://local.revolt.chat"
api = "http://local.revolt.chat:8000"
events = "ws://local.revolt.chat:9000"
autumn = "http://local.revolt.chat:3000"
january = "http://local.revolt.chat:7000"
voso_legacy = ""
voso_legacy_ws = ""
```
Then continue:
```bash
# start other necessary services
docker compose up -d
# run the API server
cargo run --bin revolt-delta
# run the events server
cargo run --bin revolt-bonfire
# hint:
# mold -run <cargo build, cargo run, etc...>
```
You can start a web client by doing the following:
```bash
# if you do not have yarn yet and have a modern Node.js:
corepack enable
# clone the web client and run it:
git clone --recursive https://github.com/revoltchat/revite
cd revite
yarn
yarn build:deps
yarn dev --port 3001
```
Then go to https://local.revolt.chat:3001
## Deployment Guide
### Cutting new crate releases
Begin by bumping crate versions:
```bash
just patch # 0.0.X
just minor # 0.X.0
just major # X.0.0
```
Then commit the changes to package files.
Proceed to publish all the new crates:
```bash
just publish
```
### Cutting new binary releases
Tag and push a new release by running:
```bash
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=MONGOBD 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). Please check individual crates for further license information.
The Revolt backend is generally licensed under the [GNU Affero General Public License v3.0](https://github.com/revoltchat/backend/blob/master/LICENSE).
**Individual crates may supply their own licenses!**

View File

@@ -1,6 +1,9 @@
disallowed-methods = [
# Shouldn't need to access these directly
"revolt_database::models::bots::model::Bot::remove_field",
"revolt_database::models::messages::model::Message::attach_sendable_embed",
"revolt_database::models::users::model::User::set_relationship",
"revolt_database::models::users::model::User::apply_relationship",
# Prefer to use Object::create()
"revolt_database::models::bots::ops::AbstractBots::insert_bot",

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-bonfire"
version = "0.6.7"
version = "0.7.9"
license = "AGPL-3.0-or-later"
edition = "2021"
@@ -9,15 +9,19 @@ edition = "2021"
[dependencies]
# util
log = "*"
sentry = "0.31.5"
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"
# quark
revolt-quark = { path = "../quark" }
# serde
bincode = "1.3.3"
serde_json = "1.0.79"
rmp-serde = "1.0.0"
serde = "1.0.136"
@@ -32,5 +36,13 @@ 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.9", path = "../core/permissions" }
revolt-presence = { path = "../core/presence", features = ["redis-is-patched"] }
sentry = "0.31.5"
# redis
fred = { version = "8.0.1", features = ["subscriber-client"] }

View File

@@ -1,6 +1,6 @@
use async_tungstenite::tungstenite::{handshake, Message};
use futures::channel::oneshot::Sender;
use revolt_quark::{Error, Result};
use revolt_result::{create_error, Result};
use serde::{Deserialize, Serialize};
/// Enumeration of supported protocol formats
@@ -37,16 +37,16 @@ impl ProtocolConfiguration {
match self.format {
ProtocolFormat::Json => {
if let Message::Text(text) = msg {
serde_json::from_str(text).map_err(|_| Error::InternalError)
serde_json::from_str(text).map_err(|_| create_error!(InternalError))
} else {
Err(Error::InternalError)
Err(create_error!(InternalError))
}
}
ProtocolFormat::Msgpack => {
if let Message::Binary(buf) = msg {
rmp_serde::from_slice(buf).map_err(|_| Error::InternalError)
rmp_serde::from_slice(buf).map_err(|_| create_error!(InternalError))
} else {
Err(Error::InternalError)
Err(create_error!(InternalError))
}
}
}

View File

@@ -1,5 +1,5 @@
use once_cell::sync::OnceCell;
use revolt_quark::{Database, DatabaseInfo};
use revolt_database::{Database, DatabaseInfo};
static DBCONN: OnceCell<Database> = OnceCell::new();
@@ -10,7 +10,9 @@ pub async fn connect() {
.await
.expect("Failed to connect to the database.");
DBCONN.set(database).expect("Setting `Database`");
if DBCONN.set(database).is_err() {
panic!("couldn't set database")
}
}
/// Get a reference to the current database.

View File

@@ -1,21 +1,15 @@
use std::collections::HashSet;
use crate::{
get_relationship,
models::{
server_member::FieldsMember,
user::{PartialUser, Presence, RelationshipStatus},
Channel, Member, User,
},
perms, Database, Permission, Result,
use revolt_database::{
events::client::EventV1, util::permissions::DatabasePermissionQuery, Channel, Database, Member,
MemberCompositeKey, Presence, RelationshipStatus,
};
use revolt_models::v0;
use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
use revolt_presence::filter_online;
use revolt_result::Result;
use super::{
client::EventV1,
state::{Cache, State},
};
use super::state::{Cache, State};
/// Cache Manager
impl Cache {
@@ -25,20 +19,22 @@ impl Cache {
Channel::TextChannel { server, .. } | Channel::VoiceChannel { server, .. } => {
let member = self.members.get(server);
let server = self.servers.get(server);
let mut perms = perms(self.users.get(&self.user_id).unwrap()).channel(channel);
let mut query =
DatabasePermissionQuery::new(db, self.users.get(&self.user_id).unwrap())
.channel(channel);
// let mut perms = perms(self.users.get(&self.user_id).unwrap()).channel(channel);
if let Some(member) = member {
perms.member.set_ref(member);
query = query.member(member);
}
if let Some(server) = server {
perms.server.set_ref(server);
query = query.server(server);
}
perms
.has_permission(db, Permission::ViewChannel)
calculate_channel_permissions(&mut query)
.await
.unwrap_or_default()
.has_channel_permission(ChannelPermission::ViewChannel)
}
_ => true,
}
@@ -63,7 +59,7 @@ impl Cache {
/// Check whether we can subscribe to another user
pub fn can_subscribe_to_user(&self, user_id: &str) -> bool {
if let Some(user) = self.users.get(&self.user_id) {
match get_relationship(user, user_id) {
match user.relationship_with(user_id) {
RelationshipStatus::Friend
| RelationshipStatus::Incoming
| RelationshipStatus::Outgoing
@@ -95,7 +91,8 @@ impl Cache {
impl State {
/// Generate a Ready packet for the current user
pub async fn generate_ready_payload(&mut self, db: &Database) -> Result<EventV1> {
let mut user = self.clone_user();
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
@@ -141,7 +138,6 @@ impl State {
// Fetch presence data for known users.
let online_ids = filter_online(&user_ids.iter().cloned().collect::<Vec<String>>()).await;
user.online = Some(true);
// Fetch user data.
let users = db
@@ -154,15 +150,14 @@ impl State {
.await?;
// Fetch customisations.
let emojis = Some(
db.fetch_emoji_by_parent_ids(
let emojis = db
.fetch_emoji_by_parent_ids(
&servers
.iter()
.map(|x| x.id.to_string())
.collect::<Vec<String>>(),
)
.await?,
);
.await?;
// Copy data into local state cache.
self.cache.users = users.iter().cloned().map(|x| (x.id.clone(), x)).collect();
@@ -176,40 +171,43 @@ impl State {
.collect();
// Make all users appear from our perspective.
let mut users: Vec<User> = users
let mut users: Vec<v0::User> = users
.into_iter()
.map(|mut x| {
x.online = Some(online_ids.contains(&x.id));
x.with_relationship(&user)
.map(|other_user| {
let is_online = online_ids.contains(&other_user.id);
other_user.into_known(&user, is_online)
})
.collect();
// Make sure we see our own user correctly.
user.relationship = Some(RelationshipStatus::User);
users.push(user.foreign());
users.push(user.into_self().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,
channels,
members,
emojis,
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(),
})
}
@@ -243,11 +241,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 });
@@ -270,8 +268,8 @@ impl State {
.channels
.insert(channel.id().to_string(), channel.clone());
self.insert_subscription(channel.id().to_string());
bulk_events.push(EventV1::ChannelCreate(channel));
self.insert_subscription(channel.id().to_string()).await;
bulk_events.push(EventV1::ChannelCreate(channel.into()));
}
}
}
@@ -296,7 +294,7 @@ impl State {
} {
let event = EventV1::UserUpdate {
id: self.cache.user_id.clone(),
data: PartialUser {
data: v0::PartialUser {
online: Some(target),
..Default::default()
},
@@ -343,8 +341,8 @@ impl State {
match event {
EventV1::ChannelCreate(channel) => {
let id = channel.id().to_string();
self.insert_subscription(id.clone());
self.cache.channels.insert(id, channel.clone());
self.insert_subscription(id.clone()).await;
self.cache.channels.insert(id, channel.clone().into());
}
EventV1::ChannelUpdate {
id, data, clear, ..
@@ -357,10 +355,10 @@ impl State {
if let Some(channel) = self.cache.channels.get_mut(id) {
for field in clear {
channel.remove(field);
channel.remove_field(&field.clone().into());
}
channel.apply_options(data.clone());
channel.apply_options(data.clone().into());
}
if !self.cache.channels.contains_key(id) {
@@ -374,7 +372,7 @@ impl State {
if could_view != can_view {
if can_view {
queue_add = Some(id.clone());
*event = EventV1::ChannelCreate(channel.clone());
*event = EventV1::ChannelCreate(channel.clone().into());
} else {
queue_remove = Some(id.clone());
*event = EventV1::ChannelDelete { id: id.clone() };
@@ -383,17 +381,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;
}
}
@@ -401,16 +399,28 @@ impl State {
id,
server,
channels,
emojis: _,
} => {
self.insert_subscription(id.clone());
self.cache.servers.insert(id.clone(), server.clone());
let member = Member::new(id.clone(), self.cache.user_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 {
server: server.id.clone(),
user: self.cache.user_id.clone(),
},
..Default::default()
};
self.cache.members.insert(id.clone(), member);
for channel in channels {
self.cache
.channels
.insert(channel.id().to_string(), channel.clone());
.insert(channel.id().to_string(), channel.clone().into());
}
queue_server = Some(id.clone());
@@ -420,10 +430,10 @@ impl State {
} => {
if let Some(server) = self.cache.servers.get_mut(id) {
for field in clear {
server.remove(field);
server.remove_field(&field.clone().into());
}
server.apply_options(data.clone());
server.apply_options(data.clone().into());
}
if data.default_permissions.is_some() {
@@ -435,11 +445,11 @@ impl State {
}
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 +457,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);
}
}
@@ -461,13 +471,13 @@ impl State {
if id.user == self.cache.user_id {
if let Some(member) = self.cache.members.get_mut(&id.server) {
for field in &clear.clone() {
member.remove(field);
member.remove_field(&field.clone().into());
}
member.apply_options(data.clone());
member.apply_options(data.clone().into());
}
if data.roles.is_some() || clear.contains(&FieldsMember::Roles) {
if data.roles.is_some() || clear.contains(&v0::FieldsMember::Roles) {
queue_server = Some(id.server.clone());
}
}
@@ -482,10 +492,10 @@ impl State {
if let Some(server) = self.cache.servers.get_mut(id) {
if let Some(role) = server.roles.get_mut(role_id) {
for field in &clear.clone() {
role.remove(field);
role.remove_field(&field.clone().into());
}
role.apply_options(data.clone());
role.apply_options(data.clone().into());
}
}
@@ -521,12 +531,12 @@ impl State {
*event_id = None;
}
EventV1::UserRelationship { id, user, .. } => {
self.cache.users.insert(id.clone(), user.clone());
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;
}
}
@@ -540,50 +550,13 @@ 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
}
}
impl EventV1 {
/// Publish helper wrapper
pub async fn p(self, channel: String) {
#[cfg(not(debug_assertions))]
redis_kiss::p(channel, self).await;
#[cfg(debug_assertions)]
info!("Publishing event to {channel}: {self:?}");
#[cfg(debug_assertions)]
redis_kiss::publish(channel, self).await.unwrap();
}
/// Publish user event
pub async fn p_user(self, id: String, db: &Database) {
self.clone().p(id.clone()).await;
// ! FIXME: 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;
}
}
}
/// Publish private event
pub async fn private(self, id: String) {
self.p(format!("{id}!")).await;
}
/// Publish internal global event
pub async fn global(self) {
self.p("global".to_string()).await;
}
}

View File

@@ -0,0 +1,2 @@
pub mod r#impl;
pub mod state;

View File

@@ -1,8 +1,13 @@
use std::collections::{HashMap, HashSet};
use std::{
collections::{HashMap, HashSet},
sync::Arc,
time::Duration,
};
use async_std::sync::{Mutex, RwLock};
use lru::LruCache;
use crate::models::{Channel, Member, Server, User};
use lru_time_cache::{LruCache as LruTimeCache, TimedEntry};
use revolt_database::{Channel, Member, Server, User};
/// Enumeration representing some change in subscriptions
pub enum SubscriptionStateChange {
@@ -31,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>,
@@ -44,6 +50,7 @@ impl Default for Cache {
fn default() -> Self {
Cache {
user_id: Default::default(),
is_bot: false,
users: Default::default(),
channels: Default::default(),
@@ -59,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());
@@ -81,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);
}
}
@@ -108,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;
}
@@ -138,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;
}
@@ -160,6 +206,6 @@ impl State {
SubscriptionStateChange::Reset => panic!("Should not remove during a reset!"),
}
self.subscribed.remove(subscription);
subscribed.remove(subscription);
}
}

View File

@@ -7,6 +7,7 @@ use revolt_presence::clear_region;
extern crate log;
pub mod config;
pub mod events;
mod database;
mod websocket;
@@ -14,7 +15,7 @@ mod websocket;
#[async_std::main]
async fn main() {
// Configure requirements for Bonfire.
revolt_quark::configure!();
revolt_config::configure!(events);
database::connect().await;
// Clean up the current region information.
@@ -29,6 +30,10 @@ async fn main() {
// Start accepting new connections and spawn a client for each connection.
while let Ok((stream, addr)) = listener.accept().await {
websocket::spawn_client(database::get_db(), stream, addr);
async_std::task::spawn(async move {
info!("User connected from {addr:?}");
websocket::client(database::get_db(), stream, addr).await;
info!("User disconnected from {addr:?}");
});
}
}

View File

@@ -1,257 +1,523 @@
use std::net::SocketAddr;
use std::{collections::HashSet, net::SocketAddr, sync::Arc};
use futures::{channel::oneshot, pin_mut, select, FutureExt, SinkExt, StreamExt, TryStreamExt};
use revolt_presence::{create_session, delete_session};
use revolt_quark::{
events::{
client::EventV1,
server::ClientMessage,
state::{State, SubscriptionStateChange},
},
models::{user::UserHint, User},
redis_kiss, Database,
use async_tungstenite::WebSocketStream;
use authifier::AuthifierEvent;
use fred::{
error::{RedisError, RedisErrorKind},
interfaces::{ClientLike, EventInterface, PubsubInterface},
types::RedisConfig,
};
use futures::{
channel::oneshot,
join, pin_mut, select,
stream::{SplitSink, SplitStream},
FutureExt, SinkExt, StreamExt, TryStreamExt,
};
use redis_kiss::{PayloadType, REDIS_PAYLOAD_TYPE, REDIS_URI};
use revolt_database::{
events::{client::EventV1, server::ClientMessage},
Database, User, UserHint,
};
use revolt_presence::{create_session, delete_session};
use async_std::{net::TcpStream, sync::Mutex, task};
use async_std::{
net::TcpStream,
sync::{Mutex, RwLock},
task::spawn,
};
use revolt_result::create_error;
use sentry::Level;
use crate::config::WebsocketHandshakeCallback;
use crate::config::{ProtocolConfiguration, WebsocketHandshakeCallback};
use crate::events::state::{State, SubscriptionStateChange};
/// Spawn a new WebSocket client worker given access to the database,
type WsReader = SplitStream<WebSocketStream<TcpStream>>;
type WsWriter = SplitSink<WebSocketStream<TcpStream>, async_tungstenite::tungstenite::Message>;
/// Start a new WebSocket client worker given access to the database,
/// the relevant TCP stream and the remote address of the client.
pub fn spawn_client(db: &'static Database, stream: TcpStream, addr: SocketAddr) {
// Spawn a new Async task to work on.
task::spawn(async move {
info!("User connected from {addr:?}");
pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr) {
// Upgrade the TCP connection to a WebSocket connection.
// In this process, we also parse any additional parameters given.
// e.g. wss://example.com?format=json&version=1
let (sender, receiver) = oneshot::channel();
let Ok(ws) = async_tungstenite::accept_hdr_async_with_config(
stream,
WebsocketHandshakeCallback::from(sender),
None,
)
.await
else {
return;
};
// Upgrade the TCP connection to a WebSocket connection.
// In this process, we also parse any additional parameters given.
// e.g. wss://example.com?format=json&version=1
let (sender, receiver) = oneshot::channel();
if let Ok(ws) = async_tungstenite::accept_hdr_async_with_config(
stream,
WebsocketHandshakeCallback::from(sender),
None,
)
.await
{
// Verify we've received a valid config, otherwise we should just drop the connection.
if let Ok(mut config) = receiver.await {
info!(
"User {addr:?} provided protocol configuration (version = {}, format = {:?})",
config.get_protocol_version(),
config.get_protocol_format()
);
// Verify we've received a valid config, otherwise we should just drop the connection.
let Ok(mut config) = receiver.await else {
return;
};
// Split the socket for simultaneously read and write.
let (write, mut read) = ws.split();
let write = Mutex::new(write);
info!(
"User {addr:?} provided protocol configuration (version = {}, format = {:?})",
config.get_protocol_version(),
config.get_protocol_format()
);
// If the user has not provided authentication, request information.
if config.get_session_token().is_none() {
'outer: while let Ok(message) = read.try_next().await {
if let Ok(ClientMessage::Authenticate { token }) =
config.decode(message.as_ref().unwrap())
{
config.set_session_token(token);
break 'outer;
}
// Split the socket for simultaneously read and write.
let (mut write, mut read) = ws.split();
// If the user has not provided authentication, request information.
if config.get_session_token().is_none() {
while let Ok(Some(message)) = read.try_next().await {
if let Ok(ClientMessage::Authenticate { token }) = config.decode(&message) {
config.set_session_token(token);
break;
}
}
}
// 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, 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, session_id);
let user_id = state.cache.user_id.clone();
// Notify socket we have authenticated.
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 ready_payload = match state.generate_ready_payload(db).await {
Ok(ready_payload) => ready_payload,
Err(err) => {
sentry::capture_error(&err);
return;
}
};
if let Err(err) = write.send(config.encode(&ready_payload)).await {
error!("Failed to write: {err:?}");
sentry::capture_error(&err);
return;
}
// Create presence session.
let (first_session, session_id) = create_session(&user_id, 0).await;
// If this was the first session, notify other users that we just went online.
if first_session {
state.broadcast_presence_change(true).await;
}
{
// 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_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;
// If this was the last session, notify other users that we just went offline.
if last_session {
state.broadcast_presence_change(false).await;
}
}
#[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 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();
'out: loop {
// Check for state changes for subscriptions.
match state.apply_state().await {
SubscriptionStateChange::Reset => {
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;
}
}
// Try to authenticate the user.
if let Some(token) = config.get_session_token().as_ref() {
match User::from_token(db, token, UserHint::Any).await {
Ok(user) => {
info!("User {addr:?} authenticated as @{}", user.username);
#[cfg(debug_assertions)]
info!("{addr:?} has reset their subscriptions");
}
SubscriptionStateChange::Change { add, remove } => {
for id in remove {
#[cfg(debug_assertions)]
info!("{addr:?} unsubscribing from {id}");
// Create local state.
let mut state = State::from(user);
let user_id = state.cache.user_id.clone();
if let Err(err) = subscriber.unsubscribe(id).await {
error!("Unsubscribe failed: {err:?}");
sentry::capture_error(&err);
break 'out;
}
}
// Create presence session.
let (first_session, session_id) = create_session(&user_id, 0).await;
for id in add {
#[cfg(debug_assertions)]
info!("{addr:?} subscribing to {id}");
// Notify socket we have authenticated.
write
.lock()
.await
.send(config.encode(&EventV1::Authenticated))
.await
.ok();
// Download required data to local cache and send Ready payload.
if let Ok(ready_payload) = state.generate_ready_payload(db).await {
write
.lock()
.await
.send(config.encode(&ready_payload))
.await
.ok();
// If this was the first session, notify other users that we just went online.
if first_session {
state.broadcast_presence_change(true).await;
}
// Create a PubSub connection to poll on.
let listener = async {
if let Ok(mut conn) = redis_kiss::open_pubsub_connection().await
{
loop {
// Check for state changes for subscriptions.
match state.apply_state() {
SubscriptionStateChange::Reset => {
for id in state.iter_subscriptions() {
conn.subscribe(id).await.unwrap();
}
#[cfg(debug_assertions)]
info!("{addr:?} has reset their subscriptions");
}
SubscriptionStateChange::Change { add, remove } => {
for id in remove {
#[cfg(debug_assertions)]
info!("{addr:?} unsubscribing from {id}");
conn.unsubscribe(id).await.unwrap();
}
for id in add {
#[cfg(debug_assertions)]
info!("{addr:?} subscribing to {id}");
conn.subscribe(id).await.unwrap();
}
}
SubscriptionStateChange::None => {}
}
// * Debug logging of current subscriptions.
/*#[cfg(debug_assertions)]
info!(
"User {addr:?} is subscribed to {:?}",
state
.iter_subscriptions()
.collect::<Vec<&String>>()
);*/
// Handle incoming events.
match conn.on_message().next().await.map(|res| {
res.map(|item|(
item.get_channel_name().to_string(),
redis_kiss::decode_payload::<EventV1>(&item),
))
}) {
Some(Ok((channel, item))) => {
if let Ok(mut event) = item {
if state
.handle_incoming_event_v1(
db, &mut event,
)
.await
&& write.lock().await
.send(config.encode(&event))
.await
.is_err()
{
break;
}
} else {
warn!("Failed to deserialise an event for {channel}!");
}
}
Some(Err(e)) => {
info!("Error while consuming pub/sub messages: {e:?}");
sentry::capture_error(&e);
break
}
// No more data, assume we disconnected or otherwise
// something bad occurred, so disconnect user.
None => break,
}
}
}
}
.fuse();
// Read from WebSocket stream.
let worker =
async {
while let Ok(Some(msg)) = read.try_next().await {
if let Ok(payload) = config.decode(&msg) {
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();
}
}
_ => {}
}
}
}
}
.fuse();
// Pin both tasks.
pin_mut!(listener, worker);
// Wait for either disconnect or for listener to die.
select!(
() = listener => {},
() = worker => {}
);
// * Combine the streams back once we are ready to disconnect.
/* ws = read.reunite(write).unwrap(); */
}
// Clean up presence session.
let last_session = delete_session(&user_id, session_id).await;
// If this was the last session, notify other users that we just went offline.
if last_session {
state.broadcast_presence_change(false).await;
}
}
Err(err) => {
write.lock().await.send(config.encode(&err)).await.ok();
}
if let Err(err) = subscriber.subscribe(id).await {
error!("Subscribe failed: {err:?}");
sentry::capture_error(&err);
break 'out;
}
}
}
// * Disconnect the WebSocket if it isn't already.
/*ws.close(Some(CloseFrame {
code: CloseCode::Normal,
reason: std::borrow::Cow::from(""),
}))
.await
.unwrap();*/
SubscriptionStateChange::None => {}
}
info!("User disconnected from {addr:?}");
});
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();
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;
}
}
}
}
if let Err(err) = subscriber.quit().await {
error!("{}", err);
sentry::capture_error(&err);
}
}
#[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 t1 = read.try_next().fuse();
let t2 = kill_signal_r.recv().fuse();
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.6.7"
version = "0.7.9"
edition = "2021"
license = "AGPL-3.0-or-later"
license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"]
description = "Revolt Backend: Configuration"
@@ -14,6 +14,7 @@ default = ["test"]
[dependencies]
# Utility
dotenv = "0.15.0"
config = "0.13.3"
cached = "0.44.0"
once_cell = "1.18.0"
@@ -24,3 +25,10 @@ serde = { version = "1", features = ["derive"] }
# Async
futures-locks = "0.7.1"
async-std = { version = "1.8.0", features = ["attributes"], optional = true }
# Logging
log = "0.4.14"
pretty_env_logger = "0.4.0"
# Sentry
sentry = "0.31.5"

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

@@ -0,0 +1,3 @@
[database]
mongodb = "mongodb://localhost"
redis = "redis://localhost/"

View File

@@ -12,7 +12,6 @@ voso_legacy = ""
voso_legacy_ws = ""
[api]
staging = false
[api.registration]
invite_only = false
@@ -33,6 +32,7 @@ api_key = ""
[api.security]
authifier_shield_key = ""
voso_legacy_token = ""
trust_cloudflare = false
[api.security.captcha]
hcaptcha_key = ""
@@ -42,15 +42,20 @@ hcaptcha_sitekey = ""
max_concurrent_connections = 50
[features]
webhooks_enabled = false
[features.limits]
[features.limits.default]
outgoing_friend_requests = 10
group_size = 100
bots = 5
message_length = 2000
message_embeds = 5
message_replies = 5
message_attachments = 5
message_embeds = 5
message_reactions = 20
servers = 100
server_emoji = 100
server_roles = 200
@@ -62,3 +67,7 @@ background_size = 6000000
icon_size = 2500000
banner_size = 6000000
emoji_size = 500000
[sentry]
api = ""
events = ""

View File

@@ -6,6 +6,9 @@ use futures_locks::RwLock;
use once_cell::sync::Lazy;
use serde::Deserialize;
#[cfg(not(debug_assertions))]
use std::env;
static CONFIG_BUILDER: Lazy<RwLock<Config>> = Lazy::new(|| {
RwLock::new({
let mut builder = Config::builder().add_source(File::from_str(
@@ -13,8 +16,13 @@ static CONFIG_BUILDER: Lazy<RwLock<Config>> = Lazy::new(|| {
FileFormat::Toml,
));
if std::path::Path::new("revolt.toml").exists() {
builder = builder.add_source(File::new("revolt.toml", FileFormat::Toml));
if std::env::var("TEST_DB").is_ok() {
builder = builder.add_source(File::from_str(
include_str!("../Revolt.test.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()
@@ -51,6 +59,9 @@ pub struct ApiSmtp {
pub username: String,
pub password: String,
pub from_address: String,
pub reply_to: Option<String>,
pub port: Option<i32>,
pub use_tls: Option<bool>,
}
#[derive(Deserialize, Debug, Clone)]
@@ -75,6 +86,7 @@ pub struct ApiSecurity {
pub authifier_shield_key: String,
pub voso_legacy_token: String,
pub captcha: ApiSecurityCaptcha,
pub trust_cloudflare: bool,
}
#[derive(Deserialize, Debug, Clone)]
@@ -84,7 +96,6 @@ pub struct ApiWorkers {
#[derive(Deserialize, Debug, Clone)]
pub struct Api {
pub staging: bool,
pub registration: ApiRegistration,
pub smtp: ApiSmtp,
pub vapid: ApiVapid,
@@ -95,11 +106,15 @@ pub struct Api {
#[derive(Deserialize, Debug, Clone)]
pub struct FeaturesLimits {
pub outgoing_friend_requests: usize,
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_reactions: usize,
pub servers: usize,
pub server_emoji: usize,
pub server_roles: usize,
@@ -124,6 +139,13 @@ pub struct FeaturesLimitsCollection {
#[derive(Deserialize, Debug, Clone)]
pub struct Features {
pub limits: FeaturesLimitsCollection,
pub webhooks_enabled: bool,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Sentry {
pub api: String,
pub events: String,
}
#[derive(Deserialize, Debug, Clone)]
@@ -132,6 +154,31 @@ pub struct Settings {
pub hosts: Hosts,
pub api: Api,
pub features: Features,
pub sentry: Sentry,
}
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.");
}
}
}
pub async fn init() {
@@ -150,6 +197,46 @@ pub async fn config() -> Settings {
read().await.try_deserialize::<Settings>().unwrap()
}
/// Configure logging and common Rust variables
pub async fn setup_logging(release: &'static str, dsn: String) -> Option<sentry::ClientInitGuard> {
dotenv::dotenv().ok();
if std::env::var("RUST_LOG").is_err() {
std::env::set_var("RUST_LOG", "info");
}
if std::env::var("ROCKET_ADDRESS").is_err() {
std::env::set_var("ROCKET_ADDRESS", "0.0.0.0");
}
pretty_env_logger::init();
log::info!("Starting {release}");
if dsn.is_empty() {
None
} else {
Some(sentry::init((
dsn,
sentry::ClientOptions {
release: Some(release.into()),
..Default::default()
},
)))
}
}
#[macro_export]
macro_rules! configure {
($application: ident) => {
let config = $crate::config().await;
let _sentry = $crate::setup_logging(
concat!(env!("CARGO_PKG_NAME"), "@", env!("CARGO_PKG_VERSION")),
config.sentry.$application,
)
.await;
};
}
#[cfg(feature = "test")]
#[cfg(test)]
mod tests {

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-database"
version = "0.6.7"
version = "0.7.9"
edition = "2021"
license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"]
@@ -23,11 +23,13 @@ default = ["mongodb", "async-std-runtime", "tasks"]
[dependencies]
# Core
revolt-config = { version = "0.6.7", path = "../config" }
revolt-result = { version = "0.6.7", path = "../result" }
revolt-models = { version = "0.6.7", path = "../models" }
revolt-presence = { version = "0.6.7", path = "../presence" }
revolt-permissions = { version = "0.6.7", path = "../permissions", features = [
revolt-config = { version = "0.7.9", path = "../config" }
revolt-result = { version = "0.7.9", path = "../result" }
revolt-models = { version = "0.7.9", path = "../models", features = [
"validator",
] }
revolt-presence = { version = "0.7.9", path = "../presence" }
revolt-permissions = { version = "0.7.9", path = "../permissions", features = [
"serde",
"bson",
] }
@@ -45,6 +47,7 @@ decancer = "1.6.2"
deadqueue = "0.2.4"
linkify = { optional = true, version = "0.8.1" }
url-escape = { optional = true, version = "0.1.1" }
validator = { version = "0.16", features = ["derive"] }
isahc = { optional = true, version = "1.7", features = ["json"] }
# Serialisation
@@ -86,4 +89,4 @@ fcm = "0.9.2"
web-push = "0.10.0"
# Authifier
authifier = { version = "1.0" }
authifier = { version = "1.0.8" }

View File

@@ -0,0 +1,28 @@
[
{
"_object_type": "User",
"_id": "__ID:0__",
"username": "Owner",
"discriminator": "0001"
},
{
"_object_type": "User",
"_id": "__ID:1__",
"username": "Member",
"discriminator": "0001"
},
{
"_object_type": "User",
"_id": "__ID:2__",
"username": "Member",
"discriminator": "0002"
},
{
"_object_type": "Channel",
"_id": "__ID:3__",
"channel_type": "Group",
"name": "My Group",
"owner": "__ID:0__",
"recipients": ["__ID:0__", "__ID:1__"]
}
]

View File

@@ -0,0 +1,89 @@
[
{
"_object_type": "User",
"_id": "__ID:0__",
"username": "Owner",
"discriminator": "0001"
},
{
"_object_type": "User",
"_id": "__ID:1__",
"username": "Moderator",
"discriminator": "0001"
},
{
"_object_type": "User",
"_id": "__ID:2__",
"username": "User",
"discriminator": "0001"
},
{
"_object_type": "Channel",
"_id": "__ID:3__",
"channel_type": "TextChannel",
"name": "General",
"server": "__ID:4__",
"default_permissions": {
"a": 0,
"d": 1048576
},
"role_permissions": {
"__ID:5__": {
"a": 1048576,
"d": 0
}
}
},
{
"_object_type": "Server",
"_id": "__ID:4__",
"owner": "__ID:0__",
"name": "Server",
"channels": ["__ID:3__"],
"roles": {
"__ID:5__": {
"name": "Moderator",
"permissions": {
"a": 545270208,
"d": 0
},
"rank": 3
},
"__ID:6__": {
"name": "Owner",
"permissions": {
"a": 0,
"d": 0
},
"rank": 0
}
},
"default_permissions": 4000322560
},
{
"_object_type": "ServerMember",
"_id": {
"user": "__ID:0__",
"server": "__ID:4__"
},
"roles": ["__ID:6__"],
"joined_at": 1698318340195
},
{
"_object_type": "ServerMember",
"_id": {
"user": "__ID:1__",
"server": "__ID:4__"
},
"roles": ["__ID:5__"],
"joined_at": 1698318340195
},
{
"_object_type": "ServerMember",
"_id": {
"user": "__ID:2__",
"server": "__ID:4__"
},
"joined_at": 1698318340195
}
]

View File

@@ -1,6 +1,9 @@
mod mongodb;
mod reference;
use rand::Rng;
use revolt_config::config;
pub use self::mongodb::*;
pub use self::reference::*;
@@ -31,25 +34,44 @@ impl DatabaseInfo {
/// Create a database client from the given database information
#[async_recursion]
pub async fn connect(self) -> Result<Database, String> {
let config = config().await;
Ok(match self {
DatabaseInfo::Auto => {
if let Ok(uri) = std::env::var("MONGODB") {
return DatabaseInfo::MongoDb {
uri,
if std::env::var("TEST_DB").is_ok() {
DatabaseInfo::Test(format!(
"revolt_test_{}",
rand::thread_rng().gen_range(1_000_000..10_000_000)
))
.connect()
.await?
} else if !config.database.mongodb.is_empty() {
DatabaseInfo::MongoDb {
uri: config.database.mongodb,
database_name: "revolt".to_string(),
}
.connect()
.await;
.await?
} else {
DatabaseInfo::Reference.connect().await?
}
DatabaseInfo::Reference.connect().await?
}
DatabaseInfo::Test(database_name) => {
if let Ok(uri) = std::env::var("MONGODB") {
return DatabaseInfo::MongoDb { uri, database_name }.connect().await;
match std::env::var("TEST_DB")
.expect("`TEST_DB` environment variable should be set to REFERENCE or MONGODB")
.as_str()
{
"REFERENCE" => DatabaseInfo::Reference.connect().await?,
"MONGODB" => {
DatabaseInfo::MongoDb {
uri: config.database.mongodb,
database_name,
}
.connect()
.await?
}
_ => unreachable!("must specify REFERENCE or MONGODB"),
}
DatabaseInfo::Reference.connect().await?
}
DatabaseInfo::Reference => Database::Reference(Default::default()),
DatabaseInfo::MongoDb { uri, database_name } => {
@@ -65,14 +87,3 @@ impl DatabaseInfo {
})
}
}
impl From<Database> for authifier::Database {
fn from(value: Database) -> Self {
match value {
Database::Reference(_) => Default::default(),
Database::MongoDb(MongoDb(client, _)) => authifier::Database::MongoDb(
authifier::database::MongoDb(client.database("revolt")),
),
}
}
}

View File

@@ -4,7 +4,8 @@ use futures::lock::Mutex;
use crate::{
Bot, Channel, ChannelCompositeKey, ChannelUnread, Emoji, File, Invite, Member,
MemberCompositeKey, Message, Server, ServerBan, User, UserSettings, Webhook,
MemberCompositeKey, Message, RatelimitEvent, Report, Server, ServerBan, Snapshot, User,
UserSettings, Webhook,
};
database_derived!(
@@ -19,12 +20,13 @@ database_derived!(
pub emojis: Arc<Mutex<HashMap<String, Emoji>>>,
pub files: Arc<Mutex<HashMap<String, File>>>,
pub messages: Arc<Mutex<HashMap<String, Message>>>,
pub ratelimit_events: Arc<Mutex<HashMap<String, RatelimitEvent>>>,
pub user_settings: Arc<Mutex<HashMap<String, UserSettings>>>,
pub users: Arc<Mutex<HashMap<String, User>>>,
pub server_bans: Arc<Mutex<HashMap<MemberCompositeKey, ServerBan>>>,
pub server_members: Arc<Mutex<HashMap<MemberCompositeKey, Member>>>,
pub servers: Arc<Mutex<HashMap<String, Server>>>,
pub safety_reports: Arc<Mutex<HashMap<String, ()>>>,
pub safety_snapshots: Arc<Mutex<HashMap<String, ()>>>,
pub safety_reports: Arc<Mutex<HashMap<String, Report>>>,
pub safety_snapshots: Arc<Mutex<HashMap<String, Snapshot>>>,
}
);

View File

@@ -3,9 +3,9 @@ use serde::{Deserialize, Serialize};
use revolt_models::v0::{
AppendMessage, Channel, Emoji, FieldsChannel, FieldsMember, FieldsRole, FieldsServer,
FieldsUser, FieldsWebhook, MemberCompositeKey, Message, PartialChannel, PartialMember,
PartialMessage, PartialRole, PartialServer, PartialUser, PartialWebhook, Server, UserSettings,
Webhook,
FieldsUser, FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialMember,
PartialMessage, PartialRole, PartialServer, PartialUser, PartialWebhook, Report, Server, User,
UserSettings, Webhook,
};
use revolt_result::Error;
@@ -48,17 +48,19 @@ pub enum EventV1 {
/// Successfully authenticated
Authenticated,
/* /// Basic data to cache
/// Logged out
Logout,
/// Basic data to cache
Ready {
users: Vec<User>,
servers: Vec<Server>,
channels: Vec<Channel>,
members: Vec<Member>,
emojis: Option<Vec<Emoji>>,
emojis: Vec<Emoji>,
},
/// Ping response
Pong { data: Ping }, */
Pong { data: Ping },
/// New message
Message(Message),
@@ -110,6 +112,7 @@ pub enum EventV1 {
id: String,
server: Server,
channels: Vec<Channel>,
emojis: Vec<Emoji>,
},
/// Update existing server
@@ -154,17 +157,12 @@ pub enum EventV1 {
event_id: Option<String>,
},
/*/// Relationship with another user changed
UserRelationship {
id: String,
user: User,
// ! this field can be deprecated
status: RelationshipStatus,
},*/
/// Relationship with another user changed
UserRelationship { id: String, user: User },
/// Settings updated remotely
UserSettingsUpdate { id: String, update: UserSettings },
/*/// User has been platform banned or deleted their account
/// User has been platform banned or deleted their account
///
/// Clients should remove the following associated data:
/// - Messages
@@ -173,15 +171,15 @@ pub enum EventV1 {
/// - Server Memberships
///
/// User flags are specified to explain why a wipe is occurring though not all reasons will necessarily ever appear.
UserPlatformWipe { user_id: String, flags: i32 }, */
UserPlatformWipe { user_id: String, flags: i32 },
/// New emoji
EmojiCreate(Emoji),
/// Delete emoji
EmojiDelete { id: String },
/*/// New report
ReportCreate(Report), */
/// New report
ReportCreate(Report),
/// New channel
ChannelCreate(Channel),
@@ -248,11 +246,10 @@ impl EventV1 {
pub async fn p_user(self, id: String, db: &Database) {
self.clone().p(id.clone()).await;
// ! FIXME: this should be captured by member list in the future
// ! and not immediately fanned out to users
// 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;
}
}
}
@@ -262,6 +259,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

@@ -1 +1,2 @@
pub mod client;
pub mod server;

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

@@ -18,7 +18,7 @@ mod tests {
db.migrate_database().await.unwrap();
// Migrate the existing database
db.migrate_database().await.unwrap()
db.migrate_database().await.unwrap();
});
}
}

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 = 27;
pub async fn migrate_database(db: &MongoDb) {
let migrations = db.col::<Document>("migrations");
@@ -983,6 +984,93 @@ 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");
}
}
// 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

@@ -1,7 +1,8 @@
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
@@ -79,9 +80,8 @@ impl Bot {
return Err(create_error!(IsBot));
}
// TODO: config
let max_bot_count = 5;
if db.get_number_of_bots_by_user(&owner.id).await? >= max_bot_count {
let config = config().await;
if db.get_number_of_bots_by_user(&owner.id).await? >= config.features.limits.default.bots {
return Err(create_error!(ReachedMaximumBots));
}
@@ -93,7 +93,7 @@ impl Bot {
Some(id.to_string()),
Some(PartialUser {
bot: Some(BotInformation {
owner: id.to_string(),
owner: owner.id.to_string(),
}),
..Default::default()
}),
@@ -142,6 +142,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(())
}

View File

@@ -1,15 +1,16 @@
use revolt_result::{create_error, Result};
use crate::Database;
use crate::{Channel, Database, User};
/* static ALPHABET: [char; 54] = [
static ALPHABET: [char; 54] = [
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J',
'K', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f',
'g', 'h', 'j', 'k', 'm', 'n', 'p', 'q', 'r', 's', 't', 'v', 'w', 'x', 'y', 'z',
]; */
];
auto_derived!(
/// Invite
#[serde(tag = "type")]
pub enum Invite {
/// Invite to a specific server channel
Server {
@@ -56,9 +57,13 @@ impl Invite {
}
/// Create a new invite from given information
/*pub async fn create_channel_invite(db: &Database, creator_id: String, target: &Channel) -> Result<Invite> {
pub async fn create_channel_invite(
db: &Database,
creator: &User,
channel: &Channel,
) -> Result<Invite> {
let code = nanoid::nanoid!(8, &ALPHABET);
let invite = match &target {
let invite = match &channel {
Channel::Group { id, .. } => Ok(Invite::Group {
code,
creator: creator.id.clone(),
@@ -72,12 +77,12 @@ impl Invite {
channel: id.clone(),
})
}
_ => Err(Error::InvalidOperation),
_ => Err(create_error!(InvalidOperation)),
}?;
db.insert_invite(&invite).await?;
Ok(invite)
}*/
}
/// Resolve an invite by its ID or by a public server ID
pub async fn find(db: &Database, code: &str) -> Result<Invite> {

View File

@@ -1,11 +1,16 @@
use std::collections::HashMap;
use revolt_models::v0::MessageAuthor;
use revolt_config::config;
use revolt_models::v0::{self, MessageAuthor};
use revolt_permissions::OverrideField;
use revolt_result::Result;
use serde::{Deserialize, Serialize};
use ulid::Ulid;
use crate::{events::client::EventV1, Database, File, IntoDocumentPath, SystemMessage, User};
use crate::{
events::client::EventV1, tasks::ack::AckEvent, Database, File, IntoDocumentPath, PartialServer,
Server, SystemMessage, User,
};
auto_derived!(
#[serde(tag = "channel_type")]
@@ -168,7 +173,7 @@ auto_derived!(
#[allow(clippy::disallowed_methods)]
impl Channel {
/// Create a channel
/* /// Create a channel
pub async fn create(&self, db: &Database) -> Result<()> {
db.insert_channel(self).await?;
@@ -186,6 +191,146 @@ impl Channel {
}
Ok(())
}*/
/// Create a new server channel
pub async fn create_server_channel(
db: &Database,
server: &mut Server,
data: v0::DataCreateServerChannel,
update_server: bool,
) -> Result<Channel> {
let config = config().await;
if server.channels.len() > config.features.limits.default.server_channels {
return Err(create_error!(TooManyChannels {
max: config.features.limits.default.server_channels,
}));
};
let id = ulid::Ulid::new().to_string();
let channel = match data.channel_type {
v0::LegacyServerChannelType::Text => Channel::TextChannel {
id: id.clone(),
server: server.id.to_owned(),
name: data.name,
description: data.description,
icon: None,
last_message_id: None,
default_permissions: None,
role_permissions: HashMap::new(),
nsfw: data.nsfw.unwrap_or(false),
},
v0::LegacyServerChannelType::Voice => Channel::VoiceChannel {
id: id.clone(),
server: server.id.to_owned(),
name: data.name,
description: data.description,
icon: None,
default_permissions: None,
role_permissions: HashMap::new(),
nsfw: data.nsfw.unwrap_or(false),
},
};
db.insert_channel(&channel).await?;
if update_server {
server
.update(
db,
PartialServer {
channels: Some([server.channels.clone(), [id].into()].concat()),
..Default::default()
},
vec![],
)
.await?;
EventV1::ChannelCreate(channel.clone().into())
.p(server.id.clone())
.await;
}
Ok(channel)
}
/// Create a group
pub async fn create_group(
db: &Database,
mut data: v0::DataCreateGroup,
owner_id: String,
) -> Result<Channel> {
data.users.insert(owner_id.to_string());
let config = config().await;
if data.users.len() > config.features.limits.default.group_size {
return Err(create_error!(GroupTooLarge {
max: config.features.limits.default.group_size,
}));
}
let recipients = data.users.into_iter().collect::<Vec<String>>();
let channel = Channel::Group {
id: ulid::Ulid::new().to_string(),
name: data.name,
owner: owner_id,
description: data.description,
recipients: recipients.clone(),
icon: None,
last_message_id: None,
permissions: None,
nsfw: data.nsfw.unwrap_or(false),
};
db.insert_channel(&channel).await?;
let event = EventV1::ChannelCreate(channel.clone().into());
for recipient in recipients {
event.clone().private(recipient).await;
}
Ok(channel)
}
/// Create a DM (or return the existing one / saved messages)
pub async fn create_dm(db: &Database, user_a: &User, user_b: &User) -> Result<Channel> {
// Try to find existing channel
if let Ok(channel) = db.find_direct_message_channel(&user_a.id, &user_b.id).await {
Ok(channel)
} else {
let channel = if user_a.id == user_b.id {
// Create a new saved messages channel
Channel::SavedMessages {
id: Ulid::new().to_string(),
user: user_a.id.to_string(),
}
} else {
// Create a new DM channel
Channel::DirectMessage {
id: Ulid::new().to_string(),
active: true, // show by default
recipients: vec![user_a.id.clone(), user_b.id.clone()],
last_message_id: None,
}
};
db.insert_channel(&channel).await?;
match &channel {
Channel::DirectMessage { .. } => {
let event = EventV1::ChannelCreate(channel.clone().into());
event.clone().private(user_a.id.clone()).await;
event.private(user_b.id.clone()).await;
}
_ => {}
};
Ok(channel)
}
}
/// Add user to a group
@@ -200,6 +345,13 @@ impl Channel {
return Err(create_error!(AlreadyInGroup));
}
let config = config().await;
if recipients.len() >= config.features.limits.default.group_size {
return Err(create_error!(GroupTooLarge {
max: config.features.limits.default.group_size
}));
}
recipients.push(String::from(&user.id));
}
@@ -214,10 +366,6 @@ impl Channel {
.p(id.to_string())
.await;
EventV1::ChannelCreate(self.clone().into())
.private(user.id.to_string())
.await;
SystemMessage::UserAdded {
id: user.id.to_string(),
by: by_id.to_string(),
@@ -235,6 +383,10 @@ impl Channel {
.await
.ok();
EventV1::ChannelCreate(self.clone().into())
.private(user.id.to_string())
.await;
Ok(())
}
_ => Err(create_error!(InvalidOperation)),
@@ -262,7 +414,7 @@ impl Channel {
}
}
/// Get a reference to this channel's id
/// Clone this channel's id
pub fn id(&self) -> String {
match self {
Channel::DirectMessage { id, .. }
@@ -475,6 +627,28 @@ impl Channel {
}
}
/// Acknowledge a message
pub async fn ack(&self, user: &str, message: &str) -> Result<()> {
EventV1::ChannelAck {
id: self.id().to_string(),
user: user.to_string(),
message_id: message.to_string(),
}
.private(user.to_string())
.await;
crate::tasks::ack::queue(
self.id().to_string(),
user.to_string(),
AckEvent::AckMessage {
id: message.to_string(),
},
)
.await;
Ok(())
}
/// Remove user from a group
pub async fn remove_user_from_group(
&self,
@@ -520,8 +694,7 @@ impl Channel {
.await
.ok();
} else {
db.delete_channel(self).await?;
return Ok(());
return self.delete(db).await;
}
}
@@ -568,6 +741,9 @@ impl Channel {
pub async fn delete(&self, db: &Database) -> Result<()> {
let id = self.id().to_string();
EventV1::ChannelDelete { id: id.clone() }.p(id).await;
// TODO: missing functionality:
// - group invites
// - channels list / categories list on server
db.delete_channel(self).await
}
}
@@ -581,3 +757,62 @@ impl IntoDocumentPath for FieldsChannel {
})
}
}
#[cfg(test)]
mod tests {
use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
use crate::{fixture, util::permissions::DatabasePermissionQuery};
#[async_std::test]
async fn permissions_group_channel() {
database_test!(|db| async move {
fixture!(db, "group_with_members",
owner user 0
member1 user 1
member2 user 2
channel channel 3);
let mut query = DatabasePermissionQuery::new(&db, &owner).channel(&channel);
assert!(calculate_channel_permissions(&mut query)
.await
.has_channel_permission(ChannelPermission::SendMessage));
let mut query = DatabasePermissionQuery::new(&db, &member1).channel(&channel);
assert!(calculate_channel_permissions(&mut query)
.await
.has_channel_permission(ChannelPermission::SendMessage));
let mut query = DatabasePermissionQuery::new(&db, &member2).channel(&channel);
assert!(!calculate_channel_permissions(&mut query)
.await
.has_channel_permission(ChannelPermission::SendMessage));
});
}
#[async_std::test]
async fn permissions_text_channel() {
database_test!(|db| async move {
fixture!(db, "server_with_roles",
owner user 0
moderator user 1
user user 2
channel channel 3);
let mut query = DatabasePermissionQuery::new(&db, &owner).channel(&channel);
assert!(calculate_channel_permissions(&mut query)
.await
.has_channel_permission(ChannelPermission::SendMessage));
let mut query = DatabasePermissionQuery::new(&db, &moderator).channel(&channel);
assert!(calculate_channel_permissions(&mut query)
.await
.has_channel_permission(ChannelPermission::SendMessage));
let mut query = DatabasePermissionQuery::new(&db, &user).channel(&channel);
assert!(!calculate_channel_permissions(&mut query)
.await
.has_channel_permission(ChannelPermission::SendMessage));
});
}
}

View File

@@ -1,6 +1,6 @@
use super::AbstractChannels;
use crate::{Channel, FieldsChannel, IntoDocumentPath, MongoDb, PartialChannel};
use bson::Document;
use crate::{AbstractServers, Channel, FieldsChannel, IntoDocumentPath, MongoDb, PartialChannel};
use bson::{Bson, Document};
use futures::StreamExt;
use revolt_permissions::OverrideField;
use revolt_result::Result;
@@ -188,6 +188,125 @@ impl AbstractChannels for MongoDb {
// Delete a channel
async fn delete_channel(&self, channel: &Channel) -> Result<()> {
let id = channel.id().to_string();
let server_id = match channel {
Channel::TextChannel { server, .. } | Channel::VoiceChannel { server, .. } => {
Some(server)
}
_ => None,
};
// Delete invites and unreads.
self.delete_associated_channel_objects(Bson::String(id.to_string()))
.await?;
// Delete messages.
self.delete_bulk_messages(doc! {
"channel": &id
})
.await?;
// Remove from server object.
if let Some(server) = server_id {
let server = self.fetch_server(server).await?;
let mut update = doc! {
"$pull": {
"channels": &id
}
};
if let Some(sys) = &server.system_messages {
let mut unset = doc! {};
if let Some(cid) = &sys.user_joined {
if &id == cid {
unset.insert("system_messages.user_joined", 1_i32);
}
}
if let Some(cid) = &sys.user_left {
if &id == cid {
unset.insert("system_messages.user_left", 1_i32);
}
}
if let Some(cid) = &sys.user_kicked {
if &id == cid {
unset.insert("system_messages.user_kicked", 1_i32);
}
}
if let Some(cid) = &sys.user_banned {
if &id == cid {
unset.insert("system_messages.user_banned", 1_i32);
}
}
if !unset.is_empty() {
update.insert("$unset", unset);
}
}
self.col::<Document>("servers")
.update_one(
doc! {
"_id": server.id
},
update,
None,
)
.await
.map_err(|_| create_database_error!("update_one", "servers"))?;
}
// Delete associated attachments
self.delete_many_attachments(doc! {
"object_id": &id
})
.await?;
// Delete the channel itself
query!(self, delete_one_by_id, COL, &channel.id()).map(|_| ())
}
}
impl MongoDb {
pub async fn delete_associated_channel_objects(&self, id: Bson) -> Result<()> {
// Delete all invites to these channels.
self.col::<Document>("channel_invites")
.delete_many(
doc! {
"channel": &id
},
None,
)
.await
.map_err(|_| create_database_error!("delete_many", "channel_invites"))?;
// Delete unread message objects on channels.
self.col::<Document>("channel_unreads")
.delete_many(
doc! {
"_id.channel": &id
},
None,
)
.await
.map_err(|_| create_database_error!("delete_many", "channel_unreads"))
.map(|_| ())?;
// update many attachments with parent id
// Delete all webhooks on this channel.
self.col::<Document>("webhooks")
.delete_many(
doc! {
"channel": &id
},
None,
)
.await
.map_err(|_| create_database_error!("delete_many", "webhooks"))
.map(|_| ())
}
}

View File

@@ -24,13 +24,12 @@ impl AbstractEmojis for MongoDb {
async fn fetch_emoji_by_parent_id(&self, parent_id: &str) -> Result<Vec<Emoji>> {
query!(
self,
find_one,
find,
COL,
doc! {
"parent.id": parent_id
}
)?
.ok_or_else(|| create_error!(NotFound))
)
}
/// Fetch emoji by their parent ids

View File

@@ -6,7 +6,7 @@ use crate::MongoDb;
use super::AbstractAttachments;
static COL: &str = "bots";
static COL: &str = "attachments";
#[async_trait]
impl AbstractAttachments for MongoDb {
@@ -115,3 +115,21 @@ impl AbstractAttachments for MongoDb {
.map_err(|_| create_database_error!("update_one", COL))
}
}
impl MongoDb {
pub async fn delete_many_attachments(&self, projection: Document) -> Result<()> {
self.col::<Document>(COL)
.update_many(
projection,
doc! {
"$set": {
"deleted": true
}
},
None,
)
.await
.map(|_| ())
.map_err(|_| create_database_error!("update_many", COL))
}
}

View File

@@ -22,15 +22,25 @@ impl AbstractAttachments for ReferenceDb {
async fn find_and_use_attachment(
&self,
id: &str,
_tag: &str,
_parent_type: &str,
_parent_id: &str,
tag: &str,
parent_type: &str,
parent_id: &str,
) -> Result<File> {
let mut files = self.files.lock().await;
if let Some(file) = files.get_mut(id) {
// TODO: check tag
// TODO: set parent ID
Ok(file.clone())
if file.tag == tag {
match parent_type {
"message" => file.message_id = Some(parent_id.to_owned()),
"user" => file.user_id = Some(parent_id.to_owned()),
"object" => file.object_id = Some(parent_id.to_owned()),
"server" => file.server_id = Some(parent_id.to_owned()),
_ => unreachable!(),
}
Ok(file.clone())
} else {
Err(create_error!(NotFound))
}
} else {
Err(create_error!(NotFound))
}

View File

@@ -1,13 +1,22 @@
use std::collections::HashSet;
use indexmap::{IndexMap, IndexSet};
use iso8601_timestamp::Timestamp;
use revolt_models::v0::{Embed, MessageAuthor, MessageSort, MessageWebhook, PushNotification};
use revolt_config::config;
use revolt_models::v0::{
self, BulkMessageResponse, DataMessageSend, Embed, MessageAuthor, MessageSort, MessageWebhook,
PushNotification, ReplyIntent, SendableEmbed, Text, RE_MENTION,
};
use revolt_permissions::{ChannelPermission, PermissionValue};
use revolt_result::Result;
use ulid::Ulid;
use validator::Validate;
use crate::{
events::client::EventV1,
tasks::{self, ack::AckEvent},
Channel, Database, File,
util::idempotency::IdempotencyKey,
Channel, Database, Emoji, File, User,
};
auto_derived_partial!(
@@ -147,6 +156,7 @@ auto_derived!(
}
/// Message Filter
#[derive(Default)]
pub struct MessageFilter {
/// Parent channel ID
pub channel: Option<String>,
@@ -196,6 +206,165 @@ impl Default for Message {
#[allow(clippy::disallowed_methods)]
impl Message {
/// Create message from API data
pub async fn create_from_api(
db: &Database,
channel: Channel,
data: DataMessageSend,
author: MessageAuthor<'_>,
mut idempotency: IdempotencyKey,
generate_embeds: bool,
allow_mentions: bool,
) -> Result<Message> {
let config = config().await;
Message::validate_sum(
&data.content,
data.embeds.as_deref().unwrap_or_default(),
config.features.limits.default.message_length,
)?;
idempotency
.consume_nonce(data.nonce)
.await
.map_err(|_| create_error!(InvalidOperation))?;
// Check the message is not empty
if (data.content.as_ref().map_or(true, |v| v.is_empty()))
&& (data.attachments.as_ref().map_or(true, |v| v.is_empty()))
&& (data.embeds.as_ref().map_or(true, |v| v.is_empty()))
{
return Err(create_error!(EmptyMessage));
}
// Ensure restrict_reactions is not specified without reactions list
if let Some(interactions) = &data.interactions {
if interactions.restrict_reactions {
let disallowed = if let Some(list) = &interactions.reactions {
list.is_empty()
} else {
true
};
if disallowed {
return Err(create_error!(InvalidProperty));
}
}
}
let (author_id, webhook) = match &author {
MessageAuthor::User(user) => (user.id.clone(), None),
MessageAuthor::Webhook(webhook) => (webhook.id.clone(), Some((*webhook).clone())),
MessageAuthor::System { .. } => ("00000000000000000000000000".to_string(), None),
};
// Start constructing the message
let message_id = Ulid::new().to_string();
let mut message = Message {
id: message_id.clone(),
channel: channel.id(),
masquerade: data.masquerade.map(|masquerade| masquerade.into()),
interactions: data
.interactions
.map(|interactions| interactions.into())
.unwrap_or_default(),
author: author_id,
webhook: webhook.map(|w| w.into()),
..Default::default()
};
// Parse mentions in message.
let mut mentions = HashSet::new();
if allow_mentions {
if let Some(content) = &data.content {
for capture in RE_MENTION.captures_iter(content) {
if let Some(mention) = capture.get(1) {
mentions.insert(mention.as_str().to_string());
}
}
}
}
// Verify replies are valid.
let mut replies = HashSet::new();
if let Some(entries) = data.replies {
if entries.len() > config.features.limits.default.message_replies {
return Err(create_error!(TooManyReplies {
max: config.features.limits.default.message_replies,
}));
}
for ReplyIntent { id, mention } in entries {
let message = db.fetch_message(&id).await?;
if mention && allow_mentions {
mentions.insert(message.author.to_owned());
}
replies.insert(message.id);
}
}
if !mentions.is_empty() {
message.mentions.replace(mentions.into_iter().collect());
}
if !replies.is_empty() {
message
.replies
.replace(replies.into_iter().collect::<Vec<String>>());
}
// Add attachments to message.
let mut attachments = vec![];
if data
.attachments
.as_ref()
.is_some_and(|v| v.len() > config.features.limits.default.message_attachments)
{
return Err(create_error!(TooManyAttachments {
max: config.features.limits.default.message_attachments,
}));
}
if data
.embeds
.as_ref()
.is_some_and(|v| v.len() > config.features.limits.default.message_embeds)
{
return Err(create_error!(TooManyEmbeds {
max: config.features.limits.default.message_embeds,
}));
}
for attachment_id in data.attachments.as_deref().unwrap_or_default() {
attachments.push(
db.find_and_use_attachment(attachment_id, "attachments", "message", &message_id)
.await?,
);
}
if !attachments.is_empty() {
message.attachments.replace(attachments);
}
// Process included embeds.
for sendable_embed in data.embeds.unwrap_or_default() {
message.attach_sendable_embed(db, sendable_embed).await?;
}
// Set content
message.content = data.content;
// Pass-through nonce value for clients
message.nonce = Some(idempotency.into_key());
// Send the message
message.send(db, author, &channel, generate_embeds).await?;
Ok(message)
}
/// Send a message without any notifications
pub async fn send_without_notifications(
&mut self,
@@ -274,6 +443,121 @@ impl Message {
Ok(())
}
/// Create text embed from sendable embed
pub async fn create_embed(&self, db: &Database, embed: SendableEmbed) -> Result<Embed> {
embed.validate().map_err(|error| {
create_error!(FailedValidation {
error: error.to_string()
})
})?;
let media = if let Some(id) = embed.media {
Some(
db.find_and_use_attachment(&id, "attachments", "message", &self.id)
.await?,
)
} else {
None
};
Ok(Embed::Text(Text {
icon_url: embed.icon_url,
url: embed.url,
title: embed.title,
description: embed.description,
media: media.map(|m| m.into()),
colour: embed.colour,
}))
}
/// Update message data
pub async fn update(&mut self, db: &Database, partial: PartialMessage) -> Result<()> {
self.apply_options(partial.clone());
db.update_message(&self.id, &partial).await?;
EventV1::MessageUpdate {
id: self.id.clone(),
channel: self.channel.clone(),
data: partial.into(),
}
.p(self.channel.clone())
.await;
Ok(())
}
/// Helper function to fetch many messages with users
pub async fn fetch_with_users(
db: &Database,
query: MessageQuery,
perspective: &User,
include_users: Option<bool>,
server_id: Option<String>,
) -> Result<BulkMessageResponse> {
let messages: Vec<v0::Message> = db
.fetch_messages(query)
.await?
.into_iter()
.map(Into::into)
.collect();
if let Some(true) = include_users {
let user_ids = messages
.iter()
.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 { .. } => {}
}
}
users
})
.collect::<HashSet<String>>()
.into_iter()
.collect::<Vec<String>>();
let users = User::fetch_many_ids_as_mutuals(db, perspective, &user_ids).await?;
Ok(BulkMessageResponse::MessagesAndUsers {
messages,
users,
members: if let Some(server_id) = server_id {
Some(
db.fetch_members(&server_id, &user_ids)
.await?
.into_iter()
.map(Into::into)
.collect(),
)
} else {
None
},
})
} else {
Ok(BulkMessageResponse::JustMessages(messages))
}
}
/// Append content to message
pub async fn append(
db: &Database,
@@ -293,6 +577,187 @@ impl Message {
Ok(())
}
/// Convert sendable embed to text embed and attach to message
pub async fn attach_sendable_embed(
&mut self,
db: &Database,
embed: v0::SendableEmbed,
) -> Result<()> {
let media: Option<v0::File> = if let Some(id) = embed.media {
Some(
db.find_and_use_attachment(&id, "attachments", "message", &self.id)
.await?
.into(),
)
} else {
None
};
let embed = v0::Embed::Text(v0::Text {
icon_url: embed.icon_url,
url: embed.url,
title: embed.title,
description: embed.description,
media,
colour: embed.colour,
});
if let Some(embeds) = &mut self.embeds {
embeds.push(embed);
} else {
self.embeds = Some(vec![embed]);
}
Ok(())
}
/// Add a reaction to a 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
&& !self.reactions.contains_key(emoji)
{
return Err(create_error!(InvalidOperation));
}
// Check if the emoji is whitelisted
if !self.interactions.can_use(emoji) {
return Err(create_error!(InvalidOperation));
}
// Check if the emoji is usable by us
if !Emoji::can_use(db, emoji).await? {
return Err(create_error!(InvalidOperation));
}
// Send reaction event
EventV1::MessageReact {
id: self.id.to_string(),
channel_id: self.channel.to_string(),
user_id: user.id.to_string(),
emoji_id: emoji.to_string(),
}
.p(self.channel.to_string())
.await;
// Add emoji
db.add_reaction(&self.id, emoji, &user.id).await
}
/// Validate the sum of content of a message is under threshold
pub fn validate_sum(
content: &Option<String>,
embeds: &[SendableEmbed],
max_length: usize,
) -> Result<()> {
let mut running_total = 0;
if let Some(content) = content {
running_total += content.len();
}
for embed in embeds {
if let Some(desc) = &embed.description {
running_total += desc.len();
}
}
if running_total <= max_length {
Ok(())
} else {
Err(create_error!(PayloadTooLarge))
}
}
/// Delete a message
pub async fn delete(self, db: &Database) -> Result<()> {
let file_ids: Vec<String> = self
.attachments
.map(|files| files.iter().map(|file| file.id.to_string()).collect())
.unwrap_or_default();
if !file_ids.is_empty() {
db.mark_attachments_as_deleted(&file_ids).await?;
}
db.delete_message(&self.id).await?;
EventV1::MessageDelete {
id: self.id,
channel: self.channel.clone(),
}
.p(self.channel)
.await;
Ok(())
}
/// Bulk delete messages
pub async fn bulk_delete(db: &Database, channel: &str, ids: Vec<String>) -> Result<()> {
let valid_ids = db
.fetch_messages_by_id(&ids)
.await?
.into_iter()
.filter(|msg| msg.channel == channel)
.map(|msg| msg.id)
.collect::<Vec<String>>();
db.delete_messages(channel, &valid_ids).await?;
EventV1::BulkMessageDelete {
channel: channel.to_string(),
ids: valid_ids,
}
.p(channel.to_string())
.await;
Ok(())
}
/// Remove a reaction from a message
pub async fn remove_reaction(&self, db: &Database, user: &str, emoji: &str) -> Result<()> {
// Check if it actually exists
let empty = if let Some(users) = self.reactions.get(emoji) {
if !users.contains(user) {
return Err(create_error!(NotFound));
}
users.len() == 1
} else {
return Err(create_error!(NotFound));
};
// Send reaction event
EventV1::MessageUnreact {
id: self.id.to_string(),
channel_id: self.channel.to_string(),
user_id: user.to_string(),
emoji_id: emoji.to_string(),
}
.p(self.channel.to_string())
.await;
if empty {
// If empty, remove the reaction entirely
db.clear_reaction(&self.id, emoji).await
} else {
// Otherwise only remove that one reaction
db.remove_reaction(&self.id, emoji, user).await
}
}
/// Remove a reaction from a message
pub async fn clear_reaction(&self, db: &Database, emoji: &str) -> Result<()> {
// Send reaction event
EventV1::MessageRemoveReaction {
id: self.id.to_string(),
channel_id: self.channel.to_string(),
emoji_id: emoji.to_string(),
}
.p(self.channel.to_string())
.await;
// Write to database
db.clear_reaction(&self.id, emoji).await
}
}
impl SystemMessage {
@@ -309,6 +774,27 @@ impl SystemMessage {
}
impl Interactions {
/// Validate interactions info is correct
pub async fn validate(&self, db: &Database, permissions: &PermissionValue) -> Result<()> {
let config = config().await;
if let Some(reactions) = &self.reactions {
permissions.throw_if_lacking_channel_permission(ChannelPermission::React)?;
if reactions.len() > config.features.limits.default.message_reactions {
return Err(create_error!(InvalidOperation));
}
for reaction in reactions {
if !Emoji::can_use(db, reaction).await? {
return Err(create_error!(InvalidOperation));
}
}
}
Ok(())
}
/// Check if we can use a given emoji to react
pub fn can_use(&self, emoji: &str) -> bool {
if self.restrict_reactions {

View File

@@ -16,6 +16,9 @@ pub trait AbstractMessages: Sync + Send {
/// Fetch multiple messages by given query
async fn fetch_messages(&self, query: MessageQuery) -> Result<Vec<Message>>;
/// Fetch multiple messages by given IDs
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<()>;

View File

@@ -4,7 +4,9 @@ use mongodb::options::FindOptions;
use revolt_models::v0::MessageSort;
use revolt_result::Result;
use crate::{AppendMessage, Message, MessageQuery, MessageTimePeriod, MongoDb, PartialMessage};
use crate::{
AppendMessage, DocumentId, Message, MessageQuery, MessageTimePeriod, MongoDb, PartialMessage,
};
use super::AbstractMessages;
@@ -159,6 +161,21 @@ impl AbstractMessages for MongoDb {
}
}
/// Fetch multiple messages by given IDs
async fn fetch_messages_by_id(&self, ids: &[String]) -> Result<Vec<Message>> {
self.find_with_options(
COL,
doc! {
"ids": {
"$in": ids
}
},
None,
)
.await
.map_err(|_| create_database_error!("find", COL))
}
/// 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(|_| ())
@@ -278,3 +295,57 @@ impl AbstractMessages for MongoDb {
.map_err(|_| create_database_error!("delete_many", COL))
}
}
impl MongoDb {
pub async fn delete_bulk_messages(&self, projection: Document) -> Result<()> {
let mut for_attachments = projection.clone();
for_attachments.insert(
"attachments",
doc! {
"$exists": 1_i32
},
);
// Check if there are any attachments we need to delete.
let message_ids_with_attachments = self
.find_with_options::<_, DocumentId>(
COL,
for_attachments,
FindOptions::builder()
.projection(doc! { "_id": 1_i32 })
.build(),
)
.await
.map_err(|_| create_database_error!("find_many", "attachments"))?
.into_iter()
.map(|x| x.id)
.collect::<Vec<String>>();
// If we found any, mark them as deleted.
if !message_ids_with_attachments.is_empty() {
self.col::<Document>("attachments")
.update_many(
doc! {
"message_id": {
"$in": message_ids_with_attachments
}
},
doc! {
"$set": {
"deleted": true
}
},
None,
)
.await
.map_err(|_| create_database_error!("update_many", "attachments"))?;
}
// And then delete said messages.
self.col::<Document>(COL)
.delete_many(projection, None)
.await
.map(|_| ())
.map_err(|_| create_database_error!("delete_many", COL))
}
}

View File

@@ -1,3 +1,4 @@
use futures::future::try_join_all;
use indexmap::IndexSet;
use revolt_result::Result;
@@ -60,7 +61,7 @@ impl AbstractMessages for ReferenceDb {
.cloned()
.collect();
// TODO: sorting, etc
// FIXME: sorting, etc (will be required for tests)
Ok(matched_messages)
@@ -176,6 +177,11 @@ impl AbstractMessages for ReferenceDb {
}*/
}
/// Fetch multiple messages by given IDs
async fn fetch_messages_by_id(&self, ids: &[String]) -> Result<Vec<Message>> {
try_join_all(ids.iter().map(|id| self.fetch_message(id))).await
}
/// Update a given message with new information
async fn update_message(&self, id: &str, message: &PartialMessage) -> Result<()> {
let mut messages = self.messages.lock().await;

View File

@@ -8,6 +8,8 @@ mod emojis;
mod files;
mod messages;
mod ratelimit_events;
mod safety_reports;
mod safety_snapshots;
mod server_bans;
mod server_members;
mod servers;
@@ -24,6 +26,8 @@ pub use emojis::*;
pub use files::*;
pub use messages::*;
pub use ratelimit_events::*;
pub use safety_reports::*;
pub use safety_snapshots::*;
pub use server_bans::*;
pub use server_members::*;
pub use servers::*;
@@ -45,6 +49,8 @@ pub trait AbstractDatabase:
+ files::AbstractAttachments
+ messages::AbstractMessages
+ ratelimit_events::AbstractRatelimitEvents
+ safety_reports::AbstractReport
+ safety_snapshots::AbstractSnapshot
+ server_bans::AbstractServerBans
+ server_members::AbstractServerMembers
+ servers::AbstractServers

View File

@@ -1,28 +1,46 @@
use std::cmp::Ordering;
use std::time::Duration;
use std::time::SystemTime;
use super::AbstractRatelimitEvents;
use crate::RatelimitEvent;
use crate::RatelimitEventType;
use crate::ReferenceDb;
use revolt_result::Result;
use ulid::Ulid;
#[async_trait]
impl AbstractRatelimitEvents for ReferenceDb {
/// Insert a new ratelimit event
async fn insert_ratelimit_event(&self, _event: &RatelimitEvent) -> Result<()> {
// TODO: implement
unimplemented!()
async fn insert_ratelimit_event(&self, event: &RatelimitEvent) -> Result<()> {
let mut ratelimit_events = self.ratelimit_events.lock().await;
if ratelimit_events.contains_key(&event.id) {
Err(create_database_error!("insert", "message"))
} else {
ratelimit_events.insert(event.id.to_string(), event.clone());
Ok(())
}
}
/// Count number of events in given duration and check if we've hit the limit
async fn has_ratelimited(
&self,
_target_id: &str,
_event_type: RatelimitEventType,
_period: Duration,
_count: usize,
target_id: &str,
event_type: RatelimitEventType,
period: Duration,
count: usize,
) -> Result<bool> {
// TODO: implement
unimplemented!()
let ratelimit_events = self.ratelimit_events.lock().await;
let gte_cmp_id = Ulid::from_datetime(SystemTime::now() - period).to_string();
Ok(ratelimit_events
.iter()
.filter(|(id, event)| {
id.cmp(&&gte_cmp_id) == Ordering::Greater
&& event.target_id == target_id
&& event.event_type == event_type
})
.count()
>= count)
}
}

View File

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

View File

@@ -0,0 +1,22 @@
use revolt_models::v0::{ReportStatus, ReportedContent};
auto_derived!(
/// User-generated platform moderation report
pub struct Report {
/// Unique Id
#[serde(rename = "_id")]
pub id: String,
/// Id of the user creating this report
pub author_id: String,
/// Reported content
pub content: ReportedContent,
/// Additional report context
pub additional_context: String,
/// Status of the report
#[serde(flatten)]
pub status: ReportStatus,
/// Additional notes included on the report
#[serde(default)]
pub notes: String,
}
);

View File

@@ -0,0 +1,12 @@
use revolt_result::Result;
use crate::Report;
mod mongodb;
mod reference;
#[async_trait]
pub trait AbstractReport: Sync + Send {
/// Insert a new report into the database
async fn insert_report(&self, report: &Report) -> Result<()>;
}

View File

@@ -0,0 +1,16 @@
use revolt_result::Result;
use crate::MongoDb;
use crate::Report;
use super::AbstractReport;
static COL: &str = "safety_reports";
#[async_trait]
impl AbstractReport for MongoDb {
/// Insert a new report into the database
async fn insert_report(&self, report: &Report) -> Result<()> {
query!(self, insert_one, COL, &report).map(|_| ())
}
}

View File

@@ -0,0 +1,20 @@
use revolt_result::Result;
use crate::ReferenceDb;
use crate::Report;
use super::AbstractReport;
#[async_trait]
impl AbstractReport for ReferenceDb {
/// Insert a new report into the database
async fn insert_report(&self, report: &Report) -> Result<()> {
let mut reports = self.safety_reports.lock().await;
if reports.contains_key(&report.id) {
Err(create_database_error!("insert", "report"))
} else {
reports.insert(report.id.to_string(), report.clone());
Ok(())
}
}
}

View File

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

View File

@@ -1,13 +1,44 @@
use crate::{
models::{
message::{MessageFilter, MessageQuery, MessageSort, MessageTimePeriod},
snapshot::SnapshotContent,
Message, Server, User,
},
Database, Result,
};
use revolt_models::v0::MessageSort;
use revolt_result::Result;
use crate::{Database, Message, MessageFilter, MessageQuery, MessageTimePeriod, Server, User};
auto_derived!(
/// Snapshot of some content
pub struct Snapshot {
/// Unique Id
#[serde(rename = "_id")]
pub id: String,
/// Report parent Id
pub report_id: String,
/// Snapshot of content
pub content: SnapshotContent,
}
/// Enum to map into different models
/// that can be saved in a snapshot
#[serde(tag = "_type")]
pub enum SnapshotContent {
Message {
/// Context before the message
#[serde(rename = "_prior_context", default)]
prior_context: Vec<Message>,
/// Context after the message
#[serde(rename = "_leading_context", default)]
leading_context: Vec<Message>,
/// Message
#[serde(flatten)]
message: Message,
},
Server(Server),
User(User),
}
);
impl SnapshotContent {
/// Generate snapshot from a given message
pub async fn generate_from_message(
db: &Database,
message: Message,
@@ -54,13 +85,14 @@ impl SnapshotContent {
Ok((
SnapshotContent::Message {
message,
prior_context,
leading_context,
prior_context: prior_context.into_iter().map(Into::into).collect(),
leading_context: leading_context.into_iter().map(Into::into).collect(),
},
files,
))
}
/// Generate snapshot from a given server
pub fn generate_from_server(server: Server) -> Result<(SnapshotContent, Vec<String>)> {
// Collect server's icon and banner
let files = [&server.icon, &server.banner]
@@ -71,6 +103,7 @@ impl SnapshotContent {
Ok((SnapshotContent::Server(server), files))
}
/// Generate snapshot from a given user
pub fn generate_from_user(user: User) -> Result<(SnapshotContent, Vec<String>)> {
// Collect user's avatar and profile background
let files = [

View File

@@ -1,11 +1,12 @@
use crate::models::Snapshot;
use crate::Result;
use revolt_result::Result;
use crate::Snapshot;
mod mongodb;
mod reference;
#[async_trait]
pub trait AbstractSnapshot: Sync + Send {
/// Insert a new snapshot into the database
async fn insert_snapshot(&self, snapshot: &Snapshot) -> Result<()>;
/// Fetch a snapshots by a report's id
async fn fetch_snapshots(&self, report_id: &str) -> Result<Vec<Snapshot>>;
}

View File

@@ -0,0 +1,16 @@
use revolt_result::Result;
use crate::MongoDb;
use crate::Snapshot;
use super::AbstractSnapshot;
static COL: &str = "safety_snapshots";
#[async_trait]
impl AbstractSnapshot for MongoDb {
/// Insert a new snapshot into the database
async fn insert_snapshot(&self, snapshot: &Snapshot) -> Result<()> {
query!(self, insert_one, COL, &snapshot).map(|_| ())
}
}

View File

@@ -0,0 +1,20 @@
use revolt_result::Result;
use crate::ReferenceDb;
use crate::Snapshot;
use super::AbstractSnapshot;
#[async_trait]
impl AbstractSnapshot for ReferenceDb {
/// Insert a new report into the database
async fn insert_snapshot(&self, snapshot: &Snapshot) -> Result<()> {
let mut snapshots = self.safety_snapshots.lock().await;
if snapshots.contains_key(&snapshot.id) {
Err(create_database_error!("insert", "snapshot"))
} else {
snapshots.insert(snapshot.id.to_string(), snapshot.clone());
Ok(())
}
}
}

View File

@@ -1,4 +1,6 @@
use crate::MemberCompositeKey;
use revolt_result::Result;
use crate::{Database, MemberCompositeKey, Server};
auto_derived!(
/// Server Ban
@@ -10,3 +12,24 @@ auto_derived!(
pub reason: Option<String>,
}
);
impl ServerBan {
/// Create ban
pub async fn create(
db: &Database,
server: &Server,
user_id: &str,
reason: Option<String>,
) -> Result<ServerBan> {
let ban = ServerBan {
id: MemberCompositeKey {
server: server.id.to_string(),
user: user_id.to_string(),
},
reason,
};
db.insert_ban(&ban).await?;
Ok(ban)
}
}

View File

@@ -3,8 +3,8 @@ use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
use revolt_result::{create_error, Result};
use crate::{
events::client::EventV1, util::permissions::DatabasePermissionQuery, Database, File, Server,
SystemMessage, User,
events::client::EventV1, util::permissions::DatabasePermissionQuery, Channel, Database, File,
Server, SystemMessage, User,
};
auto_derived_partial!(
@@ -80,9 +80,8 @@ impl Member {
db: &Database,
server: &Server,
user: &User,
// channels: Option<Vec<Channel>>,
//) -> Result<Vec<Channel>> {
) -> Result<()> {
channels: Option<Vec<Channel>>,
) -> Result<Vec<Channel>> {
if db.fetch_ban(&server.id, &user.id).await.is_ok() {
return Err(create_error!(Banned));
}
@@ -101,9 +100,10 @@ impl Member {
db.insert_member(&member).await?;
let mut channels = vec![];
let should_fetch = channels.is_none();
let mut channels = channels.unwrap_or_default();
if true {
if should_fetch {
let query = DatabasePermissionQuery::new(db, user).server(server);
let existing_channels = db.fetch_channels(&server.channels).await?;
@@ -119,6 +119,8 @@ impl Member {
}
}
let emojis = db.fetch_emoji_by_parent_id(&server.id).await?;
EventV1::ServerMemberJoin {
id: server.id.clone(),
user: user.id.clone(),
@@ -134,6 +136,7 @@ impl Member {
.into_iter()
.map(|channel| channel.into())
.collect(),
emojis: emojis.into_iter().map(|emoji| emoji.into()).collect(),
}
.private(user.id.clone())
.await;
@@ -152,8 +155,7 @@ impl Member {
.ok();
}
// Ok(channels)
Ok(())
Ok(channels)
}
/// Update member data
@@ -213,4 +215,47 @@ impl Member {
false
}
}
/// Remove member from server
pub async fn remove(
self,
db: &Database,
server: &Server,
intention: RemovalIntention,
silent: bool,
) -> Result<()> {
db.delete_member(&self.id).await?;
EventV1::ServerMemberLeave {
id: self.id.server.to_string(),
user: self.id.user.to_string(),
}
.p(self.id.server.to_string())
.await;
if !silent {
if let Some(id) = server
.system_messages
.as_ref()
.and_then(|x| match intention {
RemovalIntention::Leave => x.user_left.as_ref(),
RemovalIntention::Kick => x.user_kicked.as_ref(),
RemovalIntention::Ban => x.user_banned.as_ref(),
})
{
match intention {
RemovalIntention::Leave => SystemMessage::UserLeft { id: self.id.user },
RemovalIntention::Kick => SystemMessage::UserKicked { id: self.id.user },
RemovalIntention::Ban => SystemMessage::UserBanned { id: self.id.user },
}
.into_message(id.to_string())
// TODO: support notifications here in the future?
.send_without_notifications(db, false, false)
.await
.ok();
}
}
Ok(())
}
}

View File

@@ -1,10 +1,11 @@
use std::collections::{HashMap, HashSet};
use revolt_permissions::OverrideField;
use revolt_models::v0::{self, DataCreateServerChannel};
use revolt_permissions::{OverrideField, DEFAULT_PERMISSION_SERVER};
use revolt_result::Result;
use ulid::Ulid;
use crate::{events::client::EventV1, Database, File};
use crate::{events::client::EventV1, Channel, Database, File, User};
auto_derived_partial!(
/// Server
@@ -22,7 +23,7 @@ auto_derived_partial!(
pub description: Option<String>,
/// Channels within this server
// ! FIXME: this may be redundant
// TODO: investigate if this is redundant and can be removed
pub channels: Vec<String>,
/// Categories for this server
#[serde(skip_serializing_if = "Option::is_none")]
@@ -131,8 +132,52 @@ auto_derived!(
#[allow(clippy::disallowed_methods)]
impl Server {
/// Create a server
pub async fn create(&self, db: &Database) -> Result<()> {
db.insert_server(self).await
pub async fn create(
db: &Database,
data: v0::DataCreateServer,
owner: &User,
create_default_channels: bool,
) -> Result<(Server, Vec<Channel>)> {
let mut server = Server {
id: ulid::Ulid::new().to_string(),
owner: owner.id.to_string(),
name: data.name,
description: data.description,
channels: vec![],
nsfw: data.nsfw.unwrap_or(false),
default_permissions: *DEFAULT_PERMISSION_SERVER as i64,
analytics: false,
banner: None,
categories: None,
discoverable: false,
flags: None,
icon: None,
roles: HashMap::new(),
system_messages: None,
};
let channels: Vec<Channel> = if create_default_channels {
vec![
Channel::create_server_channel(
db,
&mut server,
DataCreateServerChannel {
channel_type: v0::LegacyServerChannelType::Text,
name: "General".to_string(),
..Default::default()
},
false,
)
.await?,
]
} else {
vec![]
};
server.channels = channels.iter().map(|c| c.id()).collect();
db.insert_server(&server).await?;
Ok((server, channels))
}
/// Update server data
@@ -208,144 +253,6 @@ impl Server {
Err(create_error!(NotFound))
}
}
/* /// Create a new member in a server
pub async fn create_member(
&self,
db: &Database,
user: User,
channels: Option<Vec<Channel>>,
) -> Result<Vec<Channel>> {
if db.fetch_ban(&self.id, &user.id).await.is_ok() {
return Err(Error::Banned);
}
let member = Member {
id: MemberCompositeKey {
server: self.id.clone(),
user: user.id.clone(),
},
joined_at: Timestamp::now_utc(),
nickname: None,
avatar: None,
roles: vec![],
timeout: None,
};
db.insert_member(&member).await?;
let should_fetch = channels.is_none();
let mut channels = channels.unwrap_or_default();
if should_fetch {
let perm = perms(&user).server(self).member(&member);
let existing_channels = db.fetch_channels(&self.channels).await?;
for channel in existing_channels {
if perm
.clone()
.channel(&channel)
.has_permission(db, Permission::ViewChannel)
.await?
{
channels.push(channel);
}
}
}
/* // TODO: EventV1::ServerMemberJoin {
id: self.id.clone(),
user: user.id.clone(),
}
.p(self.id.clone())
.await;
EventV1::ServerCreate {
id: self.id.clone(),
server: self.clone(),
channels: channels.clone(),
}
.private(user.id.clone())
.await; */
if let Some(id) = self
.system_messages
.as_ref()
.and_then(|x| x.user_joined.as_ref())
{
SystemMessage::UserJoined {
id: user.id.clone(),
}
.into_message(id.to_string())
.create_no_web_push(db, id, false)
.await
.ok();
}
Ok(channels)
}
/// Remove a member from a server
pub async fn remove_member(
&self,
db: &Database,
member: Member,
intention: RemovalIntention,
silent: bool,
) -> Result<()> {
db.delete_member(&member.id).await?;
/* // TODO: EventV1::ServerMemberLeave {
id: self.id.to_string(),
user: member.id.user.clone(),
}
.p(member.id.server)
.await; */
if !silent {
if let Some(id) = self.system_messages.as_ref().and_then(|x| match intention {
RemovalIntention::Leave => x.user_left.as_ref(),
RemovalIntention::Kick => x.user_kicked.as_ref(),
RemovalIntention::Ban => x.user_banned.as_ref(),
}) {
match intention {
RemovalIntention::Leave => SystemMessage::UserLeft { id: member.id.user },
RemovalIntention::Kick => SystemMessage::UserKicked { id: member.id.user },
RemovalIntention::Ban => SystemMessage::UserBanned { id: member.id.user },
}
.into_message(id.to_string())
.create_no_web_push(db, id, false)
.await
.ok();
}
}
Ok(())
}
/// Create ban
pub async fn ban_user(
self,
db: &Database,
id: MemberCompositeKey,
reason: Option<String>,
) -> Result<ServerBan> {
let ban = ServerBan { id, reason };
db.insert_ban(&ban).await?;
Ok(ban)
}
/// Ban a member from a server
pub async fn ban_member(
self,
db: &Database,
member: Member,
reason: Option<String>,
) -> Result<ServerBan> {
self.remove_member(db, member.clone(), RemovalIntention::Ban, false)
.await?;
self.ban_user(db, member.id, reason).await
} */
}
impl Role {
@@ -450,3 +357,36 @@ impl SystemMessageChannels {
ids
}
}
#[cfg(test)]
mod tests {
use revolt_permissions::{calculate_server_permissions, ChannelPermission};
use crate::{fixture, util::permissions::DatabasePermissionQuery};
#[async_std::test]
async fn permissions() {
database_test!(|db| async move {
fixture!(db, "server_with_roles",
owner user 0
moderator user 1
user user 2
server server 4);
let mut query = DatabasePermissionQuery::new(&db, &owner).server(&server);
assert!(calculate_server_permissions(&mut query)
.await
.has_channel_permission(ChannelPermission::GrantAllSafe));
let mut query = DatabasePermissionQuery::new(&db, &moderator).server(&server);
assert!(calculate_server_permissions(&mut query)
.await
.has_channel_permission(ChannelPermission::BanMembers));
let mut query = DatabasePermissionQuery::new(&db, &user).server(&server);
assert!(!calculate_server_permissions(&mut query)
.await
.has_channel_permission(ChannelPermission::BanMembers));
});
}
}

View File

@@ -1,4 +1,4 @@
use bson::{to_document, Document};
use bson::{to_document, Bson, Document};
use futures::StreamExt;
use revolt_result::Result;
@@ -67,6 +67,7 @@ impl AbstractServers for MongoDb {
/// Delete a server by its id
async fn delete_server(&self, id: &str) -> Result<()> {
self.delete_associated_server_objects(id).await?;
query!(self, delete_one_by_id, COL, id).map(|_| ())
}
@@ -181,3 +182,81 @@ impl IntoDocumentPath for FieldsRole {
})
}
}
impl MongoDb {
pub async fn delete_associated_server_objects(&self, server_id: &str) -> Result<()> {
// Find all channels
let channels: Vec<String> = self
.col::<Document>("channels")
.find(
doc! {
"server": server_id
},
None,
)
.await
.map_err(|_| create_database_error!("find", "channels"))?
.filter_map(|s| async {
s.map(|d| d.get_str("_id").map(|s| s.to_string()).ok())
.ok()
.flatten()
})
.collect()
.await;
// Check if there are any attachments we need to delete.
self.delete_bulk_messages(doc! {
"channel": {
"$in": &channels
}
})
.await?;
// Delete all emoji.
self.col::<Document>("emojis")
.delete_many(
doc! {
"parent.id": &server_id
},
None,
)
.await
.map_err(|_| create_database_error!("delete_many", "emojis"))?;
// Delete all channels.
self.col::<Document>("channels")
.delete_many(
doc! {
"server": &server_id
},
None,
)
.await
.map_err(|_| create_database_error!("delete_many", "channels"))?;
// Delete any associated objects, e.g. unreads and invites.
self.delete_associated_channel_objects(Bson::Document(doc! { "$in": &channels }))
.await?;
// Delete members and bans.
for with in &["server_members", "server_bans"] {
self.col::<Document>(with)
.delete_many(
doc! {
"_id.server": &server_id
},
None,
)
.await
.map_err(|_| create_database_error!("delete_many", with))?;
}
// Update many attachments with parent id.
self.delete_many_attachments(doc! {
"object_id": &server_id
})
.await?;
Ok(())
}
}

View File

@@ -1,6 +1,6 @@
use std::collections::HashMap;
use crate::Database;
use crate::{events::client::EventV1, Database};
use revolt_result::Result;
@@ -16,12 +16,12 @@ impl UserSettingsImpl for UserSettings {
async fn set(self, db: &Database, user: &str) -> Result<()> {
db.set_user_settings(user, &self).await?;
/* // TODO: EventV1::UserSettingsUpdate {
EventV1::UserSettingsUpdate {
id: user.to_string(),
update: self,
}
.private(user.to_string())
.await; */
.await;
Ok(())
}

View File

@@ -4,7 +4,10 @@ use crate::{events::client::EventV1, Database, File, RatelimitEvent};
use once_cell::sync::Lazy;
use rand::seq::SliceRandom;
use revolt_result::{create_error, Error, ErrorType, Result};
use revolt_config::config;
use revolt_models::v0;
use revolt_presence::filter_online;
use revolt_result::{create_error, Result};
use ulid::Ulid;
auto_derived_partial!(
@@ -93,20 +96,22 @@ auto_derived!(
}
/// User's active status
#[derive(Default)]
pub struct UserStatus {
/// Custom status text
#[serde(skip_serializing_if = "String::is_empty", default)]
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
/// Current presence option
#[serde(skip_serializing_if = "Option::is_none")]
pub presence: Option<Presence>,
}
/// User's profile
#[derive(Default)]
pub struct UserProfile {
/// Text content on user's profile
#[serde(skip_serializing_if = "String::is_empty", default)]
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
/// Background visible on user's profile
#[serde(skip_serializing_if = "Option::is_none")]
pub background: Option<File>,
@@ -117,6 +122,16 @@ auto_derived!(
/// Id of the owner of this bot
pub owner: String,
}
/// Enumeration providing a hint to the type of user we are handling
pub enum UserHint {
/// Could be either a user or a bot
Any,
/// Only match bots
Bot,
/// Only match users
User,
}
);
pub static DISCRIMINATOR_SEARCH_SPACE: Lazy<HashSet<String>> = Lazy::new(|| {
@@ -182,6 +197,28 @@ impl User {
Ok(user)
}
/// Get the relationship with another user
pub fn relationship_with(&self, user_b: &str) -> RelationshipStatus {
if self.id == user_b {
return RelationshipStatus::User;
}
if let Some(relations) = &self.relations {
if let Some(relationship) = relations.iter().find(|x| x.id == user_b) {
return relationship.status.clone();
}
}
RelationshipStatus::None
}
pub fn is_friends_with(&self, user_b: &str) -> bool {
matches!(
self.relationship_with(user_b),
RelationshipStatus::Friend | RelationshipStatus::User
)
}
/// Check whether two users have a mutual connection
///
/// This will check if user and user_b share a server or a group.
@@ -196,6 +233,18 @@ impl User {
.is_empty())
}
/// 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 {
Ok(())
} else {
Err(create_error!(TooManyServers {
max: config.features.limits.default.servers
}))
}
}
/// Sanitise and validate a username can be used
pub fn validate_username(username: String) -> Result<String> {
// Copy the username for validation
@@ -227,7 +276,55 @@ impl User {
Ok(username)
}
// Find a free discriminator for a given username
/// 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, String)> {
match hint {
UserHint::Bot => Ok((
db.fetch_user(
&db.fetch_bot_by_token(token)
.await
.map_err(|_| create_error!(InvalidSession))?
.id,
)
.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::Any => {
if let Ok(result) = User::from_token(db, token, UserHint::User).await {
Ok(result)
} else {
User::from_token(db, token, UserHint::Bot).await
}
}
}
}
/// Helper function to fetch many users as a mutually connected user
/// (while optimising the online ID query)
pub async fn fetch_many_ids_as_mutuals(
db: &Database,
perspective: &User,
ids: &[String],
) -> Result<Vec<v0::User>> {
let online_ids = filter_online(ids).await;
Ok(db
.fetch_users(ids)
.await?
.into_iter()
.map(|user| {
let is_online = online_ids.contains(&user.id);
user.into_known(perspective, is_online)
})
.collect())
}
/// Find a free discriminator for a given username
pub async fn find_discriminator(
db: &Database,
username: &str,
@@ -313,16 +410,182 @@ impl User {
}
}
/// Check whether a username is already in use by another user
#[allow(dead_code)]
async fn is_username_taken(db: &Database, username: &str) -> Result<bool> {
match db.fetch_user_by_username(username).await {
Ok(_) => Ok(true),
Err(Error {
error_type: ErrorType::NotFound,
..
}) => Ok(false),
Err(error) => Err(error),
/// Set a relationship to another user
pub async fn set_relationship(
&mut self,
db: &Database,
user_b: &User,
status: RelationshipStatus,
) -> Result<()> {
db.set_relationship(&self.id, &user_b.id, &status).await?;
if let RelationshipStatus::None | RelationshipStatus::User = status {
if let Some(relations) = &mut self.relations {
relations.retain(|relation| relation.id != user_b.id);
}
} else {
let relation = Relationship {
id: user_b.id.to_string(),
status,
};
if let Some(relations) = &mut self.relations {
relations.retain(|relation| relation.id != user_b.id);
relations.push(relation);
} else {
self.relations = Some(vec![relation]);
}
}
Ok(())
}
/// Apply a certain relationship between two users
pub async fn apply_relationship(
&mut self,
db: &Database,
target: &mut User,
local: RelationshipStatus,
remote: RelationshipStatus,
) -> Result<()> {
target.set_relationship(db, self, remote).await?;
self.set_relationship(db, target, local).await?;
EventV1::UserRelationship {
id: target.id.clone(),
user: self.clone().into(db, Some(&*target)).await,
}
.private(target.id.clone())
.await;
EventV1::UserRelationship {
id: self.id.clone(),
user: target.clone().into(db, Some(&*self)).await,
}
.private(self.id.clone())
.await;
Ok(())
}
/// Add another user as a friend
pub async fn add_friend(&mut self, db: &Database, target: &mut User) -> Result<()> {
let count = self
.relations
.as_ref()
.map(|relations| {
relations
.iter()
.filter(|r| matches!(r.status, RelationshipStatus::Outgoing))
.count()
})
.unwrap_or_default();
let config = config().await;
if count >= config.features.limits.default.outgoing_friend_requests {
return Err(create_error!(TooManyPendingFriendRequests {
max: config.features.limits.default.outgoing_friend_requests
}));
}
match self.relationship_with(&target.id) {
RelationshipStatus::User => Err(create_error!(NoEffect)),
RelationshipStatus::Friend => Err(create_error!(AlreadyFriends)),
RelationshipStatus::Outgoing => Err(create_error!(AlreadySentRequest)),
RelationshipStatus::Blocked => Err(create_error!(Blocked)),
RelationshipStatus::BlockedOther => Err(create_error!(BlockedByOther)),
RelationshipStatus::Incoming => {
self.apply_relationship(
db,
target,
RelationshipStatus::Friend,
RelationshipStatus::Friend,
)
.await
}
RelationshipStatus::None => {
self.apply_relationship(
db,
target,
RelationshipStatus::Outgoing,
RelationshipStatus::Incoming,
)
.await
}
}
}
/// Remove another user as a friend
pub async fn remove_friend(&mut self, db: &Database, target: &mut User) -> Result<()> {
match self.relationship_with(&target.id) {
RelationshipStatus::Friend
| RelationshipStatus::Outgoing
| RelationshipStatus::Incoming => {
self.apply_relationship(
db,
target,
RelationshipStatus::None,
RelationshipStatus::None,
)
.await
}
_ => Err(create_error!(NoEffect)),
}
}
/// Block another user
pub async fn block_user(&mut self, db: &Database, target: &mut User) -> Result<()> {
match self.relationship_with(&target.id) {
RelationshipStatus::User | RelationshipStatus::Blocked => Err(create_error!(NoEffect)),
RelationshipStatus::BlockedOther => {
self.apply_relationship(
db,
target,
RelationshipStatus::Blocked,
RelationshipStatus::Blocked,
)
.await
}
RelationshipStatus::None
| RelationshipStatus::Friend
| RelationshipStatus::Incoming
| RelationshipStatus::Outgoing => {
self.apply_relationship(
db,
target,
RelationshipStatus::Blocked,
RelationshipStatus::BlockedOther,
)
.await
}
}
}
/// Unblock another user
pub async fn unblock_user(&mut self, db: &Database, target: &mut User) -> Result<()> {
match self.relationship_with(&target.id) {
RelationshipStatus::Blocked => match target.relationship_with(&self.id) {
RelationshipStatus::Blocked => {
self.apply_relationship(
db,
target,
RelationshipStatus::BlockedOther,
RelationshipStatus::Blocked,
)
.await
}
RelationshipStatus::BlockedOther => {
self.apply_relationship(
db,
target,
RelationshipStatus::None,
RelationshipStatus::None,
)
.await
}
_ => Err(create_error!(InternalError)),
},
_ => Err(create_error!(NoEffect)),
}
}
@@ -358,7 +621,7 @@ impl User {
FieldsUser::Avatar => self.avatar = None,
FieldsUser::StatusText => {
if let Some(x) = self.status.as_mut() {
x.text = String::new();
x.text = None;
}
}
FieldsUser::StatusPresence => {
@@ -368,7 +631,7 @@ impl User {
}
FieldsUser::ProfileContent => {
if let Some(x) = self.profile.as_mut() {
x.content = String::new();
x.content = None;
}
}
FieldsUser::ProfileBackground => {

View File

@@ -1,3 +1,4 @@
use authifier::models::Session;
use revolt_result::Result;
use crate::{FieldsUser, PartialUser, RelationshipStatus, User};
@@ -14,10 +15,10 @@ pub trait AbstractUsers: Sync + Send {
async fn fetch_user(&self, id: &str) -> Result<User>;
/// Fetch a user from the database by their username
async fn fetch_user_by_username(&self, username: &str) -> Result<User>;
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>>;

View File

@@ -25,13 +25,14 @@ impl AbstractUsers for MongoDb {
}
/// Fetch a user from the database by their username
async fn fetch_user_by_username(&self, username: &str) -> Result<User> {
async fn fetch_user_by_username(&self, username: &str, discriminator: &str) -> Result<User> {
query!(
self,
find_one_with_options,
COL,
doc! {
"username": username
"username": username,
"discriminator": discriminator
},
FindOneOptions::builder()
.collation(
@@ -45,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
@@ -57,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.id).await
.ok_or_else(|| create_error!(InvalidSession))
}
/// Fetch multiple users by their ids

View File

@@ -1,7 +1,8 @@
use authifier::models::Session;
use revolt_result::Result;
use crate::ReferenceDb;
use crate::{FieldsUser, PartialUser, RelationshipStatus, User};
use crate::{ReferenceDb, Relationship};
use super::AbstractUsers;
@@ -28,18 +29,20 @@ impl AbstractUsers for ReferenceDb {
}
/// Fetch a user from the database by their username
async fn fetch_user_by_username(&self, username: &str) -> Result<User> {
async fn fetch_user_by_username(&self, username: &str, discriminator: &str) -> Result<User> {
let users = self.users.lock().await;
let lowercase = username.to_lowercase();
users
.values()
.find(|user| user.username.to_lowercase() == lowercase)
.find(|user| {
user.username.to_lowercase() == lowercase && user.discriminator == discriminator
})
.cloned()
.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!()
}
@@ -106,19 +109,49 @@ impl AbstractUsers for ReferenceDb {
/// Set relationship with another user
///
/// This should use pull_relationship if relationship is None.
/// This should use pull_relationship if relationship is None or User.
async fn set_relationship(
&self,
_user_id: &str,
_target_id: &str,
_relationship: &RelationshipStatus,
user_id: &str,
target_id: &str,
relationship: &RelationshipStatus,
) -> Result<()> {
todo!()
if let RelationshipStatus::User | RelationshipStatus::None = &relationship {
self.pull_relationship(user_id, target_id).await
} else {
let mut users = self.users.lock().await;
let user = users
.get_mut(user_id)
.ok_or_else(|| create_error!(NotFound))?;
let relation = Relationship {
id: target_id.to_string(),
status: relationship.clone(),
};
if let Some(relations) = &mut user.relations {
relations.retain(|relation| relation.id != target_id);
relations.push(relation);
} else {
user.relations = Some(vec![relation]);
}
Ok(())
}
}
/// Remove relationship with another user
async fn pull_relationship(&self, _user_id: &str, _target_id: &str) -> Result<()> {
todo!()
async fn pull_relationship(&self, user_id: &str, target_id: &str) -> Result<()> {
let mut users = self.users.lock().await;
let user = users
.get_mut(user_id)
.ok_or_else(|| create_error!(NotFound))?;
if let Some(relations) = &mut user.relations {
relations.retain(|relation| relation.id != target_id);
}
Ok(())
}
/// Delete a user by their id

View File

@@ -13,19 +13,19 @@ impl<'r> FromRequest<'r> for User {
.local_cache_async(async {
let db = request.rocket().state::<Database>().expect("`Database`");
let _header_bot_token = request
let header_bot_token = request
.headers()
.get("x-bot-token")
.next()
.map(|x| x.to_string());
/* if let Some(bot_token) = header_bot_token {
if let Ok(user) = User::from_token(db, &bot_token, UserHint::Bot).await {
return Some(user);
if let Some(bot_token) = header_bot_token {
if let Ok(bot) = db.fetch_bot_by_token(&bot_token).await {
if let Ok(user) = db.fetch_user(&bot.id).await {
return Some(user);
}
}
} else */
if let Outcome::Success(session) = request.guard::<Session>().await {
// This uses a guard so can't really easily be refactored into from_token at this stage.
} else if let Outcome::Success(session) = request.guard::<Session>().await {
if let Ok(user) = db.fetch_user(&session.user_id).await {
return Some(user);
}

View File

@@ -13,12 +13,12 @@ pub mod process_embeds;
pub mod web_push;
/// Spawn background workers
pub async fn start_workers(db: Database) {
pub async fn start_workers(db: Database, authifier_db: authifier::Database) {
for _ in 0..WORKER_COUNT {
task::spawn(ack::worker(db.clone()));
task::spawn(last_message_id::worker(db.clone()));
task::spawn(process_embeds::worker(db.clone()));
task::spawn(web_push::worker(db.clone().into()));
task::spawn(web_push::worker(authifier_db.clone()));
}
}

View File

@@ -132,7 +132,7 @@ pub async fn generate(
return Err(create_error!(LabelMe));
}
// ! FIXME: batch request to january
// TODO: batch request to january
let mut tasks = Vec::new();
for link in links {

View File

@@ -1,4 +1,7 @@
use revolt_models::v0::*;
use revolt_permissions::{calculate_user_permissions, UserPermission};
use crate::{util::permissions::DatabasePermissionQuery, Database};
impl crate::Bot {
pub fn into_public_bot(self, user: crate::User) -> PublicBot {
@@ -12,6 +15,7 @@ impl crate::Bot {
description: user
.profile
.map(|profile| profile.content)
.flatten()
.unwrap_or_default(),
}
}
@@ -216,6 +220,86 @@ impl From<crate::Channel> for Channel {
}
}
impl From<Channel> for crate::Channel {
fn from(value: Channel) -> crate::Channel {
match value {
Channel::SavedMessages { id, user } => crate::Channel::SavedMessages { id, user },
Channel::DirectMessage {
id,
active,
recipients,
last_message_id,
} => crate::Channel::DirectMessage {
id,
active,
recipients,
last_message_id,
},
Channel::Group {
id,
name,
owner,
description,
recipients,
icon,
last_message_id,
permissions,
nsfw,
} => crate::Channel::Group {
id,
name,
owner,
description,
recipients,
icon: icon.map(|file| file.into()),
last_message_id,
permissions,
nsfw,
},
Channel::TextChannel {
id,
server,
name,
description,
icon,
last_message_id,
default_permissions,
role_permissions,
nsfw,
} => crate::Channel::TextChannel {
id,
server,
name,
description,
icon: icon.map(|file| file.into()),
last_message_id,
default_permissions,
role_permissions,
nsfw,
},
Channel::VoiceChannel {
id,
server,
name,
description,
icon,
default_permissions,
role_permissions,
nsfw,
} => crate::Channel::VoiceChannel {
id,
server,
name,
description,
icon: icon.map(|file| file.into()),
default_permissions,
role_permissions,
nsfw,
},
}
}
}
impl From<crate::PartialChannel> for PartialChannel {
fn from(value: crate::PartialChannel) -> Self {
PartialChannel {
@@ -233,6 +317,23 @@ impl From<crate::PartialChannel> for PartialChannel {
}
}
impl From<PartialChannel> for crate::PartialChannel {
fn from(value: PartialChannel) -> crate::PartialChannel {
crate::PartialChannel {
name: value.name,
owner: value.owner,
description: value.description,
icon: value.icon.map(|file| file.into()),
nsfw: value.nsfw,
active: value.active,
permissions: value.permissions,
role_permissions: value.role_permissions,
default_permissions: value.default_permissions,
last_message_id: value.last_message_id,
}
}
}
impl From<FieldsChannel> for crate::FieldsChannel {
fn from(value: FieldsChannel) -> Self {
match value {
@@ -275,6 +376,15 @@ impl From<crate::EmojiParent> for EmojiParent {
}
}
impl From<EmojiParent> for crate::EmojiParent {
fn from(value: EmojiParent) -> Self {
match value {
EmojiParent::Detached => crate::EmojiParent::Detached,
EmojiParent::Server { id } => crate::EmojiParent::Server { id },
}
}
}
impl From<crate::File> for File {
fn from(value: crate::File) -> Self {
File {
@@ -294,6 +404,25 @@ impl From<crate::File> for File {
}
}
impl From<File> for crate::File {
fn from(value: File) -> crate::File {
crate::File {
id: value.id,
tag: value.tag,
filename: value.filename,
metadata: value.metadata.into(),
content_type: value.content_type,
size: value.size,
deleted: value.deleted,
reported: value.reported,
message_id: value.message_id,
user_id: value.user_id,
server_id: value.server_id,
object_id: value.object_id,
}
}
}
impl From<crate::Metadata> for Metadata {
fn from(value: crate::Metadata) -> Self {
match value {
@@ -312,6 +441,24 @@ impl From<crate::Metadata> for Metadata {
}
}
impl From<Metadata> for crate::Metadata {
fn from(value: Metadata) -> crate::Metadata {
match value {
Metadata::File => crate::Metadata::File,
Metadata::Text => crate::Metadata::Text,
Metadata::Image { width, height } => crate::Metadata::Image {
width: width as isize,
height: height as isize,
},
Metadata::Video { width, height } => crate::Metadata::Video {
width: width as isize,
height: height as isize,
},
Metadata::Audio => crate::Metadata::Audio,
}
}
}
impl From<crate::Message> for Message {
fn from(value: crate::Message) -> Self {
Message {
@@ -385,7 +532,20 @@ impl From<crate::SystemMessage> for SystemMessage {
impl From<crate::Interactions> for Interactions {
fn from(value: crate::Interactions) -> Self {
Interactions {
reactions: value.reactions,
reactions: value
.reactions
.map(|reactions| reactions.into_iter().collect()),
restrict_reactions: value.restrict_reactions,
}
}
}
impl From<Interactions> for crate::Interactions {
fn from(value: Interactions) -> Self {
crate::Interactions {
reactions: value
.reactions
.map(|reactions| reactions.into_iter().collect()),
restrict_reactions: value.restrict_reactions,
}
}
@@ -409,6 +569,29 @@ impl From<crate::Masquerade> for Masquerade {
}
}
impl From<Masquerade> for crate::Masquerade {
fn from(value: Masquerade) -> Self {
crate::Masquerade {
name: value.name,
avatar: value.avatar,
colour: value.colour,
}
}
}
impl From<crate::Report> for Report {
fn from(value: crate::Report) -> Self {
Report {
id: value.id,
author_id: value.author_id,
content: value.content,
additional_context: value.additional_context,
status: value.status,
notes: value.notes,
}
}
}
impl From<crate::ServerBan> for ServerBan {
fn from(value: crate::ServerBan) -> Self {
ServerBan {
@@ -431,6 +614,19 @@ impl From<crate::Member> for Member {
}
}
impl From<Member> for crate::Member {
fn from(value: Member) -> crate::Member {
crate::Member {
id: value.id.into(),
joined_at: value.joined_at,
nickname: value.nickname,
avatar: value.avatar.map(|f| f.into()),
roles: value.roles,
timeout: value.timeout,
}
}
}
impl From<crate::PartialMember> for PartialMember {
fn from(value: crate::PartialMember) -> Self {
PartialMember {
@@ -444,6 +640,19 @@ impl From<crate::PartialMember> for PartialMember {
}
}
impl From<PartialMember> for crate::PartialMember {
fn from(value: PartialMember) -> crate::PartialMember {
crate::PartialMember {
id: value.id.map(|id| id.into()),
joined_at: value.joined_at,
nickname: value.nickname,
avatar: value.avatar.map(|f| f.into()),
roles: value.roles,
timeout: value.timeout,
}
}
}
impl From<crate::MemberCompositeKey> for MemberCompositeKey {
fn from(value: crate::MemberCompositeKey) -> Self {
MemberCompositeKey {
@@ -453,6 +662,15 @@ impl From<crate::MemberCompositeKey> for MemberCompositeKey {
}
}
impl From<MemberCompositeKey> for crate::MemberCompositeKey {
fn from(value: MemberCompositeKey) -> crate::MemberCompositeKey {
crate::MemberCompositeKey {
server: value.server,
user: value.user,
}
}
}
impl From<crate::FieldsMember> for FieldsMember {
fn from(value: crate::FieldsMember) -> Self {
match value {
@@ -464,6 +682,17 @@ impl From<crate::FieldsMember> for FieldsMember {
}
}
impl From<FieldsMember> for crate::FieldsMember {
fn from(value: FieldsMember) -> crate::FieldsMember {
match value {
FieldsMember::Avatar => crate::FieldsMember::Avatar,
FieldsMember::Nickname => crate::FieldsMember::Nickname,
FieldsMember::Roles => crate::FieldsMember::Roles,
FieldsMember::Timeout => crate::FieldsMember::Timeout,
}
}
}
impl From<crate::RemovalIntention> for RemovalIntention {
fn from(value: crate::RemovalIntention) -> Self {
match value {
@@ -502,6 +731,34 @@ impl From<crate::Server> for Server {
}
}
impl From<Server> for crate::Server {
fn from(value: Server) -> crate::Server {
crate::Server {
id: value.id,
owner: value.owner,
name: value.name,
description: value.description,
channels: value.channels,
categories: value
.categories
.map(|categories| categories.into_iter().map(|v| v.into()).collect()),
system_messages: value.system_messages.map(|v| v.into()),
roles: value
.roles
.into_iter()
.map(|(k, v)| (k, v.into()))
.collect(),
default_permissions: value.default_permissions,
icon: value.icon.map(|f| f.into()),
banner: value.banner.map(|f| f.into()),
flags: Some(value.flags as i32),
nsfw: value.nsfw,
analytics: value.analytics,
discoverable: value.discoverable,
}
}
}
impl From<crate::PartialServer> for PartialServer {
fn from(value: crate::PartialServer) -> Self {
PartialServer {
@@ -528,6 +785,32 @@ impl From<crate::PartialServer> for PartialServer {
}
}
impl From<PartialServer> for crate::PartialServer {
fn from(value: PartialServer) -> crate::PartialServer {
crate::PartialServer {
id: value.id,
owner: value.owner,
name: value.name,
description: value.description,
channels: value.channels,
categories: value
.categories
.map(|categories| categories.into_iter().map(|v| v.into()).collect()),
system_messages: value.system_messages.map(|v| v.into()),
roles: value
.roles
.map(|roles| roles.into_iter().map(|(k, v)| (k, v.into())).collect()),
default_permissions: value.default_permissions,
icon: value.icon.map(|f| f.into()),
banner: value.banner.map(|f| f.into()),
flags: value.flags.map(|v| v as i32),
nsfw: value.nsfw,
analytics: value.analytics,
discoverable: value.discoverable,
}
}
}
impl From<crate::FieldsServer> for FieldsServer {
fn from(value: crate::FieldsServer) -> Self {
match value {
@@ -540,6 +823,18 @@ impl From<crate::FieldsServer> for FieldsServer {
}
}
impl From<FieldsServer> for crate::FieldsServer {
fn from(value: FieldsServer) -> crate::FieldsServer {
match value {
FieldsServer::Banner => crate::FieldsServer::Banner,
FieldsServer::Categories => crate::FieldsServer::Categories,
FieldsServer::Description => crate::FieldsServer::Description,
FieldsServer::Icon => crate::FieldsServer::Icon,
FieldsServer::SystemMessages => crate::FieldsServer::SystemMessages,
}
}
}
impl From<crate::Category> for Category {
fn from(value: crate::Category) -> Self {
Category {
@@ -550,6 +845,16 @@ impl From<crate::Category> for Category {
}
}
impl From<Category> for crate::Category {
fn from(value: Category) -> Self {
crate::Category {
id: value.id,
title: value.title,
channels: value.channels,
}
}
}
impl From<crate::SystemMessageChannels> for SystemMessageChannels {
fn from(value: crate::SystemMessageChannels) -> Self {
SystemMessageChannels {
@@ -561,6 +866,17 @@ impl From<crate::SystemMessageChannels> for SystemMessageChannels {
}
}
impl From<SystemMessageChannels> for crate::SystemMessageChannels {
fn from(value: SystemMessageChannels) -> Self {
crate::SystemMessageChannels {
user_joined: value.user_joined,
user_left: value.user_left,
user_kicked: value.user_kicked,
user_banned: value.user_banned,
}
}
}
impl From<crate::Role> for Role {
fn from(value: crate::Role) -> Self {
Role {
@@ -573,6 +889,18 @@ impl From<crate::Role> for Role {
}
}
impl From<Role> for crate::Role {
fn from(value: Role) -> crate::Role {
crate::Role {
name: value.name,
permissions: value.permissions,
colour: value.colour,
hoist: value.hoist,
rank: value.rank,
}
}
}
impl From<crate::PartialRole> for PartialRole {
fn from(value: crate::PartialRole) -> Self {
PartialRole {
@@ -585,6 +913,18 @@ impl From<crate::PartialRole> for PartialRole {
}
}
impl From<PartialRole> for crate::PartialRole {
fn from(value: PartialRole) -> crate::PartialRole {
crate::PartialRole {
name: value.name,
permissions: value.permissions,
colour: value.colour,
hoist: value.hoist,
rank: value.rank,
}
}
}
impl From<crate::FieldsRole> for FieldsRole {
fn from(value: crate::FieldsRole) -> Self {
match value {
@@ -593,41 +933,158 @@ impl From<crate::FieldsRole> for FieldsRole {
}
}
impl crate::User {
pub async fn into<P>(self, perspective: P) -> User
where
P: Into<Option<crate::User>>,
{
let relationship = if let Some(perspective) = perspective.into() {
perspective
.relations
.unwrap_or_default()
.into_iter()
.find(|relationship| relationship.id == self.id)
.map(|relationship| relationship.status.into())
.unwrap_or_default()
} else {
RelationshipStatus::None
};
impl From<FieldsRole> for crate::FieldsRole {
fn from(value: FieldsRole) -> Self {
match value {
FieldsRole::Colour => crate::FieldsRole::Colour,
}
}
}
// do permission stuff here
// TODO: implement permissions =)
let can_see_profile = false;
impl crate::User {
pub async fn into<'a, P>(self, db: &Database, perspective: P) -> User
where
P: Into<Option<&'a crate::User>>,
{
let perspective = perspective.into();
let (relationship, can_see_profile) = if self.bot.is_some() {
(RelationshipStatus::None, true)
} else if let Some(perspective) = perspective {
let mut query = DatabasePermissionQuery::new(db, perspective).user(&self);
if perspective.id == self.id {
(RelationshipStatus::User, true)
} else {
(
perspective
.relations
.as_ref()
.map(|relations| {
relations
.iter()
.find(|relationship| relationship.id == self.id)
.map(|relationship| relationship.status.clone().into())
.unwrap_or_default()
})
.unwrap_or_default(),
calculate_user_permissions(&mut query)
.await
.has_user_permission(UserPermission::ViewProfile),
)
}
} else {
(RelationshipStatus::None, false)
};
User {
username: self.username,
discriminator: self.discriminator,
display_name: self.display_name,
avatar: self.avatar.map(|file| file.into()),
relations: vec![],
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![]
},
badges: self.badges.unwrap_or_default() as u32,
status: None,
profile: None,
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
},
flags: self.flags.unwrap_or_default() as u32,
privileged: self.privileged,
bot: self.bot.map(|bot| bot.into()),
relationship,
id: self.id,
}
}
/// Convert user object into user model assuming mutual connection
pub fn into_known<'a, P>(self, perspective: P, is_online: bool) -> User
where
P: Into<Option<&'a crate::User>>,
{
let perspective = perspective.into();
let (relationship, can_see_profile) = if self.bot.is_some() {
(RelationshipStatus::None, true)
} else if let Some(perspective) = perspective {
if perspective.id == self.id {
(RelationshipStatus::User, true)
} else {
let relationship = perspective
.relations
.as_ref()
.map(|relations| {
relations
.iter()
.find(|relationship| relationship.id == self.id)
.map(|relationship| relationship.status.clone().into())
.unwrap_or_default()
})
.unwrap_or_default();
let can_see_profile = relationship != RelationshipStatus::BlockedOther;
(relationship, can_see_profile)
}
} else {
(RelationshipStatus::None, false)
};
User {
username: self.username,
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![]
},
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
},
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,
}
}
@@ -648,16 +1105,48 @@ impl crate::User {
})
.unwrap_or_default(),
badges: self.badges.unwrap_or_default() as u32,
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,
}
}
pub fn as_author_for_system(&self) -> MessageAuthor {
MessageAuthor::System {
username: &self.username,
avatar: self.avatar.as_ref().map(|file| file.id.as_ref()),
}
}
}
impl From<User> for crate::User {
fn from(value: User) -> crate::User {
crate::User {
id: value.id,
username: value.username,
discriminator: value.discriminator,
display_name: value.display_name,
avatar: value.avatar.map(Into::into),
relations: None,
badges: Some(value.badges as i32),
status: value.status.map(Into::into),
profile: None,
flags: Some(value.flags as i32),
privileged: value.privileged,
bot: value.bot.map(Into::into),
}
}
}
impl From<crate::PartialUser> for PartialUser {
@@ -675,7 +1164,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()),
@@ -745,6 +1233,18 @@ impl From<crate::Presence> for Presence {
}
}
impl From<Presence> for crate::Presence {
fn from(value: Presence) -> crate::Presence {
match value {
Presence::Online => crate::Presence::Online,
Presence::Idle => crate::Presence::Idle,
Presence::Focus => crate::Presence::Focus,
Presence::Busy => crate::Presence::Busy,
Presence::Invisible => crate::Presence::Invisible,
}
}
}
impl From<crate::UserStatus> for UserStatus {
fn from(value: crate::UserStatus) -> Self {
UserStatus {
@@ -754,6 +1254,15 @@ impl From<crate::UserStatus> for UserStatus {
}
}
impl From<UserStatus> for crate::UserStatus {
fn from(value: UserStatus) -> crate::UserStatus {
crate::UserStatus {
text: value.text,
presence: value.presence.map(|presence| presence.into()),
}
}
}
impl From<crate::UserProfile> for UserProfile {
fn from(value: crate::UserProfile) -> Self {
UserProfile {
@@ -763,6 +1272,15 @@ impl From<crate::UserProfile> for UserProfile {
}
}
impl From<UserProfile> for crate::UserProfile {
fn from(value: UserProfile) -> crate::UserProfile {
crate::UserProfile {
content: value.content,
background: value.background.map(|file| file.into()),
}
}
}
impl From<crate::BotInformation> for BotInformation {
fn from(value: crate::BotInformation) -> Self {
BotInformation {
@@ -770,3 +1288,11 @@ impl From<crate::BotInformation> for BotInformation {
}
}
}
impl From<BotInformation> for crate::BotInformation {
fn from(value: BotInformation) -> crate::BotInformation {
crate::BotInformation {
owner: value.owner_id,
}
}
}

View File

@@ -1,15 +1,12 @@
use std::num::NonZeroUsize;
use revolt_result::{create_error, Error, Result};
use revolt_result::{create_error, Result};
#[cfg(feature = "rocket-impl")]
use revolt_result::Error;
use async_std::sync::Mutex;
use once_cell::sync::Lazy;
use revolt_rocket_okapi::gen::OpenApiGenerator;
use revolt_rocket_okapi::request::{OpenApiFromRequest, RequestHeaderInput};
use revolt_rocket_okapi::revolt_okapi::openapi3::{Parameter, ParameterValue};
use rocket::http::Status;
use rocket::request::{FromRequest, Outcome};
use schemars::schema::{InstanceType, SchemaObject, SingleOrVec};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
@@ -42,6 +39,17 @@ impl IdempotencyKey {
}
}
#[cfg(feature = "rocket-impl")]
use revolt_rocket_okapi::{
gen::OpenApiGenerator,
request::{OpenApiFromRequest, RequestHeaderInput},
revolt_okapi::openapi3::{Parameter, ParameterValue},
};
#[cfg(feature = "rocket-impl")]
use schemars::schema::{InstanceType, SchemaObject, SingleOrVec};
#[cfg(feature = "rocket-impl")]
impl<'r> OpenApiFromRequest<'r> for IdempotencyKey {
fn from_request_input(
_gen: &mut OpenApiGenerator,
@@ -71,6 +79,13 @@ impl<'r> OpenApiFromRequest<'r> for IdempotencyKey {
}
}
#[cfg(feature = "rocket-impl")]
use rocket::{
http::Status,
request::{FromRequest, Outcome},
};
#[cfg(feature = "rocket-impl")]
#[async_trait]
impl<'r> FromRequest<'r> for IdempotencyKey {
type Error = Error;

View File

@@ -2,3 +2,4 @@ pub mod bridge;
pub mod idempotency;
pub mod permissions;
pub mod reference;
pub mod test_fixtures;

View File

@@ -1,7 +1,8 @@
use std::borrow::Cow;
use revolt_permissions::{
calculate_user_permissions, ChannelType, Override, PermissionQuery, RelationshipStatus,
calculate_user_permissions, ChannelType, Override, PermissionQuery, PermissionValue,
RelationshipStatus, DEFAULT_PERMISSION_DIRECT_MESSAGE,
};
use crate::{Channel, Database, Member, Server, User};
@@ -19,7 +20,8 @@ pub struct DatabasePermissionQuery<'a> {
member: Option<Cow<'a, Member>>,
// flag_known_relationship: Option<&'a RelationshipStatus>,
cached_user_permission: Option<u32>,
cached_user_permission: Option<PermissionValue>,
cached_mutual_connection: Option<bool>,
cached_permission: Option<u64>,
}
@@ -49,6 +51,10 @@ impl PermissionQuery for DatabasePermissionQuery<'_> {
/// Get the relationship with have with the currently selected user
async fn user_relationship(&mut self) -> RelationshipStatus {
if let Some(other_user) = &self.user {
if self.perspective.id == other_user.id {
return RelationshipStatus::User;
}
if let Some(relations) = &self.perspective.relations {
for entry in relations {
if entry.id == other_user.id {
@@ -82,14 +88,17 @@ impl PermissionQuery for DatabasePermissionQuery<'_> {
/// Do we have a mutual connection with the currently selected user?
async fn have_mutual_connection(&mut self) -> bool {
if let Some(user) = &self.user {
// TODO: cache result?
matches!(
self.perspective
.has_mutual_connection(self.database, &user.id)
.await,
Ok(true)
)
if let Some(value) = self.cached_mutual_connection {
value
} else if let Some(user) = &self.user {
let value = self
.perspective
.has_mutual_connection(self.database, &user.id)
.await
.unwrap_or_default();
self.cached_mutual_connection = Some(value);
matches!(value, true)
} else {
false
}
@@ -111,11 +120,15 @@ impl PermissionQuery for DatabasePermissionQuery<'_> {
if let Some(server) = &self.server {
if self.member.is_some() {
true
} else if let Ok(member) = self
.database
.fetch_member(&server.id, &self.perspective.id)
.await
{
self.member = Some(Cow::Owned(member));
true
} else {
self.database
.fetch_member(&server.id, &self.perspective.id)
.await
.is_ok()
false
}
} else {
false
@@ -196,7 +209,7 @@ impl PermissionQuery for DatabasePermissionQuery<'_> {
match channel {
Cow::Borrowed(Channel::Group { permissions, .. })
| Cow::Owned(Channel::Group { permissions, .. }) => Override {
allow: permissions.unwrap_or_default() as u64,
allow: permissions.unwrap_or(*DEFAULT_PERMISSION_DIRECT_MESSAGE as i64) as u64,
deny: 0,
},
Cow::Borrowed(Channel::TextChannel {
@@ -331,6 +344,23 @@ impl PermissionQuery for DatabasePermissionQuery<'_> {
| Cow::Owned(Channel::TextChannel { server, .. })
| Cow::Borrowed(Channel::VoiceChannel { server, .. })
| Cow::Owned(Channel::VoiceChannel { server, .. }) => {
if let Some(known_server) =
// I'm not sure why I can't just pattern match both at once here?
// It throws some weird error and the provided fix doesn't work :/
if let Some(Cow::Borrowed(known_server)) = self.server {
Some(known_server)
} else if let Some(Cow::Owned(ref known_server)) = self.server {
Some(known_server)
} else {
None
}
{
if server == &known_server.id {
// Already cached, return early.
return;
}
}
if let Ok(server) = self.database.fetch_server(server).await {
self.server.replace(Cow::Owned(server));
}
@@ -352,6 +382,7 @@ impl<'a> DatabasePermissionQuery<'a> {
server: None,
member: None,
cached_mutual_connection: None,
cached_user_permission: None,
cached_permission: None,
}
@@ -413,6 +444,33 @@ impl<'a> DatabasePermissionQuery<'a> {
..self
}
}
/// Access the underlying user
pub fn user_ref(&self) -> &Option<Cow<User>> {
&self.user
}
/// Access the underlying server
pub fn channel_ref(&self) -> &Option<Cow<Channel>> {
&self.channel
}
/// Access the underlying server
pub fn server_ref(&self) -> &Option<Cow<Server>> {
&self.server
}
/// Access the underlying member
pub fn member_ref(&self) -> &Option<Cow<Member>> {
&self.member
}
/// Get the known member's current ranking
pub fn get_member_rank(&self) -> Option<i64> {
self.member
.as_ref()
.map(|member| member.get_ranking(self.server.as_ref().unwrap()))
}
}
/// Short-hand for creating a permission calculator

View File

@@ -1,3 +1,5 @@
use std::str::FromStr;
use revolt_result::Result;
#[cfg(feature = "rocket-impl")]
use rocket::request::FromParam;
@@ -7,7 +9,9 @@ use schemars::{
JsonSchema,
};
use crate::{Bot, Database, Webhook};
use crate::{
Bot, Channel, Database, Emoji, Invite, Member, Message, Server, ServerBan, User, Webhook,
};
/// Reference to some object in the database
#[derive(Serialize, Deserialize)]
@@ -22,11 +26,79 @@ impl Reference {
Reference { id }
}
/// Fetch ban from Ref
pub async fn as_ban(&self, db: &Database, server: &str) -> Result<ServerBan> {
db.fetch_ban(server, &self.id).await
}
/// Fetch bot from Ref
pub async fn as_bot(&self, db: &Database) -> Result<Bot> {
db.fetch_bot(&self.id).await
}
/// Fetch emoji from Ref
pub async fn as_emoji(&self, db: &Database) -> Result<Emoji> {
db.fetch_emoji(&self.id).await
}
/// Fetch channel from Ref
pub async fn as_channel(&self, db: &Database) -> Result<Channel> {
db.fetch_channel(&self.id).await
}
/// Fetch invite from Ref or create invite to server if discoverable
pub async fn as_invite(&self, db: &Database) -> Result<Invite> {
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
pub async fn as_message(&self, db: &Database) -> Result<Message> {
db.fetch_message(&self.id).await
}
/// Fetch message from Ref and validate channel
pub async fn as_message_in_channel(&self, db: &Database, channel: &str) -> Result<Message> {
let msg = db.fetch_message(&self.id).await?;
if msg.channel != channel {
return Err(create_error!(NotFound));
}
Ok(msg)
}
/// Fetch member from Ref
pub async fn as_member(&self, db: &Database, server: &str) -> Result<Member> {
db.fetch_member(server, &self.id).await
}
/// Fetch server from Ref
pub async fn as_server(&self, db: &Database) -> Result<Server> {
db.fetch_server(&self.id).await
}
/// Fetch user from Ref
pub async fn as_user(&self, db: &Database) -> Result<User> {
db.fetch_user(&self.id).await
}
/// Fetch webhook from Ref
pub async fn as_webhook(&self, db: &Database) -> Result<Webhook> {
db.fetch_webhook(&self.id).await

View File

@@ -0,0 +1,103 @@
use std::collections::HashMap;
use once_cell::sync::Lazy;
use regex::{Captures, Regex};
use serde_json::from_str;
use crate::{Channel, Database, Member, Server, User};
static RE_ID: Lazy<Regex> = Lazy::new(|| Regex::new("__ID:(\\d+)__").unwrap());
#[derive(Debug, Deserialize)]
#[serde(tag = "_object_type")]
enum LoadedFixture {
User(User),
Channel(Channel),
Server(Server),
ServerMember(Member),
}
pub async fn load_fixture(db: &Database, input: &str) -> HashMap<String, String> {
let mut ids = HashMap::<String, String>::new();
let input = RE_ID.replace_all(input, |cap: &Captures| {
let d = cap.get(1).unwrap().as_str();
if !ids.contains_key(d) {
ids.insert(d.to_string(), ulid::Ulid::new().to_string());
}
ids.get(d).unwrap().clone()
});
// Deserialise the fixtures
let items: Vec<LoadedFixture> = from_str(&input).expect("Failed to deserialise fixture");
// Load all of the items within
for item in items {
#[allow(clippy::disallowed_methods)]
match item {
LoadedFixture::User(user) => db.insert_user(&user).await.unwrap(),
LoadedFixture::Channel(channel) => db.insert_channel(&channel).await.unwrap(),
LoadedFixture::Server(server) => db.insert_server(&server).await.unwrap(),
LoadedFixture::ServerMember(member) => db.insert_member(&member).await.unwrap(),
}
}
// Return IDs for ease of use
ids
}
#[async_trait]
pub trait FetchFixture {
async fn user(&self, db: &Database, d: usize) -> User;
async fn channel(&self, db: &Database, d: usize) -> Channel;
async fn server(&self, db: &Database, d: usize) -> Server;
async fn member(&self, db: &Database, d_server: usize, d_user: usize) -> Member;
}
#[async_trait]
impl FetchFixture for HashMap<String, String> {
async fn user(&self, db: &Database, d: usize) -> User {
db.fetch_user(self.get(&d.to_string()).unwrap())
.await
.unwrap()
}
async fn channel(&self, db: &Database, d: usize) -> Channel {
db.fetch_channel(self.get(&d.to_string()).unwrap())
.await
.unwrap()
}
async fn server(&self, db: &Database, d: usize) -> Server {
db.fetch_server(self.get(&d.to_string()).unwrap())
.await
.unwrap()
}
async fn member(&self, db: &Database, d_server: usize, d_user: usize) -> Member {
db.fetch_member(
self.get(&d_server.to_string()).unwrap(),
self.get(&d_user.to_string()).unwrap(),
)
.await
.unwrap()
}
}
#[macro_export]
macro_rules! fixture {
( $database:expr, $name:expr, $( $variable:ident $type:ident $id: expr )+ ) => {
use $crate::util::test_fixtures::FetchFixture;
let fixtures = $crate::util::test_fixtures::load_fixture(
&$database,
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/fixtures/", $name, ".json")),
)
.await;
$(
let $variable = fixtures.$type(&$database, $id).await;
)+
};
}

View File

@@ -1,38 +1,42 @@
[package]
name = "revolt-models"
version = "0.6.7"
version = "0.7.9"
edition = "2021"
license = "AGPL-3.0-or-later"
license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"]
description = "Revolt Backend: API Models"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[features]
serde = ["dep:serde", "revolt-permissions/serde"]
serde = ["dep:serde", "revolt-permissions/serde", "indexmap/serde"]
schemas = ["dep:schemars", "revolt-permissions/schemas"]
validator = ["dep:validator"]
rocket = ["dep:rocket"]
partials = ["dep:revolt_optional_struct", "serde", "schemas"]
default = ["serde", "partials"]
default = ["serde", "partials", "rocket"]
[dependencies]
# Core
revolt-config = { version = "0.6.7", path = "../config" }
revolt-permissions = { version = "0.6.7", path = "../permissions" }
revolt-config = { version = "0.7.9", path = "../config" }
revolt-permissions = { version = "0.7.9", path = "../permissions" }
# Utility
regex = "1"
indexmap = "1.9.3"
once_cell = "1.17.1"
# Rocket
rocket = { optional = true, version = "0.5.0-rc.2", default-features = false }
# Serialisation
revolt_optional_struct = { version = "0.2.0", optional = true }
serde = { version = "1", features = ["derive"], optional = true }
iso8601-timestamp = { version = "0.2.11", features = ["schema", "bson"] }
# Spec Generation
schemars = { version = "0.8.8", optional = true }
schemars = { version = "0.8.8", optional = true, features = ["indexmap1"] }
# 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

@@ -30,7 +30,16 @@ macro_rules! auto_derived_partial {
#[derive(
OptionalStruct, Debug, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema,
)]
#[optional_derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
#[optional_derive(
Debug,
Clone,
Eq,
PartialEq,
Serialize,
Deserialize,
JsonSchema,
Default
)]
#[optional_name = $name]
#[opt_skip_serializing_none]
#[opt_some_priority]

View File

@@ -1,7 +1,5 @@
use super::User;
use validator::Validate;
auto_derived!(
/// Bot
#[derive(Default)]
@@ -103,7 +101,7 @@ auto_derived!(
/// Bot Details
#[derive(Default)]
#[cfg_attr(feature = "validator", derive(Validate))]
#[cfg_attr(feature = "validator", derive(validator::Validate))]
pub struct DataCreateBot {
/// Bot username
#[cfg_attr(
@@ -115,7 +113,7 @@ auto_derived!(
/// New Bot Details
#[derive(Default)]
#[cfg_attr(feature = "validator", derive(Validate))]
#[cfg_attr(feature = "validator", derive(validator::Validate))]
pub struct DataEditBot {
/// Bot username
#[cfg_attr(

View File

@@ -1,5 +1,8 @@
use super::{Channel, File, Server, User};
auto_derived!(
/// Invite
#[serde(tag = "type")]
pub enum Invite {
/// Invite to a specific server channel
Server {
@@ -24,4 +27,77 @@ auto_derived!(
channel: String,
},
}
/// Public invite response
#[allow(clippy::large_enum_variant)]
#[serde(tag = "type")]
pub enum InviteResponse {
/// Server channel invite
Server {
/// Invite code
code: String,
/// Id of the server
server_id: String,
/// Name of the server
server_name: String,
/// Attachment for server icon
#[serde(skip_serializing_if = "Option::is_none")]
server_icon: Option<File>,
/// Attachment for server banner
#[serde(skip_serializing_if = "Option::is_none")]
server_banner: Option<File>,
/// Enum of server flags
#[serde(skip_serializing_if = "Option::is_none")]
server_flags: Option<i32>,
/// Id of server channel
channel_id: String,
/// Name of server channel
channel_name: String,
/// Description of server channel
#[serde(skip_serializing_if = "Option::is_none")]
channel_description: Option<String>,
/// Name of user who created the invite
user_name: String,
/// Avatar of the user who created the invite
#[serde(skip_serializing_if = "Option::is_none")]
user_avatar: Option<File>,
/// Number of members in this server
member_count: i64,
},
/// Group channel invite
Group {
/// Invite code
code: String,
/// Id of group channel
channel_id: String,
/// Name of group channel
channel_name: String,
/// Description of group channel
#[serde(skip_serializing_if = "Option::is_none")]
channel_description: Option<String>,
/// Name of user who created the invite
user_name: String,
/// Avatar of the user who created the invite
#[serde(skip_serializing_if = "Option::is_none")]
user_avatar: Option<File>,
},
}
/// Invite join response
#[serde(tag = "type")]
#[allow(clippy::large_enum_variant)]
pub enum InviteJoinResponse {
Server {
/// Channels in the server
channels: Vec<Channel>,
/// Server we are joining
server: Server,
},
Group {
/// Group channel we are joining
channel: Channel,
/// Members of this group
users: Vec<User>,
},
}
);

View File

@@ -1,3 +1,6 @@
#[cfg(feature = "validator")]
use validator::Validate;
use super::File;
auto_derived_partial!(
@@ -69,13 +72,23 @@ auto_derived!(
pub channel_id: String,
/// The permissions for the webhook
pub permissions: u64
pub permissions: u64,
}
/// Optional fields on webhook object
pub enum FieldsWebhook {
Avatar,
}
/// Information for the webhook
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct CreateWebhookBody {
#[validate(length(min = 1, max = 32))]
pub name: String,
#[validate(length(min = 1, max = 128))]
pub avatar: Option<String>,
}
);
impl From<Webhook> for MessageWebhook {
@@ -94,7 +107,7 @@ impl From<Webhook> for ResponseWebhook {
name: value.name,
avatar: value.avatar.map(|file| file.id),
channel_id: value.channel_id,
permissions: value.permissions
permissions: value.permissions,
}
}
}

View File

@@ -1,10 +1,14 @@
use super::File;
use revolt_permissions::OverrideField;
use std::collections::HashMap;
use revolt_permissions::{Override, OverrideField};
use std::collections::{HashMap, HashSet};
#[cfg(feature = "rocket")]
use rocket::FromForm;
auto_derived!(
/// Channel
#[serde(tag = "channel_type")]
pub enum Channel {
/// Personal "Saved Notes" channel which allows users to save messages
SavedMessages {
@@ -205,4 +209,101 @@ auto_derived!(
#[cfg_attr(feature = "serde", serde(default))]
pub remove: Option<Vec<FieldsChannel>>,
}
/// Create new group
#[derive(Default)]
#[cfg_attr(feature = "validator", derive(validator::Validate))]
pub struct DataCreateGroup {
/// Group name
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 32)))]
pub name: String,
/// Group description
#[cfg_attr(feature = "validator", validate(length(min = 0, max = 1024)))]
pub description: Option<String>,
/// Group icon
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 128)))]
pub icon: Option<String>,
/// Array of user IDs to add to the group
///
/// Must be friends with these users.
#[cfg_attr(feature = "validator", validate(length(min = 0, max = 49)))]
#[serde(default)]
pub users: HashSet<String>,
/// Whether this group is age-restricted
#[serde(skip_serializing_if = "Option::is_none")]
pub nsfw: Option<bool>,
}
/// Server Channel Type
#[derive(Default)]
pub enum LegacyServerChannelType {
/// Text Channel
#[default]
Text,
/// Voice Channel
Voice,
}
/// Create new server channel
#[derive(Default)]
#[cfg_attr(feature = "validator", derive(validator::Validate))]
pub struct DataCreateServerChannel {
/// Channel type
#[serde(rename = "type", default = "LegacyServerChannelType::default")]
pub channel_type: LegacyServerChannelType,
/// Channel name
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 32)))]
pub name: String,
/// Channel description
#[cfg_attr(feature = "validator", validate(length(min = 0, max = 1024)))]
pub description: Option<String>,
/// Whether this channel is age restricted
#[serde(skip_serializing_if = "Option::is_none")]
pub nsfw: Option<bool>,
}
/// New default permissions
#[serde(untagged)]
pub enum DataDefaultChannelPermissions {
Value {
/// Permission values to set for members in a `Group`
permissions: u64,
},
Field {
/// Allow / deny values to set for members in this `TextChannel` or `VoiceChannel`
permissions: Override,
},
}
/// New role permissions
pub struct DataSetRolePermissions {
/// Allow / deny values to set for this role
pub permissions: Override,
}
/// Options when deleting a channel
#[cfg_attr(feature = "rocket", derive(FromForm))]
pub struct OptionsChannelDelete {
/// Whether to not send a leave message
pub leave_silently: Option<bool>,
}
/// Voice server token response
pub struct LegacyCreateVoiceUserResponse {
/// Token for authenticating with the voice server
token: String,
}
);
impl Channel {
/// Get a reference to this channel's id
pub fn id(&self) -> &str {
match self {
Channel::DirectMessage { id, .. }
| Channel::Group { id, .. }
| Channel::SavedMessages { id, .. }
| Channel::TextChannel { id, .. }
| Channel::VoiceChannel { id, .. } => id,
}
}
}

View File

@@ -1,3 +1,14 @@
use once_cell::sync::Lazy;
use regex::Regex;
#[cfg(feature = "validator")]
use validator::Validate;
/// Regex for valid emoji names
///
/// Alphanumeric and underscores
pub static RE_EMOJI: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-z0-9_]+$").unwrap());
auto_derived!(
/// Emoji
pub struct Emoji {
@@ -30,4 +41,17 @@ auto_derived!(
Server { id: String },
Detached,
}
/// Create a new emoji
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct DataCreateEmoji {
/// Server name
#[validate(length(min = 1, max = 32), regex = "RE_EMOJI")]
pub name: String,
/// Parent information
pub parent: EmojiParent,
/// Whether the emoji is mature
#[serde(default)]
pub nsfw: bool,
}
);

View File

@@ -1,11 +1,22 @@
use std::time::SystemTime;
use indexmap::{IndexMap, IndexSet};
use once_cell::sync::Lazy;
use regex::Regex;
use revolt_config::config;
use indexmap::{IndexMap, IndexSet};
#[cfg(feature = "validator")]
use validator::Validate;
#[cfg(feature = "rocket")]
use rocket::{FromForm, FromFormField};
use iso8601_timestamp::Timestamp;
use super::{Embed, File, MessageWebhook, User, Webhook};
use super::{Embed, File, Member, MessageWebhook, User, Webhook, RE_COLOUR};
pub static RE_MENTION: Lazy<Regex> =
Lazy::new(|| Regex::new(r"<@([0-9A-HJKMNP-TV-Z]{26})>").unwrap());
auto_derived_partial!(
/// Message
@@ -58,6 +69,24 @@ auto_derived_partial!(
);
auto_derived!(
/// Bulk Message Response
#[serde(untagged)]
pub enum BulkMessageResponse {
JustMessages(
/// List of messages
Vec<Message>,
),
MessagesAndUsers {
/// List of messages
messages: Vec<Message>,
/// List of users
users: Vec<User>,
/// List of members
#[serde(skip_serializing_if = "Option::is_none")]
members: Option<Vec<Member>>,
},
}
/// System Event
#[serde(tag = "type")]
pub enum SystemMessage {
@@ -86,17 +115,21 @@ auto_derived!(
}
/// Name and / or avatar override information
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct Masquerade {
/// Replace the display name shown on this message
#[serde(skip_serializing_if = "Option::is_none")]
#[validate(length(min = 1, max = 32))]
pub name: Option<String>,
/// Replace the avatar shown on this message (URL to image file)
#[serde(skip_serializing_if = "Option::is_none")]
#[validate(length(min = 1, max = 256))]
pub avatar: Option<String>,
/// Replace the display role colour shown on this message
///
/// Must have `ManageRole` permission to use
#[serde(skip_serializing_if = "Option::is_none")]
#[validate(length(min = 1, max = 128), regex = "RE_COLOUR")]
pub colour: Option<String>,
}
@@ -124,6 +157,7 @@ auto_derived!(
///
/// Sort used for retrieving messages
#[derive(Default)]
#[cfg_attr(feature = "rocket", derive(FromFormField))]
pub enum MessageSort {
/// Sort by the most relevant messages
#[default]
@@ -152,6 +186,148 @@ auto_derived!(
/// URL to open when clicking notification
pub url: String,
}
/// Representation of a text embed before it is sent.
#[derive(Default)]
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct SendableEmbed {
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 128)))]
pub icon_url: Option<String>,
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 256)))]
pub url: Option<String>,
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 100)))]
pub title: Option<String>,
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 2000)))]
pub description: Option<String>,
pub media: Option<String>,
#[cfg_attr(
feature = "validator",
validate(length(min = 1, max = 128), regex = "RE_COLOUR")
)]
pub colour: Option<String>,
}
/// What this message should reply to and how
pub struct ReplyIntent {
/// Message Id
pub id: String,
/// Whether this reply should mention the message's author
pub mention: bool,
}
/// Message to send
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct DataMessageSend {
/// Unique token to prevent duplicate message sending
///
/// **This is deprecated and replaced by `Idempotency-Key`!**
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 64)))]
pub nonce: Option<String>,
/// Message content to send
#[cfg_attr(feature = "validator", validate(length(min = 0, max = 2000)))]
pub content: Option<String>,
/// Attachments to include in message
pub attachments: Option<Vec<String>>,
/// Messages to reply to
pub replies: Option<Vec<ReplyIntent>>,
/// Embeds to include in message
///
/// Text embed content contributes to the content length cap
#[cfg_attr(feature = "validator", validate)]
pub embeds: Option<Vec<SendableEmbed>>,
/// Masquerade to apply to this message
#[cfg_attr(feature = "validator", validate)]
pub masquerade: Option<Masquerade>,
/// Information about how this message should be interacted with
pub interactions: Option<Interactions>,
}
/// Options for querying messages
#[cfg_attr(feature = "validator", derive(Validate))]
#[cfg_attr(feature = "rocket", derive(FromForm))]
pub struct OptionsQueryMessages {
/// Maximum number of messages to fetch
///
/// For fetching nearby messages, this is \`(limit + 1)\`.
#[cfg_attr(feature = "validator", validate(range(min = 1, max = 100)))]
pub limit: Option<i64>,
/// Message id before which messages should be fetched
#[cfg_attr(feature = "validator", validate(length(min = 26, max = 26)))]
pub before: Option<String>,
/// Message id after which messages should be fetched
#[cfg_attr(feature = "validator", validate(length(min = 26, max = 26)))]
pub after: Option<String>,
/// Message sort direction
pub sort: Option<MessageSort>,
/// Message id to search around
///
/// Specifying 'nearby' ignores 'before', 'after' and 'sort'.
/// It will also take half of limit rounded as the limits to each side.
/// It also fetches the message ID specified.
#[cfg_attr(feature = "validator", validate(length(min = 26, max = 26)))]
pub nearby: Option<String>,
/// Whether to include user (and member, if server channel) objects
pub include_users: Option<bool>,
}
/// Options for searching for messages
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct DataMessageSearch {
/// Full-text search query
///
/// See [MongoDB documentation](https://docs.mongodb.com/manual/text-search/#-text-operator) for more information.
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 64)))]
pub query: String,
/// Maximum number of messages to fetch
#[cfg_attr(feature = "validator", validate(range(min = 1, max = 100)))]
pub limit: Option<i64>,
/// Message id before which messages should be fetched
#[cfg_attr(feature = "validator", validate(length(min = 26, max = 26)))]
pub before: Option<String>,
/// Message id after which messages should be fetched
#[cfg_attr(feature = "validator", validate(length(min = 26, max = 26)))]
pub after: Option<String>,
/// Message sort direction
///
/// By default, it will be sorted by latest.
#[cfg_attr(feature = "serde", serde(default = "MessageSort::default"))]
pub sort: MessageSort,
/// Whether to include user (and member, if server channel) objects
pub include_users: Option<bool>,
}
/// Changes to make to message
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct DataEditMessage {
/// New message content
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 2000)))]
pub content: Option<String>,
/// Embeds to include in the message
#[cfg_attr(feature = "validator", validate(length(min = 0, max = 10)))]
pub embeds: Option<Vec<SendableEmbed>>,
}
/// Options for bulk deleting messages
#[cfg_attr(
feature = "validator",
cfg_attr(feature = "validator", derive(Validate))
)]
pub struct OptionsBulkDelete {
/// Message IDs
#[validate(length(min = 1, max = 100))]
pub ids: Vec<String>,
}
/// Options for removing reaction
#[cfg_attr(feature = "rocket", derive(FromForm))]
pub struct OptionsUnreact {
/// Remove a specific user's reaction
pub user_id: Option<String>,
/// Remove all reactions
pub remove_all: Option<bool>,
}
);
/// Message Author Abstraction

View File

@@ -7,6 +7,7 @@ mod embeds;
mod emojis;
mod files;
mod messages;
mod safety_reports;
mod server_bans;
mod server_members;
mod servers;
@@ -22,6 +23,7 @@ pub use embeds::*;
pub use emojis::*;
pub use files::*;
pub use messages::*;
pub use safety_reports::*;
pub use server_bans::*;
pub use server_members::*;
pub use servers::*;

View File

@@ -0,0 +1,148 @@
use iso8601_timestamp::Timestamp;
auto_derived!(
/// User-generated platform moderation report
pub struct Report {
/// Unique Id
#[serde(rename = "_id")]
pub id: String,
/// Id of the user creating this report
pub author_id: String,
/// Reported content
pub content: ReportedContent,
/// Additional report context
pub additional_context: String,
/// Status of the report
#[serde(flatten)]
pub status: ReportStatus,
/// Additional notes included on the report
#[serde(default)]
pub notes: String,
}
/// Reason for reporting content (message or server)
pub enum ContentReportReason {
/// No reason has been specified
NoneSpecified,
/// Illegal content catch-all reason
Illegal,
/// Selling or facilitating use of drugs or other illegal goods
IllegalGoods,
/// Extortion or blackmail
IllegalExtortion,
/// Revenge or child pornography
IllegalPornography,
/// Illegal hacking activity
IllegalHacking,
/// Extreme violence, gore, or animal cruelty
/// With exception to violence potrayed in media / creative arts
ExtremeViolence,
/// Content that promotes harm to others / self
PromotesHarm,
/// Unsolicited advertisements
UnsolicitedSpam,
/// This is a raid
Raid,
/// Spam or platform abuse
SpamAbuse,
/// Scams or fraud
ScamsFraud,
/// Distribution of malware or malicious links
Malware,
/// Harassment or abuse targeted at another user
Harassment,
}
/// Reason for reporting a user
pub enum UserReportReason {
/// No reason has been specified
NoneSpecified,
/// Unsolicited advertisements
UnsolicitedSpam,
/// User is sending spam or otherwise abusing the platform
SpamAbuse,
/// User's profile contains inappropriate content for a general audience
InappropriateProfile,
/// User is impersonating another user
Impersonation,
/// User is evading a ban
BanEvasion,
/// User is not of minimum age to use the platform
Underage,
}
/// The content being reported
#[serde(tag = "type")]
pub enum ReportedContent {
/// Report a message
Message {
/// ID of the message
id: String,
/// Reason for reporting message
report_reason: ContentReportReason,
},
/// Report a server
Server {
/// ID of the server
id: String,
/// Reason for reporting server
report_reason: ContentReportReason,
},
/// Report a user
User {
/// ID of the user
id: String,
/// Reason for reporting a user
report_reason: UserReportReason,
/// Message context
message_id: Option<String>,
},
}
/// Status of the report
#[serde(tag = "status")]
pub enum ReportStatus {
/// Report is waiting for triage / action
Created {},
/// Report was rejected
Rejected {
rejection_reason: String,
closed_at: Option<Timestamp>,
},
/// Report was actioned and resolved
Resolved { closed_at: Option<Timestamp> },
}
/// Just the status of the report
pub enum ReportStatusString {
/// Report is waiting for triage / action
Created,
/// Report was rejected
Rejected,
/// Report was actioned and resolved
Resolved,
}
);

View File

@@ -1,4 +1,7 @@
use super::MemberCompositeKey;
use super::{File, MemberCompositeKey, User};
#[cfg(feature = "validator")]
use validator::Validate;
auto_derived!(
/// Server Ban
@@ -9,4 +12,44 @@ auto_derived!(
/// Reason for ban creation
pub reason: Option<String>,
}
/// Information for new server ban
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct DataBanCreate {
/// Ban reason
#[cfg_attr(feature = "validator", validate(length(min = 0, max = 1024)))]
pub reason: Option<String>,
}
/// Just enough information to list a ban
pub struct BannedUser {
/// Id of the banned user
#[cfg_attr(feature = "serde", serde(rename = "_id"))]
pub id: String,
/// Username of the banned user
pub username: String,
/// Discriminator of the banned user
pub discriminator: String,
/// Avatar of the banned user
pub avatar: Option<File>,
}
/// Ban list result
pub struct BanListResult {
/// Users objects
pub users: Vec<BannedUser>,
/// Ban objects
pub bans: Vec<ServerBan>,
}
);
impl From<User> for BannedUser {
fn from(user: User) -> Self {
BannedUser {
id: user.id,
username: user.username,
discriminator: user.discriminator,
avatar: user.avatar,
}
}
}

View File

@@ -1,6 +1,35 @@
use super::File;
use std::collections::HashMap;
use super::{File, Role, User};
use iso8601_timestamp::Timestamp;
use once_cell::sync::Lazy;
use regex::Regex;
#[cfg(feature = "validator")]
use validator::Validate;
#[cfg(feature = "rocket")]
use rocket::FromForm;
/// Regex for valid role colours
///
/// Allows the use of named colours, rgb(a), variables and all gradients.
///
/// Flags:
/// - Case-insensitive (`i`)
///
/// Source:
/// ```regex
/// VALUE = [a-z ]+|var\(--[a-z\d-]+\)|rgba?\([\d, ]+\)|#[a-f0-9]+
/// ADDITIONAL_VALUE = \d+deg
/// STOP = ([ ]+(\d{1,3}%|0))?
///
/// ^(?:VALUE|(repeating-)?(linear|conic|radial)-gradient\((VALUE|ADDITIONAL_VALUE)STOP(,[ ]*(VALUE)STOP)+\))$
/// ```
pub static RE_COLOUR: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)^(?:[a-z ]+|var\(--[a-z\d-]+\)|rgba?\([\d, ]+\)|#[a-f0-9]+|(repeating-)?(linear|conic|radial)-gradient\(([a-z ]+|var\(--[a-z\d-]+\)|rgba?\([\d, ]+\)|#[a-f0-9]+|\d+deg)([ ]+(\d{1,3}%|0))?(,[ ]*([a-z ]+|var\(--[a-z\d-]+\)|rgba?\([\d, ]+\)|#[a-f0-9]+)([ ]+(\d{1,3}%|0))?)+\))$").unwrap()
});
auto_derived_partial!(
/// Server Member
@@ -56,4 +85,46 @@ auto_derived!(
Kick,
Ban,
}
/// Member response
#[serde(untagged)]
pub enum MemberResponse {
Member(Member),
MemberWithRoles {
member: Member,
roles: HashMap<String, Role>,
},
}
/// Options for fetching all members
#[cfg_attr(feature = "rocket", derive(FromForm))]
pub struct OptionsFetchAllMembers {
/// Whether to exclude offline users
pub exclude_offline: Option<bool>,
}
/// Response with all members
pub struct AllMemberResponse {
/// List of members
pub members: Vec<Member>,
/// List of users
pub users: Vec<User>,
}
/// New member information
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct DataMemberEdit {
/// Member nickname
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 32)))]
pub nickname: Option<String>,
/// Attachment Id to set for avatar
pub avatar: Option<String>,
/// Array of role ids
pub roles: Option<Vec<String>>,
/// Timestamp this member is timed out until
pub timeout: Option<Timestamp>,
/// Fields to remove from channel object
#[cfg_attr(feature = "validator", validate(length(min = 1)))]
pub remove: Option<Vec<FieldsMember>>,
}
);

View File

@@ -1,8 +1,14 @@
use super::File;
use super::{Channel, File, RE_COLOUR};
use revolt_permissions::OverrideField;
use revolt_permissions::{Override, OverrideField};
use std::collections::HashMap;
#[cfg(feature = "validator")]
use validator::Validate;
#[cfg(feature = "rocket")]
use rocket::FromForm;
auto_derived_partial!(
/// Server
pub struct Server {
@@ -19,7 +25,7 @@ auto_derived_partial!(
pub description: Option<String>,
/// Channels within this server
// ! FIXME: this may be redundant
// TODO: investigate if this is redundant and can be removed
pub channels: Vec<String>,
/// Categories for this server
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
@@ -117,10 +123,13 @@ auto_derived!(
}
/// Channel category
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct Category {
/// Unique ID for this category
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 32)))]
pub id: String,
/// Title for this category
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 32)))]
pub title: String,
/// Channels in this category
pub channels: Vec<String>,
@@ -141,4 +150,140 @@ auto_derived!(
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub user_banned: Option<String>,
}
/// Information about new server to create
#[derive(Default)]
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct DataCreateServer {
/// Server name
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 32)))]
pub name: String,
/// Server description
#[cfg_attr(feature = "validator", validate(length(min = 0, max = 1024)))]
pub description: Option<String>,
/// Whether this server is age-restricted
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub nsfw: Option<bool>,
}
/// Information about new role to create
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct DataCreateRole {
/// Role name
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 32)))]
pub name: String,
/// Ranking position
///
/// Smaller values take priority.
pub rank: Option<i64>,
}
/// Response after creating new role
pub struct NewRoleResponse {
/// Id of the role
pub id: String,
/// New role
pub role: Role,
}
/// Information returned when creating server
pub struct CreateServerLegacyResponse {
/// Server object
pub server: Server,
/// Default channels
pub channels: Vec<Channel>,
}
/// Options when fetching server
#[cfg_attr(feature = "rocket", derive(FromForm))]
pub struct OptionsFetchServer {
/// Whether to include channels
pub include_channels: Option<bool>,
}
/// Fetch server information
#[serde(untagged)]
pub enum FetchServerResponse {
JustServer(Server),
ServerWithChannels {
#[serde(flatten)]
server: Server,
channels: Vec<Channel>,
},
}
/// New server information
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct DataEditServer {
/// Server name
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 32)))]
pub name: Option<String>,
/// Server description
#[cfg_attr(feature = "validator", validate(length(min = 0, max = 1024)))]
pub description: Option<String>,
/// Attachment Id for icon
pub icon: Option<String>,
/// Attachment Id for banner
pub banner: Option<String>,
/// Category structure for server
#[cfg_attr(feature = "validator", validate)]
pub categories: Option<Vec<Category>>,
/// System message configuration
pub system_messages: Option<SystemMessageChannels>,
/// Bitfield of server flags
#[cfg_attr(feature = "validator", serde(skip_serializing_if = "Option::is_none"))]
pub flags: Option<i32>,
// Whether this server is age-restricted
// nsfw: Option<bool>,
/// Whether this server is public and should show up on [Revolt Discover](https://rvlt.gg)
pub discoverable: Option<bool>,
/// Whether analytics should be collected for this server
///
/// Must be enabled in order to show up on [Revolt Discover](https://rvlt.gg).
pub analytics: Option<bool>,
/// Fields to remove from server object
#[cfg_attr(feature = "validator", validate(length(min = 1)))]
pub remove: Option<Vec<FieldsServer>>,
}
/// New role information
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct DataEditRole {
/// Role name
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 32)))]
pub name: Option<String>,
/// Role colour
#[cfg_attr(
feature = "validator",
validate(length(min = 1, max = 128), regex = "RE_COLOUR")
)]
pub colour: Option<String>,
/// Whether this role should be displayed separately
pub hoist: Option<bool>,
/// Ranking position
///
/// Smaller values take priority.
pub rank: Option<i64>,
/// Fields to remove from role object
#[cfg_attr(feature = "validator", validate(length(min = 1)))]
pub remove: Option<Vec<FieldsRole>>,
}
/// New role permissions
pub struct DataSetServerRolePermission {
/// Allow / deny values for the role in this server.
pub permissions: Override,
}
/// Options when leaving a server
#[cfg_attr(feature = "rocket", derive(FromForm))]
pub struct OptionsServerDelete {
/// Whether to not send a leave message
pub leave_silently: Option<bool>,
}
);

View File

@@ -1,6 +1,26 @@
#[cfg(feature = "rocket")]
use rocket::FromForm;
use std::collections::HashMap;
/// HashMap of user settings
/// Each key is mapped to a tuple consisting of the
/// revision timestamp and serialised data (in JSON format)
pub type UserSettings = HashMap<String, (i64, String)>;
auto_derived!(
/// Options for fetching settings
pub struct OptionsFetchSettings {
/// Keys to fetch
pub keys: Vec<String>,
}
/// Additional options for inserting settings
#[cfg_attr(feature = "rocket", derive(FromForm))]
pub struct OptionsSetSettings {
/// Timestamp of settings change.
///
/// Used to avoid feedback loops.
pub timestamp: Option<i64>,
}
);

View File

@@ -3,12 +3,21 @@ use regex::Regex;
use super::File;
#[cfg(feature = "validator")]
use validator::Validate;
/// Regex for valid usernames
///
/// Block zero width space
/// Block lookalike characters
pub static RE_USERNAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(\p{L}|[\d_.-])+$").unwrap());
/// Regex for valid display names
///
/// Block zero width space
/// Block newline and carriage return
pub static RE_DISPLAY_NAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[^\u200B\n\r]+$").unwrap());
auto_derived_partial!(
/// User
pub struct User {
@@ -41,9 +50,6 @@ auto_derived_partial!(
/// User's current status
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub status: Option<UserStatus>,
/// User's profile page
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub profile: Option<UserProfile>,
/// Enum of user flags
#[cfg_attr(
@@ -123,20 +129,26 @@ auto_derived!(
}
/// User's active status
#[derive(Default)]
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct UserStatus {
/// Custom status text
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "String::is_empty"))]
pub text: String,
#[validate(length(min = 0, max = 128))]
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub text: Option<String>,
/// Current presence option
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub presence: Option<Presence>,
}
/// User's profile
#[derive(Default)]
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct UserProfile {
/// Text content on user's profile
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "String::is_empty"))]
pub content: String,
#[validate(length(min = 0, max = 2000))]
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub content: Option<String>,
/// Background visible on user's profile
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub background: Option<File>,
@@ -182,12 +194,79 @@ auto_derived!(
Spam = 8,
}
/// New user profile data
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct DataUserProfile {
/// Text to set as user profile description
#[cfg_attr(feature = "validator", validate(length(min = 0, max = 2000)))]
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub content: Option<String>,
/// Attachment Id for background
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 128)))]
pub background: Option<String>,
}
/// New user information
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct DataEditUser {
/// New display name
#[cfg_attr(
feature = "validator",
validate(length(min = 2, max = 32), regex = "RE_DISPLAY_NAME")
)]
pub display_name: Option<String>,
/// Attachment Id for avatar
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 128)))]
pub avatar: Option<String>,
/// New user status
#[cfg_attr(feature = "validator", validate)]
pub status: Option<UserStatus>,
/// New user profile data
///
/// This is applied as a partial.
#[cfg_attr(feature = "validator", validate)]
pub profile: Option<DataUserProfile>,
/// Bitfield of user badges
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub badges: Option<i32>,
/// Enum of user flags
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub flags: Option<i32>,
/// Fields to remove from user object
#[cfg_attr(feature = "validator", validate(length(min = 1)))]
pub remove: Option<Vec<FieldsUser>>,
}
/// User flag reponse
pub struct FlagResponse {
/// Flags
pub flags: i32,
}
/// Mutual friends and servers response
pub struct MutualResponse {
/// Array of mutual user IDs that both users are friends with
pub users: Vec<String>,
/// Array of mutual server IDs that both users are in
pub servers: Vec<String>,
}
/// Bot information for if the user is a bot
pub struct BotInformation {
/// Id of the owner of this bot
#[cfg_attr(feature = "serde", serde(rename = "owner"))]
pub owner_id: String,
}
/// User lookup information
pub struct DataSendFriendRequest {
/// Username and discriminator combo separated by #
pub username: String,
}
);
pub trait CheckRelationship {

View File

@@ -1,8 +1,8 @@
[package]
name = "revolt-permissions"
version = "0.6.7"
version = "0.7.9"
edition = "2021"
license = "AGPL-3.0-or-later"
license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"]
description = "Revolt Backend: Permission Logic"
@@ -21,7 +21,7 @@ async-std = { version = "1.8.0", features = ["attributes"] }
[dependencies]
# Core
revolt-result = { version = "0.6.5", path = "../result" }
revolt-result = { version = "0.7.9", path = "../result" }
# Utility
auto_ops = "0.3.0"

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

@@ -5,38 +5,44 @@ use crate::{
};
/// Calculate permissions against a user
pub async fn calculate_user_permissions<P: PermissionQuery>(query: &mut P) -> u32 {
pub async fn calculate_user_permissions<P: PermissionQuery>(query: &mut P) -> PermissionValue {
if query.are_we_privileged().await {
return u32::MAX;
return u64::MAX.into();
}
if query.are_the_users_same().await {
return u32::MAX;
return u64::MAX.into();
}
let mut permissions = 0_u32;
let mut permissions = 0_u64;
match query.user_relationship().await {
RelationshipStatus::Friend => return u32::MAX,
RelationshipStatus::Friend => return u64::MAX.into(),
RelationshipStatus::Blocked | RelationshipStatus::BlockedOther => {
return UserPermission::Access as u32
return (UserPermission::Access as u64).into()
}
RelationshipStatus::Incoming | RelationshipStatus::Outgoing => {
permissions = UserPermission::Access as u32;
permissions = UserPermission::Access as u64;
}
_ => {}
}
if query.have_mutual_connection().await {
permissions = UserPermission::Access + UserPermission::ViewProfile;
permissions = UserPermission::Access as u64 + UserPermission::ViewProfile as u64;
if query.user_is_bot().await || query.are_we_a_bot().await {
permissions += UserPermission::SendMessage as u32;
permissions += UserPermission::SendMessage as u64;
}
permissions
permissions.into()
} else {
permissions
permissions.into()
}
// TODO: add boolean switch for permission for users to globally message a user
// maybe an enum?
// PrivacyLevel { Private, Friends, Mutual, Public, Global }
// TODO: add boolean switch for permission for users to mutually DM a user
}
/// Calculate permissions against a server
@@ -81,9 +87,7 @@ pub async fn calculate_channel_permissions<P: PermissionQuery>(query: &mut P) ->
query.set_recipient_as_user().await;
let permissions = calculate_user_permissions(query).await;
if (permissions & UserPermission::SendMessage as u32)
== UserPermission::SendMessage as u32
{
if permissions.has_user_permission(UserPermission::SendMessage) {
(*DEFAULT_PERMISSION_DIRECT_MESSAGE).into()
} else {
(*DEFAULT_PERMISSION_VIEW_ONLY).into()
@@ -106,7 +110,9 @@ pub async fn calculate_channel_permissions<P: PermissionQuery>(query: &mut P) ->
ChannelType::ServerChannel => {
query.set_server_from_channel().await;
if query.are_we_a_member().await {
if query.are_we_server_owner().await {
ChannelPermission::GrantAllSafe.into()
} else if query.are_we_a_member().await {
let mut permissions = calculate_server_permissions(query).await;
permissions.apply(query.get_default_channel_permissions().await);
@@ -121,7 +127,7 @@ pub async fn calculate_channel_permissions<P: PermissionQuery>(query: &mut P) ->
if !permissions.has_channel_permission(ChannelPermission::ViewChannel) {
permissions.revoke_all();
}
permissions
} else {
0_u64.into()

View File

@@ -8,7 +8,7 @@ pub use server::*;
pub use user::*;
/// Holds a permission value to manipulate.
#[derive(Debug)]
#[derive(Clone, Debug)]
pub struct PermissionValue(u64);
impl PermissionValue {
@@ -39,20 +39,33 @@ impl PermissionValue {
}
/// Check whether certain a permission has been granted
pub fn has(&mut self, v: u64) -> bool {
pub fn has(&self, v: u64) -> bool {
(self.0 & v) == v
}
/// Check whether certain a channel permission has been granted
pub fn has_channel_permission(&mut self, permission: ChannelPermission) -> bool {
/// Check whether certain a user permission has been granted
pub fn has_user_permission(&self, permission: UserPermission) -> bool {
self.has(permission as u64)
}
/// Check whether certain a channel permission has been granted
pub fn has_channel_permission(&self, permission: ChannelPermission) -> bool {
self.has(permission as u64)
}
/// Throw if missing user permission
pub fn throw_if_lacking_user_permission(&self, permission: UserPermission) -> Result<()> {
if self.has_user_permission(permission) {
Ok(())
} else {
Err(create_error!(MissingPermission {
permission: permission.to_string()
}))
}
}
/// Throw if missing channel permission
pub fn throw_if_lacking_channel_permission(
&mut self,
permission: ChannelPermission,
) -> Result<()> {
pub fn throw_if_lacking_channel_permission(&self, permission: ChannelPermission) -> Result<()> {
if self.has_channel_permission(permission) {
Ok(())
} else {
@@ -61,6 +74,35 @@ impl PermissionValue {
}))
}
}
/// Throw an error if we cannot grant permissions on either allows or denies
/// going from the previous given value to the next given value.
///
/// We need to check any:
/// - allows added (permissions now granted)
/// - denies removed (permissions now neutral or granted)
pub async fn throw_permission_override<C>(
&self,
current_value: C,
next_value: &Override,
) -> Result<()>
where
C: Into<Option<Override>>,
{
let current_value = current_value.into();
if let Some(current_value) = current_value {
if !self.has(!current_value.allows() & next_value.allows())
|| !self.has(current_value.denies() & !next_value.denies())
{
return Err(create_error!(CannotGiveMissingPermissions));
}
} else if !self.has(next_value.allows()) {
return Err(create_error!(CannotGiveMissingPermissions));
}
Ok(())
}
}
impl From<i64> for PermissionValue {

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