Compare commits

...

275 Commits

Author SHA1 Message Date
Paul Makles
4c8ea31d98 feat(admin): add global message query route 2023-03-11 17:26:48 +00:00
Paul Makles
92ac86a6bd chore: bump version
fix: separate privileged check when editing server
2023-03-11 17:04:30 +00:00
Paul Makles
13ed69c82a feat: privileged user editing 2023-03-11 16:53:25 +00:00
Paul Makles
b83f6da648 feat: allow editing server flags through edit route 2023-03-11 16:40:23 +00:00
Paul Makles
89f1167239 refactor: unify message query into one method 2023-03-11 16:37:48 +00:00
Paul Makles
82d868751f chore: add privileged override for user permission 2023-03-04 20:45:49 +00:00
Paul Makles
31b9f18921 chore: bump version and publish 0.5.13 2023-03-04 11:19:53 +00:00
Paul Makles
fd80823910 fix: ensure we have send perm to edit message 2023-03-04 11:18:18 +00:00
Paul Makles
6bd8221eda feat(core): add raw db statisics endpoint for admin panel 2023-03-04 11:18:05 +00:00
Paul Makles
ce77e926a5 fix(safety): implement fetch_snapshot fully 2023-03-03 20:14:30 +00:00
Paul Makles
2710edb76b feat(safety): add api route for fetching a report 2023-03-03 08:17:55 +00:00
Paul Makles
8f1ff9e774 feat(safety): add API route for editing report 2023-03-02 12:54:55 +00:00
Paul Makles
c09244039e chore: bump and release version 2023-03-01 19:14:31 +00:00
Paul Makles
bf9108408e feat(safety): fetch additional context for snapshot 2023-03-01 15:09:17 +00:00
Paul Makles
304336d905 feat(safety): fetch reports and fetch snapshot API 2023-03-01 11:22:22 +00:00
Paul Makles
42b4906594 chore: add database migrations for safety 2023-03-01 11:21:58 +00:00
Paul Makles
3038fb230f feat(safety): save surrounding context around messages (closes #226) 2023-02-23 18:12:45 +01:00
Paul Makles
73af105feb feat(safety): add global event for report creation (closes #225) 2023-02-23 17:58:05 +01:00
Paul Makles
2de37b23c1 merge: branch local cleanup branch 2023-02-22 12:45:39 +01:00
Paul Makles
0c75d8c8b3 refactor: remove old .github directories [skip ci] 2023-02-22 12:44:46 +01:00
Paul Makles
be793b1570 chore(ci): update commit author [skip ci] 2023-02-22 12:44:24 +01:00
Paul Makles
5a3a97df34 merge: pull request #223 from revoltchat/insert/feat/user-safety-api 2023-02-22 08:52:37 +00:00
Paul Makles
0c072b01d7 fix(safety): prevent reporting own content or self 2023-02-21 12:42:08 +01:00
Paul Makles
056c0380b2 feat(safety): implement report content API 2023-02-21 12:38:35 +01:00
Paul Makles
49598daf70 fix: port user safety API to new codebase 2023-02-17 09:45:34 +00:00
Paul Makles
0445181d5b merge: branch 'master' into insert/feat/user-safety-api 2023-02-17 09:43:12 +00:00
Paul Makles
e780178cc0 fix(ci): move token definition to correct stage 2023-01-29 16:46:19 +00:00
Paul Makles
c4b9884825 fix(ci): use correct token 2023-01-29 16:36:44 +00:00
Paul Makles
fdab997d17 chore(ci): switch testing to stable Rust 2023-01-29 14:23:56 +00:00
Paul Makles
8e0addc3e0 fix: fallback properly if no VERGEN strings can generate 2023-01-29 14:22:57 +00:00
Paul Makles
0da4baf777 chore: bump version and clean up repo 2023-01-29 14:16:18 +00:00
Paul Makles
c03fcda85b refactor: switch from "rauth" to Authifier crate 2023-01-29 14:14:35 +00:00
Paul Makles
0afbcc065f refactor: clippy warning for empty list 2023-01-27 16:18:00 +00:00
Paul Makles
a406e5504d chore: add support for Streamable in media struct 2023-01-27 16:17:47 +00:00
Zomatree
e8c233f60b feat: concurrently query January for embeds (#221) 2023-01-27 16:06:27 +00:00
insertish
72595f30d4 ci: synced local '.github/workflows/triage_pr.yml' with remote 'workflows/triage_pr.yml'
[skip ci]
2023-01-24 19:43:40 +00:00
insertish
39e345af74 ci: synced local '.github/workflows/triage_issue.yml' with remote 'workflows/triage_issue.yml'
[skip ci]
2023-01-24 19:43:40 +00:00
Paul Makles
e0b918771d chore: ensure restrict_reactions is used in conjunction with reactions list 2023-01-20 18:06:44 +00:00
Paul Makles
7a6bd70dcd feat: add route to fetch user flags
chore: bump rauth to change disabled account behaviour
2023-01-20 17:47:26 +00:00
Paul Makles
c0ef3d295a fix: fallback if vergen cannot generate data 2023-01-20 17:14:25 +00:00
Paul Makles
f5c95f9d12 chore: bump rauth for lockout reset on pwd reset 2023-01-20 15:57:58 +00:00
Paul Makles
fed1da72fd feat: support rauth events
feat: add platform user wipe event
2023-01-20 15:43:45 +00:00
Paul Makles
cf9c0bc5e1 chore: bump rauth 2023-01-20 14:00:19 +00:00
Paul Makles
cc2054838a chore: swap okapi for revolt_okapi 2023-01-20 14:00:19 +00:00
Paul Makles
d9d2735efa fix(ratelimiter): re-calculate reset time
closes #204

Co-authored-by: TheBobBobs <84781603+TheBobBobs@users.noreply.github.com>
2023-01-18 18:26:00 +00:00
Ryan
94b0472934 feat: pass server flags with invite data (#203) 2023-01-18 18:17:59 +00:00
Sophie L
4a7917e9ea feat: enable reactions by default in servers/DMs 2023-01-18 18:17:37 +00:00
Lea
ce6b454874 fix: raise masquerade avatar URL length limit 2023-01-18 18:17:05 +00:00
ToastXC
fc32996b51 chore: remove clippy warnings (#213) 2023-01-18 18:16:27 +00:00
Paul Makles
d74104fae4 feat: initial ideas for content reporting 2023-01-18 12:35:49 +00:00
Paul Makles
32d3683013 fix(ci): re-order docker-compose arguments
chore(ci): prevent running gen on PR
2023-01-14 15:53:09 +00:00
Paul Makles
0cebb69e02 feat: automate generating OpenAPI specification 2023-01-14 15:42:30 +00:00
Paul Makles
117958d8cd feat: include build information in root api call 2023-01-14 15:42:06 +00:00
Paul Makles
c5b823ad13 chore: clippy warnings 2023-01-03 21:06:43 +00:00
Paul Makles
27b4699299 fix: unify foreign user fetch into one method 2023-01-03 21:04:04 +00:00
Paul Makles
ee1c4cc2d5 chore: bump quark
feat: update email templates to include recipient
2022-11-19 16:52:51 +00:00
Paul Makles
0e0daf86f7 feat: update emails to not look garbage 2022-10-23 21:23:29 +01:00
Paul Makles
572e08c733 chore: update rauth 2022-10-23 21:08:08 +01:00
Paul Makles
9011939344 chore: remove .env file 2022-10-23 20:55:45 +01:00
Paul Makles
f4dcd091cc feat: add 'Focus' presence status 2022-09-18 12:12:31 +01:00
Paul Makles
86ad72b426 fix: strip unknown channels from categories (fixes #201) 2022-09-12 21:39:29 +01:00
Paul Makles
044d82d566 fix: use correct validation for masq role colour 2022-09-03 10:06:49 +01:00
Paul Makles
deffe663cb feat: add ability to leave groups / servers silently 2022-09-02 15:32:11 +01:00
Paul Makles
e7f6433d77 fix: actually transfer group ownership 2022-09-01 09:45:17 +01:00
Paul Makles
345e4d5f13 fix: validate categories 2022-09-01 09:41:00 +01:00
Sophie L
81fc3e7760 fix: set the cap to 100 2022-09-01 09:01:43 +01:00
Paul Makles
ca8d2e6118 fix: reaction ordering by using indexmap 2022-08-14 11:47:30 +02:00
Paul Makles
c1227e8e33 fix: check React permission on interactions 2022-08-14 11:31:36 +02:00
Paul Makles
0e0c6f2c3a fix: validate number of reactions on message when adding more 2022-08-13 20:26:09 +02:00
Paul Makles
fa82be74b5 fix: adjust permission checks for member_edit 2022-07-26 14:06:53 +01:00
Paul Makles
8491ced13d fix: ensure list matches 2022-07-18 12:46:33 +01:00
Paul Makles
229d4e2e1d fix: correct ordering for creating ownership change event 2022-07-15 21:46:41 +01:00
Paul Makles
7bdd2d69d6 feat: add message reactions + interactions object 2022-07-15 21:24:49 +01:00
Paul Makles
ab2af9b5e5 feat: add group ownership transfer 2022-07-15 17:58:08 +01:00
Paul Makles
5aebda2d16 chore: restrict emojis to lowercase 2022-07-15 17:57:53 +01:00
Paul Makles
a7273d0556 feat: allow servers to set discoverable value 2022-07-15 17:57:30 +01:00
Paul Makles
72aed64e5b chore: allow banning users not in server 2022-07-15 16:47:27 +01:00
Paul Makles
00ad4d54e7 feat: add nsfw flag to emotes 2022-07-15 16:34:08 +01:00
Paul Makles
cba8c4ef3b fix: add case-insensitive flag to colour regex 2022-07-15 16:13:04 +01:00
Paul Makles
b6e395fe0d fix: use default for roles key 2022-07-15 16:02:58 +01:00
Paul Makles
8d07457c58 chore: make joined_at migration shorter 2022-07-15 16:02:46 +01:00
Paul Makles
f1171e5358 feat: restrict role colours to regex (increase length limit) 2022-07-15 15:36:00 +01:00
Paul Makles
d96c9f62c4 chore: detach emojis on delete 2022-07-15 14:01:59 +01:00
Paul Makles
4f73e43a03 feat: restrict permissions for users in timeout 2022-07-15 13:55:55 +01:00
Paul Makles
741b8ee8fd feat: add role colours to masquerades
feat: add `joined_at` property to members
feat: add `timeout` property to members (non-functional)
2022-07-15 13:42:01 +01:00
Paul Makles
692081b7f0 chore: bump rauth for verification fix 2022-07-14 14:01:10 +01:00
Paul Makles
943b1f08f0 fix: prevent short-circuit on permissions 2022-07-10 18:27:22 +01:00
Paul Makles
0e5c8ed59b chore: bump version 2022-07-09 17:44:05 +01:00
Paul Makles
a7e0c42ee4 feat: add emoji 2022-07-07 13:23:31 +01:00
Paul Makles
386f027a5a chore: deprecate old add friend route 2022-07-07 13:20:03 +01:00
Paul Makles
30c7f553d8 chore: refactor autumn to media 2022-07-06 16:46:18 +01:00
Paul Makles
e291eef364 chore: create entity relationship diagram [skip ci] 2022-07-06 16:43:17 +01:00
Martin Löffler
73443ce883 revert: bump rauth for acc. verification fix
the "fix" caused emails to not send
2022-06-23 01:30:54 +02:00
Paul Makles
47297e243f fix: enforce min. password length
fixes #188
2022-06-21 11:06:02 +01:00
Paul Makles
4188b6d2f2 fix: ignore anchor links when parsing messages
fixes #183
2022-06-21 10:38:59 +01:00
Paul Makles
165380c7b4 chore: bump rauth for acc. verification fix 2022-06-21 10:28:14 +01:00
Paul Makles
49ca24ac9f chore: further restrict usernames
closes #186
2022-06-21 10:27:58 +01:00
Paul Makles
35f956ce7d fix: add separate bucket for default_avatar
closes #166
2022-06-20 11:13:15 +01:00
Paul Makles
4baab5d5d5 feat(messaging): cap total text content to 2k characters 2022-06-20 11:09:14 +01:00
Paul Makles
7fc4fb2df7 fix: rewrite attachment deletion logic 2022-06-20 10:49:09 +01:00
Paul Makles
ef757aa2fb chore: bump quark 2022-06-20 10:27:26 +01:00
Paul Makles
0585dd0c20 fix: consistent username validation across routes
fixes #187
2022-06-20 10:27:22 +01:00
Paul Makles
f96541efab fix: add additional validation on legacy nonce value 2022-06-14 17:35:44 +01:00
Paul Makles
c6414338b6 fix: marking server as read would not mark it as read
fixes #169

Porting code forwards from an older revision of the codebase; https://github.com/revoltchat/backend/blob/0.5.3-alpha.10/src/database/entities/server.rs
2022-06-14 17:32:43 +01:00
Paul Makles
4c4eb60cdb fix: don't allow members to be added more than once
fixes #182
2022-06-14 17:27:15 +01:00
Paul Makles
11d89b3bf0 feat: enable 2FA login 2022-06-12 18:50:30 +01:00
Paul Makles
64bb171cc8 fix: remove test flag from rauth 2022-06-12 18:03:49 +01:00
Paul Makles
6de5ad15c5 chore: bump rauth 2022-06-12 17:48:12 +01:00
Paul Makles
b5ab16d66f fix: bump quark to fix is_disabled check 2022-06-12 17:07:22 +01:00
Paul Makles
b9aad6d38c chore: bump rauth 2022-06-11 20:49:19 +01:00
Paul Makles
ed5c8159e9 chore: bump rauth 2022-06-10 17:47:13 +01:00
Paul Makles
2e6e1fdd44 chore: bump quark; changes deletion period 2022-06-10 17:22:34 +01:00
Paul Makles
cb2e6fb2f7 feat: separate friend request route 2022-06-09 17:02:01 +01:00
Paul Makles
d069acd5a5 chore: expose correct version in api spec 2022-06-09 16:05:04 +01:00
Paul Makles
f8b5c25c17 chore: bump rauth; adds account deletion
closes #159
2022-06-09 16:00:59 +01:00
Paul Makles
80af18761f feat: add further restrictions on usernames 2022-06-09 14:06:13 +01:00
Paul Makles
98e10c3478 fix: bump rauth with assertion fix 2022-06-08 14:28:58 +01:00
Paul Makles
2051c8ce45 chore(refactor): validate further up the code 2022-06-08 14:14:16 +01:00
Paul Makles
0abc12e5fa chore(refactor): process attachments last 2022-06-08 14:12:11 +01:00
Paul Makles
aaceb5bdee fix: sub-validate message structs 2022-06-08 14:11:24 +01:00
Paul Makles
3de7ee4c7b fix: cors needs to be attached 2022-06-08 14:08:33 +01:00
Paul Makles
d660127c14 chore: refactor generic web server code into quark 2022-06-08 14:08:33 +01:00
Paul Makles
7390b3c087 feat: update to rAuth v1 2022-06-08 14:08:33 +01:00
Paul Makles
020f2a1b1a fix: enforce role permissions on member_edit 2022-06-06 11:37:37 +01:00
Paul Makles
3e55ff3577 chore: switch to rocket_empty [skip ci] 2022-06-02 15:21:31 +01:00
Paul Makles
bb70294546 chore: delete stray Cargo.lock [skip ci] 2022-06-02 13:41:17 +01:00
Paul Makles
9c93017b8a chore(ci): upload to ghcr after base image build 2022-06-02 13:16:17 +01:00
Paul Makles
57592c0862 chore(ci): update base image [skip ci] 2022-06-02 13:14:17 +01:00
Paul Makles
f09981695e fix(ci): use matrix.project value correctly 2022-06-02 13:08:08 +01:00
Paul Makles
ad76e85c8d chore: create cla.yml 2022-06-02 12:17:30 +01:00
Paul Makles
a100b7aefa chore: try to fix docker.yaml 2022-06-02 12:17:30 +01:00
Paul Makles
a06ef2ba1a chore: add docker workflow 2022-06-02 12:17:30 +01:00
Paul Makles
6336b254c5 feat: new CI pipeline [skip ci] 2022-06-02 12:17:30 +01:00
Paul Makles
f1dd7d2c78 chore: comment out additional crates for the time being 2022-06-02 12:17:30 +01:00
Paul Makles
ae3ed7c124 chore: general clean up 2022-06-02 12:17:30 +01:00
Paul Makles
5237ecbb90 chore: update README 2022-06-02 12:17:30 +01:00
Paul Makles
2ce610e1e7 chore(monorepo): delta, january, quark 2022-06-02 12:17:30 +01:00
Paul Makles
5d8432e267 chore: remove docker workflow preparing for monorepo 2022-06-01 20:37:47 +01:00
Paul Makles
44311c87be chore: bump quark 2022-05-18 21:48:32 +01:00
Paul Makles
ee9622251a chore: bump quark 2022-05-18 21:27:54 +01:00
Paul Makles
d13573eb45 chore: bump version 2022-05-17 20:31:23 +01:00
Paul Makles
20e93b9838 chore: bump quark and rauth 2022-05-16 19:14:14 +01:00
JandereDev
dc6ce96aa2 fix: uploading server banners was broken 2022-05-15 18:10:45 +01:00
Paul Makles
de5428348c fix: actually set the correct regex 2022-05-09 23:08:17 +01:00
Paul Makles
d7527d9131 chore: loosen username restrictions 2022-05-09 23:03:08 +01:00
Paul Makles
c59c4146c4 chore: bump quark, fixes #156 2022-05-08 10:13:45 +01:00
Paul Makles
bd455b59fb fix: use i64::MAX for default role permission 2022-05-07 17:55:58 +01:00
Paul Makles
d03cb44071 chore: limit deletions to past week 2022-05-07 16:53:15 +01:00
Paul Makles
ac4cedd014 feat: bulk message delete 2022-05-07 16:42:06 +01:00
Paul Makles
09e918d8e0 feat: clean up bot restrictions 2022-05-07 15:20:42 +01:00
Paul Makles
97fef56bad fix: masquerade not being applied to mesages
fixes #153
2022-04-30 16:09:52 +01:00
Paul Makles
408743476b chore: bump quark revision 2022-04-29 17:34:01 +01:00
Paul Makles
fad53ee5f0 chore: bump quark revision 2022-04-29 17:20:04 +01:00
Paul Makles
be9a5dba5b chore: bump quark revision 2022-04-29 17:03:08 +01:00
Paul Makles
c33d387486 merge: pull request #135 from revoltchat/quark 2022-04-29 13:52:51 +01:00
Paul Makles
0651a501f6 fix: short-circuit fetch user route if self 2022-04-29 13:10:52 +01:00
Paul Makles
278d795d50 fix: don't allow mutual route if self 2022-04-29 13:07:33 +01:00
Paul Makles
6281418ecb chore: separate BannedUser mapping 2022-04-29 13:03:48 +01:00
Paul Makles
ae0e5cd7bf chore: bump quark revision 2022-04-28 20:34:00 +01:00
Paul Makles
45a3b4ba15 feat: add asset!() macro to crate 2022-04-28 20:31:40 +01:00
Paul Makles
8b72e2fa0e chore: update all dependencies 2022-04-28 20:31:28 +01:00
Paul Makles
e5af59d307 chore: remove old dependencies 2022-04-28 19:56:12 +01:00
Paul Makles
6a7e7e9501 chore: bump quark revision 2022-04-28 17:55:47 +01:00
Paul Makles
247c059f29 fix(ratelimiter): read CF-Connecting-IP for identifying user 2022-04-28 15:17:04 +01:00
Paul Makles
621dd86845 chore: bump quark 2022-04-24 19:02:50 +01:00
Paul Makles
2b016c34f7 fix: use MONGODB env variable for rauth 2022-04-24 14:01:46 +01:00
Paul Makles
e1169c5335 fix: ensure bot invite route runs correct group member checks 2022-04-24 13:59:07 +01:00
Paul Makles
fad540a87a fix: delete bots; fetch public if owner 2022-04-24 13:45:14 +01:00
Paul Makles
00cb132d29 fix: actually create the server object 2022-04-22 22:08:04 +01:00
Paul Makles
7757769f79 chore: update cargo.lock 2022-04-21 20:44:43 +01:00
Paul Makles
2d10180f08 chore: switch to latest quark revision 2022-04-21 20:44:28 +01:00
Paul Makles
180ac67056 feat: reimplement voice chat 2022-04-21 20:34:43 +01:00
Paul Makles
350bd9c0be feat: add parameter to skip offline users on fetch 2022-04-21 18:16:09 +01:00
Paul Makles
367c4e3260 chore: remove db parameter on ack 2022-04-21 18:15:59 +01:00
Paul Makles
02ba8c7978 chore: clippy 2022-04-21 17:53:16 +01:00
Paul Makles
d7839f1790 feat: allow empty message content when sending embed 2022-04-21 17:52:42 +01:00
Paul Makles
9c71f09a84 chore: split content into content, system
closes #102
2022-04-21 17:45:21 +01:00
Paul Makles
b32503d29f fix: return correct rate limit response 2022-04-21 17:43:27 +01:00
Paul Makles
535665ca1b fix: ignore blocked users when adding mentions
closes #145
2022-04-21 16:50:47 +01:00
Paul Makles
da08a7d722 feat: reimplement system messages 2022-04-21 16:36:59 +01:00
Paul Makles
5ce0c133e8 chore: refactor tasks into quark 2022-04-21 13:52:35 +01:00
Paul Makles
3adbf29060 chore: port ack task 2022-04-17 13:55:22 +01:00
Paul Makles
3d98f5dfbb feat: implement DelayedTask and last_id task 2022-04-17 13:37:07 +01:00
Paul Makles
54b6cf67ab feat(tasks): implement web push 2022-04-17 13:11:29 +01:00
Paul Makles
b4a2e8c366 fix: adjust rate limit buckets for auth_del / swagger 2022-04-17 13:11:13 +01:00
Paul Makles
3e135a1a9c feat: switch to ISO8601 timestamps 2022-04-17 10:41:12 +01:00
Paul Makles
b351ef0332 feat(user dms): open saved messages channel 2022-04-17 10:22:53 +01:00
Paul Makles
87391a49a5 feat: add link embed generation 2022-04-14 20:46:46 +01:00
Paul Makles
d2f0fb3414 feat(api): add full API rate limits 2022-04-14 17:09:16 +01:00
Paul Makles
7ea5146e58 chore: push .env.example 2022-04-12 22:58:30 +01:00
Paul Makles
3d4c40dac0 chore: use latest Quark 2022-04-12 22:56:11 +01:00
Paul Makles
c3a04d02c5 chore: update README 2022-04-12 22:55:00 +01:00
Paul Makles
1dbd66a45c chore: add LICENSE 2022-04-12 22:54:00 +01:00
Paul Makles
10645638a2 feat: use new ServerCreate events 2022-04-09 19:03:25 +01:00
Paul Makles
24def22613 chore: include "code" in invite response 2022-04-09 19:03:14 +01:00
Paul Makles
a2911e42c6 chore: bump dependencies 2022-04-09 14:38:38 +01:00
Paul Makles
558e4b86f1 chore: add debugging profile 2022-04-09 14:38:01 +01:00
Paul Makles
e245b272fa feat: update bot routes to match old spec 2022-03-20 18:47:04 +00:00
Paul Makles
b4d825b9a1 feat: strongly type and remaining routes 2022-03-20 18:14:24 +00:00
Paul Makles
aee6c751c6 fix: strongly type member fetch all route 2022-03-20 18:08:48 +00:00
Paul Makles
0232d721db chore(doc): finish up OpenAPI spec 2022-03-19 18:50:07 +00:00
Paul Makles
8dd9acdb59 chore(doc): implement remainder of documentation 2022-03-19 18:19:24 +00:00
Paul Makles
43c475eabb chore(doc): add API information to spec 2022-03-19 14:24:19 +00:00
Paul Makles
25a52cbbb1 chore(doc): comment channel routes 2022-03-19 14:14:24 +00:00
Paul Makles
14e17b10c0 feat: implement OpenAPI spec for bots and root 2022-03-18 21:22:31 +00:00
Paul Makles
a0926eea32 fix(docker): should copy all assets in 2022-03-12 23:06:03 +00:00
Paul Makles
3ae23af275 chore: update dependencies 2022-03-12 22:51:46 +00:00
Paul Makles
927556041b fix(channel/delete): send out channel update on close DM
(for https://github.com/revoltchat/revite/issues/432)
2022-03-04 16:37:26 +00:00
Paul Makles
bbb368217c feat: update avatars 2022-03-04 15:52:34 +00:00
Paul Makles
f568838ff9 fix: should be foreign 2022-03-03 22:44:55 +00:00
Paul Makles
3c5e494216 chore: return user from change username 2022-03-03 22:44:29 +00:00
Paul Makles
465d766254 chore(refactor): use strong typing for fetch public bot 2022-03-03 20:05:41 +00:00
Paul Makles
8d8bcff31f fix(server_edit): use new style throws 2022-02-24 00:07:31 +00:00
Paul Makles
3df869c21d chore: handle ban check in member creation 2022-02-23 18:08:44 +00:00
Paul Makles
a495a8dfd7 feat(permissions): handle rankings on kick / ban 2022-02-23 18:08:25 +00:00
Paul Makles
0553ce5587 feat(permissions): apply permissions on set channel routes 2022-02-23 17:51:36 +00:00
Paul Makles
fcbaab7bcb feat(server/set_default): prevent users giving perm they don't have 2022-02-23 15:59:21 +00:00
Paul Makles
437a620274 fix: also consider role ranking on setting role perm 2022-02-23 15:58:54 +00:00
Paul Makles
30af92bee4 feat(set server perm): prevent users giving out permissions they don't have 2022-02-23 15:53:58 +00:00
Paul Makles
0ecce98056 feat(roles delete): only allow members to delete roles lower ranking then themself 2022-02-23 14:42:08 +00:00
Paul Makles
04c2f8f293 feat(roles edit): ensure users can't move role above their own ranking 2022-02-23 14:39:09 +00:00
Paul Makles
d45b32bce6 feat(roles create): consider member's ranking and allow specifying custom ranking on creation 2022-02-23 14:34:29 +00:00
Paul Makles
23ec914033 feat: implement new permissions hierarchy 2022-02-23 13:30:01 +00:00
Paul Makles
71c01e0817 fix(bans): fix data format returned 2022-02-22 22:51:39 +00:00
Paul Makles
7b1a3dc7dd fix(join invite): create instead of insert member 2022-02-22 22:51:23 +00:00
Paul Makles
64d1126af9 fix(message delete): throw instead of manual check 2022-02-22 22:51:04 +00:00
Paul Makles
4c40c6c2cc feat(perms): consistently throw correct permissions 2022-02-22 21:41:12 +00:00
Paul Makles
7befd77c5b fix: switch to new permissions enum 2022-02-22 20:34:23 +00:00
Paul Makles
aab85fa143 feat: use auto db configuration 2022-02-21 14:48:36 +00:00
Paul Makles
d7be1db755 fix: make users foreign when fetching member list 2022-02-21 13:01:53 +00:00
Paul Makles
2580cec272 feat: add idempotency tokens back 2022-02-20 10:16:16 +00:00
Paul Makles
489c2bdd87 feat: rewrite requests to include events 2022-02-19 19:49:10 +00:00
Paul Makles
061af1f2c3 chore: update path 2022-02-19 12:20:01 +00:00
Paul Makles
50d9f50c8f chore: commit the things that are to be commited 2022-02-11 23:33:38 +00:00
Paul Makles
28db08ba88 fix: create membership when creating server 2022-02-11 22:27:34 +00:00
Paul Makles
9da51b2d45 fix: fix the thing (it is 10:20pm) 2022-02-11 22:21:37 +00:00
Paul Makles
6a871fa944 feat: implement fetch group invite 2022-02-11 22:16:31 +00:00
Paul Makles
4801b51cef fix: fixes for full mongo implementation 2022-02-11 21:44:59 +00:00
Paul Makles
7a9adb45f7 fix(invites): account for server bans 2022-02-11 12:52:34 +00:00
Paul Makles
f0eb228b19 fix: apply partial to bot object 2022-02-11 12:49:34 +00:00
Paul Makles
5aee381fd6 feat: enable migrations 2022-02-11 12:37:09 +00:00
Paul Makles
249b1161f9 chore: implement invite join and rauth conf 2022-02-11 11:28:06 +00:00
Paul Makles
1988a9501b chore: remove mongodb imports 2022-02-11 11:11:48 +00:00
Paul Makles
6d230e57f5 feat(web push): sub / unsub routes 2022-02-10 22:14:07 +00:00
Paul Makles
9f1eda4af0 feat(servers): role and permission routes 2022-02-10 22:14:00 +00:00
Paul Makles
734dc2bb59 feat(servers): member routes 2022-02-10 22:13:46 +00:00
Paul Makles
e22d991995 feat(servers): ban routes 2022-02-10 22:13:10 +00:00
Paul Makles
37ffde23ce feat(servers): implement create, delete, edit, fetch 2022-02-10 18:30:36 +00:00
Paul Makles
f676071641 feat(sync): implement settings and unreads fetch 2022-02-10 16:55:59 +00:00
Paul Makles
a8d5fcec9e feat(bots): implement bot routes 2022-02-10 15:54:41 +00:00
Paul Makles
d4aff072a8 feat(messages): implement message ack / delete 2022-02-10 14:23:46 +00:00
Paul Makles
57d114fee9 feat(messages): implement message editing 2022-02-10 13:33:13 +00:00
Paul Makles
a4cbbfea51 feat(messages): implement fetch, query, search 2022-02-10 12:53:26 +00:00
Paul Makles
a6cec559b1 feat(messaging): implement fetch and send 2022-02-04 23:33:58 +00:00
Paul Makles
8b4541feb0 chore: format code 2022-02-04 22:19:40 +00:00
Paul Makles
88273021a2 chore: add env variables back 2022-02-04 22:17:49 +00:00
Paul Makles
e367c42dca feat(channels): add / remove group members 2022-02-04 22:11:18 +00:00
Paul Makles
88f811761b feat: implement channel invites, perms, group create, members fetch
chore: when you accidentally run fmt
2022-02-04 21:50:30 +00:00
Paul Makles
b58755dbeb feat(channels): implement fetch, edit, delete 2022-02-04 16:48:32 +00:00
Paul Makles
780b88eb2f feat(onboard): implement onboarding routes 2022-02-04 14:57:10 +00:00
Paul Makles
1aafa51033 feat(users): implement relationships, DMs, proper perms 2022-02-04 13:38:24 +00:00
Paul Makles
63064cc9bb feat: implement edit user / username 2022-02-03 22:52:04 +00:00
Paul Makles
1d5800a3b8 feat(users): implement fetch self / user, open dm 2022-01-31 22:30:02 +00:00
Paul Makles
c1cd4bfa66 feat(routes/users): implement fetch_self 2022-01-31 20:07:36 +00:00
Paul Makles
c39a9917df chore: prototyping on user route 2022-01-31 16:59:21 +00:00
Paul Makles
0fdb749199 chore(thanos): strip down codebase to just API routes 2022-01-27 14:16:30 +00:00
Paul Makles
ab4c3f913e chore: bump and publish 2022-01-15 11:09:05 +00:00
Zomatree
8be51dc4f0 feat(messaging): add sendable embeds (#119) 2022-01-15 10:43:47 +00:00
Paul Makles
96fd18f679 chore: remove limitations on private channels 2022-01-04 20:08:04 +00:00
Paul Makles
2db54cf8de chore: merge local branch 'feat/discoverability' 2022-01-03 15:27:32 +00:00
Paul Makles
c3f092aaef feat(invite): allow direct invitation by id for discoverable servers 2022-01-03 15:26:29 +00:00
Paul Makles
ce68f7eeed fix: don't return all members on message query / search 2022-01-03 15:21:12 +00:00
Paul Makles
139e704746 feat: add discoverability and analytics flags (#130) 2022-01-03 13:12:13 +00:00
Paul Makles
345c4c627c feat: add discoverability and analytics flags 2022-01-03 12:46:10 +00:00
insertish
03ca611553 ci: Synced local '.github/workflows/triage_pr.yml' with remote 'workflows/triage_pr.yml' 2021-12-31 17:32:26 +00:00
409 changed files with 22591 additions and 11638 deletions

View File

@@ -1,3 +1,4 @@
Dockerfile
target
.mongo
.env

79
.env.example Normal file
View File

@@ -0,0 +1,79 @@
# MongoDB URI
MONGODB=mongodb://localhost
# URL to where the Revolt app is publicly accessible
REVOLT_APP_URL=http://local.revolt.chat:5000
# URL to where the API is publicly accessible
REVOLT_PUBLIC_URL=http://local.revolt.chat:8000
VITE_API_URL=http://local.revolt.chat:8000
# URL to where the WebSocket server is publicly accessible
REVOLT_EXTERNAL_WS_URL=ws://local.revolt.chat:9000
# URL to where Autumn is publicly available
AUTUMN_PUBLIC_URL=http://local.revolt.chat:3000
# URL to where January is publicly available
JANUARY_PUBLIC_URL=http://local.revolt.chat:7000
# URL to where Vortex is publicly available
# VOSO_PUBLIC_URL=https://voso.revolt.chat
##
## hCaptcha Settings
##
# If you are sure that you don't want to use hCaptcha, set to 1.
REVOLT_UNSAFE_NO_CAPTCHA=1
# hCaptcha API key
# REVOLT_HCAPTCHA_KEY=0x0000000000000000000000000000000000000000
# hCaptcha site key
# REVOLT_HCAPTCHA_SITEKEY=10000000-ffff-ffff-ffff-000000000001
##
## Email Settings
##
# If you are sure that you don't want to use email verification, set to 1.
REVOLT_UNSAFE_NO_EMAIL=1
# SMTP host
# REVOLT_SMTP_HOST=smtp.example.com
# SMTP username
# REVOLT_SMTP_USERNAME=noreply@example.com
# SMTP password
# REVOLT_SMTP_PASSWORD=CHANGEME
# SMTP From header
# REVOLT_SMTP_FROM=Revolt <noreply@example.com>
##
## Application Settings
##
# Whether to only allow users to sign up if they have an invite code
REVOLT_INVITE_ONLY=0
# Maximum number of people that can be in a group chat
REVOLT_MAX_GROUP_SIZE=150
# VAPID keys for push notifications
# Generate using this guide: https://gitlab.insrt.uk/revolt/delta/-/wikis/vapid
# --> Please replace these keys before going into production! <--
REVOLT_VAPID_PRIVATE_KEY=LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUJSUWpyTWxLRnBiVWhsUHpUbERvcEliYk1yeVNrNXpKYzVYVzIxSjJDS3hvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFWnkrQkg2TGJQZ2hEa3pEempXOG0rUXVPM3pCajRXT1phdkR6ZU00c0pqbmFwd1psTFE0WAp1ZDh2TzVodU94QWhMQlU3WWRldVovWHlBdFpWZmNyQi9BPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo=
REVOLT_VAPID_PUBLIC_KEY=BGcvgR-i2z4IQ5Mw841vJvkLjt8wY-FjmWrw83jOLCY52qcGZS0OF7nfLzuYbjsQISwVO2HXrmf18gLWVX3Kwfw=
##
## Vortex configuration
##
# VOSO_MANAGE_TOKEN=CHANGEME

2
.github/FUNDING.yml vendored
View File

@@ -1,2 +0,0 @@
ko_fi: insertish
custom: https://insrt.uk/donate

36
.github/workflows/cla.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
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

116
.github/workflows/docker.yaml vendored Normal file
View File

@@ -0,0 +1,116 @@
name: Docker Test & Publish
on:
push:
branches:
- "master"
tags:
- "*"
paths-ignore:
- ".github/**"
- "!.github/workflows/docker.yml"
- ".vscode/**"
- ".gitignore"
- "LICENSE"
- "README"
pull_request:
branches:
- "master"
paths:
- "Dockerfile"
workflow_dispatch:
jobs:
base:
runs-on: ubuntu-latest
name: Build base image (amd64)
steps:
# Configure build environment
- name: Checkout
uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
# Authenticate with GHCR
- name: Login to Github Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Build all projects and cache
- name: Build Base Image
uses: docker/build-push-action@v3
with:
context: .
push: true
tags: ghcr.io/revoltchat/base:latest
cache-from: type=gha
cache-to: type=gha,mode=max
publish_amd64:
needs: [base]
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
strategy:
matrix:
project: [delta, bonfire]
name: Build ${{ matrix.project }} image (amd64)
steps:
# Configure build environment
- name: Checkout
uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
# Authenticate with Docker Hub and GHCR
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Github Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Resolve the correct project
- uses: kanga333/variable-mapper@master
id: export
with:
key: "${{ matrix.project }}"
map: |
{
"delta": {
"path": "crates/delta",
"tag": "revoltchat/server"
},
"bonfire": {
"path": "crates/bonfire",
"tag": "revoltchat/bonfire"
}
}
export_to: output
# Configure metadata
- name: Docker meta
id: meta
uses: docker/metadata-action@v3
with:
images: ${{ steps.export.outputs.tag }}, ghcr.io/${{ steps.export.outputs.tag }}
# Build crate image
- name: Publish
uses: docker/build-push-action@v3
with:
context: .
push: true
platforms: linux/amd64
file: ${{ steps.export.outputs.path }}/Dockerfile
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -1,103 +0,0 @@
name: Docker
on:
push:
branches:
- "master"
tags:
- "*"
paths-ignore:
- ".github/**"
- "!.github/workflows/docker.yml"
- ".vscode/**"
- ".gitignore"
- ".gitlab-ci.yml"
- "LICENSE"
- "README"
pull_request:
branches:
- "master"
paths:
- "Dockerfile"
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
architecture: [linux/amd64]
steps:
- name: Checkout
uses: actions/checkout@v2
with:
submodules: "recursive"
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache/${{ matrix.architecture }}
key: ${{ runner.os }}-buildx-${{ matrix.architecture }}-${{ github.sha }}
- name: Build
uses: docker/build-push-action@v2
with:
context: .
platforms: ${{ matrix.architecture }}
cache-from: type=local,src=/tmp/.buildx-cache/${{ matrix.architecture }}
cache-to: type=local,dest=/tmp/.buildx-cache-new/${{ matrix.architecture }},mode=max
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache/${{ matrix.architecture }}
mv /tmp/.buildx-cache-new/${{ matrix.architecture }} /tmp/.buildx-cache/${{ matrix.architecture }}
publish_amd64:
needs: [test]
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v2
with:
submodules: "recursive"
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Cache amd64 Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache/linux/amd64
key: ${{ runner.os }}-buildx-linux/amd64-${{ github.sha }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v3
with:
images: revoltchat/server, ghcr.io/revoltchat/server
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Github Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and publish
uses: docker/build-push-action@v2
with:
context: .
push: true
platforms: linux/amd64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=local,src=/tmp/.buildx-cache/linux/amd64
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache

View File

@@ -1,10 +1,10 @@
name: Rust build and test
name: Rust build, test, and generate specification
on:
push:
branches: [ master ]
branches: [master]
pull_request:
branches: [ master ]
branches: [master]
env:
CARGO_TERM_COLOR: always
@@ -15,19 +15,62 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Install latest nightly
- name: Install latest stable
uses: actions-rs/toolchain@v1
with:
toolchain: nightly
override: true
components: rustfmt, clippy
toolchain: stable
override: true
components: rustfmt, clippy
- name: Run cargo build
uses: actions-rs/cargo@v1
with:
command: build
- name: Run cargo test
uses: actions-rs/cargo@v1
with:
command: test
- name: Copy .env.example
if: github.event_name != 'pull_request'
run: |
cp .env.example .env
- name: Run services in background
if: github.event_name != 'pull_request'
run: |
docker-compose -f docker-compose.db.yml up -d
- name: Start API in background
if: github.event_name != 'pull_request'
run: |
cargo run --bin revolt-delta &
- name: Wait for API to go up
if: github.event_name != 'pull_request'
uses: nev7n/wait_for_response@v1
with:
url: "http://localhost:8000/"
- name: Checkout API repository
if: github.event_name != 'pull_request'
uses: actions/checkout@v3
with:
repository: revoltchat/api
path: api
token: ${{ secrets.PAT }}
- name: Download OpenAPI specification
if: github.event_name != 'pull_request'
run: curl http://localhost:8000/openapi.json -o api/OpenAPI.json
- name: Commit changes
if: github.event_name != 'pull_request'
uses: EndBug/add-and-commit@v4
with:
cwd: "api"
add: "*.json"
author_name: Revolt CI
author_email: revolt-ci@users.noreply.github.com
message: "chore: generate OpenAPI specification"

View File

@@ -15,22 +15,27 @@ jobs:
gh api graphql -f query='
query {
organization(login: "revoltchat"){
projectNext(number: 3) {
projectV2(number: 3) {
id
fields(first:20) {
nodes {
id
name
settings
... on ProjectV2SingleSelectField {
id
name
options {
id
name
}
}
}
}
}
}
}' > project_data.json
echo 'PROJECT_ID='$(jq '.data.organization.projectNext.id' project_data.json) >> $GITHUB_ENV
echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectNext.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV
echo 'TODO_OPTION_ID='$(jq '.data.organization.projectNext.fields.nodes[] | select(.name== "Status") |.settings | fromjson.options[] | select(.name=="Todo") |.id' project_data.json) >> $GITHUB_ENV
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 'TODO_OPTION_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="Todo") |.id' project_data.json) >> $GITHUB_ENV
- name: Add issue to project
env:
@@ -39,11 +44,11 @@ jobs:
run: |
item_id="$( gh api graphql -f query='
mutation($project:ID!, $issue:ID!) {
addProjectNextItem(input: {projectId: $project, contentId: $issue}) {
projectNextItem {
addProjectV2ItemById(input: {projectId: $project, contentId: $issue}) {
item {
id
}
}
}' -f project=$PROJECT_ID -f issue=$ISSUE_ID --jq '.data.addProjectNextItem.projectNextItem.id')"
}' -f project=$PROJECT_ID -f issue=$ISSUE_ID --jq '.data.addProjectV2ItemById.item.id')"
echo 'ITEM_ID='$item_id >> $GITHUB_ENV

View File

@@ -2,7 +2,7 @@ name: Add PR to Board
on:
pull_request_target:
types: [opened]
types: [opened, synchronize, ready_for_review, review_requested]
jobs:
track_pr:
@@ -15,22 +15,27 @@ jobs:
gh api graphql -f query='
query {
organization(login: "revoltchat"){
projectNext(number: 3) {
projectV2(number: 3) {
id
fields(first:20) {
nodes {
id
name
settings
... on ProjectV2SingleSelectField {
id
name
options {
id
name
}
}
}
}
}
}
}' > project_data.json
echo 'PROJECT_ID='$(jq '.data.organization.projectNext.id' project_data.json) >> $GITHUB_ENV
echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectNext.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV
echo 'INCOMING_OPTION_ID='$(jq '.data.organization.projectNext.fields.nodes[] | select(.name== "Status") |.settings | fromjson.options[] | select(.name=="Incoming PRs") |.id' project_data.json) >> $GITHUB_ENV
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
- name: Add PR to project
env:
@@ -39,13 +44,13 @@ jobs:
run: |
item_id="$( gh api graphql -f query='
mutation($project:ID!, $pr:ID!) {
addProjectNextItem(input: {projectId: $project, contentId: $pr}) {
projectNextItem {
addProjectV2ItemById(input: {projectId: $project, contentId: $pr}) {
item {
id
}
}
}' -f project=$PROJECT_ID -f pr=$PR_ID --jq '.data.addProjectNextItem.projectNextItem.id')"
}' -f project=$PROJECT_ID -f pr=$PR_ID --jq '.data.addProjectV2ItemById.item.id')"
echo 'ITEM_ID='$item_id >> $GITHUB_ENV
- name: Set fields
@@ -59,14 +64,16 @@ jobs:
$status_field: ID!
$status_value: String!
) {
set_status: updateProjectNextItemField(input: {
set_status: updateProjectV2ItemFieldValue(input: {
projectId: $project
itemId: $item
fieldId: $status_field
value: $status_value
value: {
singleSelectOptionId: $status_value
}
}) {
projectNextItem {
projectV2Item {
id
}
}
}
}' -f project=$PROJECT_ID -f item=$ITEM_ID -f status_field=$STATUS_FIELD_ID -f status_value=${{ env.INCOMING_OPTION_ID }} --silent

9
.gitignore vendored
View File

@@ -1,7 +1,6 @@
Rocket.toml
/target
/target_backup
**/*.rs.bk
.mongo
target
.data
.env
avatar.png
.vercel

View File

@@ -1,20 +0,0 @@
stages:
- build
# Rocket does not currently compile on stable Rust.
# Once it does, we can uncomment this, and instead
# put `allow-failure: true` on the nightly build.
#
# rust-latest:
# stage: build
# image: rust:latest
# script:
# - cargo build --verbose
# - cargo test --verbose
rust-nightly:
stage: build
image: rustlang/rust:nightly
script:
- cargo build --verbose
- cargo test --verbose

View File

@@ -1,5 +1,4 @@
{
"rust-analyzer.diagnostics.disabled": [
"unresolved-macro-call"
]
"editor.formatOnSave": true,
"rust-analyzer.checkOnSave.command": "clippy"
}

2880
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,65 +1,2 @@
[package]
name = "revolt"
# To help optimise CI and Docker builds.
# Version here is left as 0.0.0, please
# adjust and run ./set_version.sh instead.
version = "0.0.0"
authors = ["Paul Makles <paulmakles@gmail.com>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
# Utility
lru = "0.7.0"
url = "2.2.2"
log = "0.4.11"
dotenv = "0.15.0"
linkify = "0.6.0"
once_cell = "1.4.1"
env_logger = "0.7.1"
lazy_static = "1.4.0"
ctrlc = { version = "3.0", features = ["termination"] }
# Lang. Utilities
regex = "1"
num_enum = "0.5.1"
impl_ops = "0.1.1"
bitfield = "0.13.2"
phf = { version = "0.9.0", features = ["macros"] }
# ID / key generation
ulid = "0.4.1"
nanoid = "0.4.0"
base64 = "0.13.0"
# serde
serde_json = "1.0.57"
serde = { version = "1.0.115", features = ["derive"] }
validator = { version = "0.11", features = ["derive"] }
rmp-serde = { git = "https://github.com/3Hren/msgpack-rust", rev = "5bf2c24203ad422233cf35b7b7bfad9f7e811814" }
# async
futures = "0.3.8"
chrono = "0.4.15"
async-channel = "1.6.1"
reqwest = { version = "0.11.4", features = ["json"] }
async-std = { version = "1.8.0", features = ["tokio1", "tokio02", "attributes"] }
# internal util
web-push = "0.7.2"
many-to-many = "0.1.2"
lettre = "0.10.0-alpha.4"
rauth = { git = "https://github.com/insertish/rauth", rev = "157263ffcbd6cb1073e288b215db227634c4c29a" }
hive_pubsub = { git = "https://gitlab.insrt.uk/insert/hive", rev = "b0f3db9d33990530d7640d4bbb309c8d9eb5c0cf", features = ["redis-backend"] }
# redis
redis = { version = "0.21.2", features = ["async-std-comp"] }
mobc = { version = "0.7.3" }
mobc-redis = { version = "0.7.0", default-features = false, features = ["async-std-comp"] }
# web
async-tungstenite = { version = "0.10.0", features = ["async-std-runtime"] }
rocket = { version = "0.5.0-rc.1", default-features = false, features = ["json"] }
mongodb = { version = "1.2.2", features = ["tokio-runtime"], default-features = false }
rocket_cors = { git = "https://github.com/lawliet89/rocket_cors", rev = "5843861a88958c16bfaa0b40f0d8910772bcd2f6" }
[workspace]
members = ["crates/*"]

View File

@@ -3,23 +3,10 @@ FROM rustlang/rust:nightly-slim AS builder
USER 0:0
WORKDIR /home/rust/src
RUN USER=root cargo new --bin revolt
WORKDIR /home/rust/src/revolt
# Install build requirements
RUN apt-get update && apt-get install -y libssl-dev pkg-config
# Build all crates
COPY Cargo.toml Cargo.lock ./
COPY src/bin/dummy.rs ./src/bin/dummy.rs
RUN apt-get update && apt-get install -y libssl-dev pkg-config && cargo build --release --bin dummy
COPY assets/templates ./assets/templates
COPY src ./src
RUN cargo install --locked --path .
# Bundle Stage
FROM debian:buster-slim
RUN apt-get update && apt-get install -y ca-certificates
COPY --from=builder /usr/local/cargo/bin/revolt ./
COPY assets ./assets
EXPOSE 8000
EXPOSE 9000
ENV ROCKET_ADDRESS 0.0.0.0
ENV ROCKET_PORT 8000
CMD ["./revolt"]
COPY crates ./crates
RUN cargo build --locked --release

10
LICENSE
View File

@@ -1,7 +1,7 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
@@ -629,8 +629,8 @@ to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Server
Copyright (C) 2021 REVOLT
Revolt Project
Copyright (C) 2022 Pawel Makles
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
@@ -643,7 +643,7 @@ the "copyright" line and a pointer to where the full notice is found.
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
@@ -658,4 +658,4 @@ specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
<http://www.gnu.org/licenses/>.

View File

@@ -1,21 +1,17 @@
# Delta
# Revolt Backend
## Description
This is a monorepo for the Revolt backend.
Delta is a blazing fast API server built with Rust for Revolt.
| 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 |-->
**Features:**
- Robust and efficient API routes for running a chat platform.
- Distributed notification system, allowing any node to be seamlessly connected.
- Simple deployment, based mostly on pure Rust code and libraries.
- Hooks up to a MongoDB deployment, provide URI and no extra work needed.
## Stack
- [Rocket](https://rocket.rs/) (REST)
- [Async Tungstenite](https://github.com/sdroege/async-tungstenite) (WebSockets)
- [MongoDB](https://mongodb.com/)
Note: `january`, `autumn`, and `vortex` are yet to be moved into this monorepo.
## Resources
@@ -25,16 +21,6 @@ Delta is a blazing fast API server built with Rust for Revolt.
- [Revolt Testers Server](https://app.revolt.chat/invite/Testers)
- [Contribution Guide](https://developers.revolt.chat/contributing)
## CLI Commands
| Command | Description |
| ------------------ | ----------------------------------------------------------------------------------------- |
| `./publish.sh` | Publish a Docker Image. |
| `./set_version.sh` | Update the version. **Not intended for PR use.** |
| `cargo build` | Build/compile Delta. |
| `cargo run` | Run Delta. |
| `cargo fmt` | Format Delta. Not intended for PR use to avoid accidentally formatting unformatted files. |
## Contributing
The contribution guide is located at [developers.revolt.chat/contributing](https://developers.revolt.chat/contributing).
@@ -42,4 +28,4 @@ Please note that a pull request should only take care of one issue so that we ca
## License
Delta is licensed under the [GNU Affero General Public License v3.0](https://github.com/revoltchat/delta/blob/master/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.

View File

@@ -1,63 +0,0 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en-GB">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Reset your password.</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<style type="text/css">
a[x-apple-data-detectors] {color: inherit !important;}
</style>
</head>
<body style="margin: 0; padding: 0;">
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td style="padding: 20px 0 30px 0;">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="600" style="border-collapse: collapse; border: 1px solid #cccccc;">
<tr>
<td align="center" bgcolor="#ff4654">
<img src="https://revolt.chat/header.png" alt="Revolt logo" width="600" height="168" style="display: block;" />
</td>
</tr>
<tr>
<td bgcolor="#ffffff" style="padding: 40px 30px 40px 30px;">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: collapse;">
<tr>
<td style="color: #153643; font-family: Arial, sans-serif;">
<h1 style="font-size: 24px; margin: 0;">Reset your password!</h1>
</td>
</tr>
<tr>
<td style="color: #153643; font-family: Arial, sans-serif; font-size: 16px; line-height: 24px; padding: 20px 0 0 0;">
<p>
You requested a password reset, if you didn't perform this action you can safely ignore this email.
</p>
<p style="margin: 0;">
Reset your password by navigating to <a href="{{url}}">{{url}}</a>.
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td bgcolor="#ff4654" style="padding: 30px 30px;">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: collapse;">
<tr>
<td style="color: #ffffff; font-family: Arial, sans-serif; font-size: 14px;">
<p style="margin: 0;">Sent by Revolt. &middot; Website: <a style="color: white;" href="https://revolt.chat">https://revolt.chat</a></p>
<p>Revolt is a user-first chat platform built with modern web technologies.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,14 +0,0 @@
<h2>Reset your password.</h2>
<p>
You requested a password reset, if you did not perform this action you can safely ignore this email.
</p>
<p>
Reset your password here: <a href="{{url}}">{{url}}</a>
</p>
<br/>
<p>
Sent by Revolt. · Website: <a href="https://revolt.chat">https://revolt.chat</a>
</p>
<p>
Revolt is a user-first chat platform built with modern web technologies.
</p>

View File

@@ -1,63 +0,0 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en-GB">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Verify your account.</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<style type="text/css">
a[x-apple-data-detectors] {color: inherit !important;}
</style>
</head>
<body style="margin: 0; padding: 0;">
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td style="padding: 20px 0 30px 0;">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="600" style="border-collapse: collapse; border: 1px solid #cccccc;">
<tr>
<td align="center" bgcolor="#ff4654">
<img src="https://revolt.chat/header.png" alt="Revolt logo" width="600" height="168" style="display: block;" />
</td>
</tr>
<tr>
<td bgcolor="#ffffff" style="padding: 40px 30px 40px 30px;">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: collapse;">
<tr>
<td style="color: #153643; font-family: Arial, sans-serif;">
<h1 style="font-size: 24px; margin: 0;">You're almost there!</h1>
</td>
</tr>
<tr>
<td style="color: #153643; font-family: Arial, sans-serif; font-size: 16px; line-height: 24px; padding: 20px 0 0 0;">
<p>
Verify your account to be able to log into the platform. If you didn't perform this action you can safely ignore this email.
</p>
<p style="margin: 0;">
Please verify your account by navigating to <a href="{{url}}">{{url}}</a>.
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td bgcolor="#ff4654" style="padding: 30px 30px;">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: collapse;">
<tr>
<td style="color: #ffffff; font-family: Arial, sans-serif; font-size: 14px;">
<p style="margin: 0;">Sent by Revolt. &middot; Website: <a style="color: white;" href="https://revolt.chat">https://revolt.chat</a></p>
<p>Revolt is a user-first chat platform built with modern web technologies.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,6 +0,0 @@
You're almost there!
If you did not perform this action you can safely ignore this email.
Please verify your account here: {{url}}
Sent by Revolt.

View File

@@ -1,15 +0,0 @@
<h2>You're almost there!</h2>
<p>
Verify your account to be able to log into the platform.<br/>
If you did not perform this action you can safely ignore this email.
</p>
<p>
Please verify your account here: <a href="{{url}}">{{url}}</a>
</p>
<br/>
<p>
Sent by Revolt. · Website: <a href="https://revolt.chat">https://revolt.chat</a>
</p>
<p>
Revolt is a user-first chat platform built with modern web technologies.
</p>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

1
crates/bonfire/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

28
crates/bonfire/Cargo.toml Normal file
View File

@@ -0,0 +1,28 @@
[package]
name = "revolt-bonfire"
version = "0.5.16"
license = "AGPL-3.0-or-later"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
# util
log = "*"
once_cell = "1.9.0"
# parsing
querystring = "1.1.0"
# quark
revolt-quark = { path = "../quark" }
# serde
serde_json = "1.0.79"
rmp-serde = "1.0.0"
serde = "1.0.136"
# async
futures = "0.3.21"
async-tungstenite = { version = "0.17.0", features = ["async-std-runtime"] }
async-std = { version = "1.8.0", features = ["tokio1", "tokio02", "attributes"] }

10
crates/bonfire/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
# Build Stage
FROM ghcr.io/revoltchat/base:latest AS builder
RUN cargo install --locked --path crates/bonfire
# Bundle Stage
FROM debian:buster-slim
RUN apt-get update && apt-get install -y ca-certificates
COPY --from=builder /usr/local/cargo/bin/revolt-bonfire ./
EXPOSE 9000
CMD ["./revolt-bonfire"]

1
crates/bonfire/LICENSE Symbolic link
View File

@@ -0,0 +1 @@
../../LICENSE

View File

@@ -0,0 +1,150 @@
use async_tungstenite::tungstenite::{handshake, Message};
use futures::channel::oneshot::Sender;
use revolt_quark::{Error, Result};
use serde::{Deserialize, Serialize};
/// Enumeration of supported protocol formats
#[derive(Debug)]
pub enum ProtocolFormat {
Json,
Msgpack,
}
/// User-provided protocol configuration
#[derive(Debug)]
pub struct ProtocolConfiguration {
protocol_version: i32,
format: ProtocolFormat,
session_token: Option<String>,
}
impl ProtocolConfiguration {
/// Create a new protocol configuration object from provided data
pub fn from(
protocol_version: i32,
format: ProtocolFormat,
session_token: Option<String>,
) -> Self {
Self {
protocol_version,
format,
session_token,
}
}
/// Decode some WebSocket message into a T: Deserialize using the client's specified protocol format
pub fn decode<'a, T: Deserialize<'a>>(&self, msg: &'a Message) -> Result<T> {
match self.format {
ProtocolFormat::Json => {
if let Message::Text(text) = msg {
serde_json::from_str(text).map_err(|_| Error::InternalError)
} else {
Err(Error::InternalError)
}
}
ProtocolFormat::Msgpack => {
if let Message::Binary(buf) = msg {
rmp_serde::from_slice(buf).map_err(|_| Error::InternalError)
} else {
Err(Error::InternalError)
}
}
}
}
/// Encode T: Serialize into a WebSocket message using the client's specified protocol format
pub fn encode<T: Serialize>(&self, data: &T) -> Message {
match self.format {
ProtocolFormat::Json => {
Message::Text(serde_json::to_string(data).expect("Failed to serialise (as json)."))
}
ProtocolFormat::Msgpack => Message::Binary(
rmp_serde::to_vec_named(data).expect("Failed to serialise (as msgpack)."),
),
}
}
/// Set the current session token
pub fn set_session_token(&mut self, token: String) {
self.session_token.replace(token);
}
/// Get the current session token
pub fn get_session_token(&self) -> &Option<String> {
&self.session_token
}
/// Get the protocol version specified
pub fn get_protocol_version(&self) -> i32 {
self.protocol_version
}
/// Get the protocol format specified
pub fn get_protocol_format(&self) -> &ProtocolFormat {
&self.format
}
}
/// Object holding one side of a channel for receiving the parsed information
pub struct WebsocketHandshakeCallback {
sender: Sender<ProtocolConfiguration>,
}
impl WebsocketHandshakeCallback {
/// Create a callback using a given sender
pub fn from(sender: Sender<ProtocolConfiguration>) -> Self {
Self { sender }
}
}
impl handshake::server::Callback for WebsocketHandshakeCallback {
/// Handle request to create a new WebSocket connection
fn on_request(
self,
request: &handshake::server::Request,
response: handshake::server::Response,
) -> Result<handshake::server::Response, handshake::server::ErrorResponse> {
// Take and parse query parameters from the URI.
let query = request.uri().query().unwrap_or_default();
let params = querystring::querify(query);
// Set default values for the protocol.
let mut protocol_version = 1;
let mut format = ProtocolFormat::Json;
let mut session_token = None;
// Parse and map parameters from key-value to known variables.
for (key, value) in params {
match key {
"version" => {
if let Ok(version) = value.parse() {
protocol_version = version;
}
}
"format" => match value {
"json" => format = ProtocolFormat::Json,
"msgpack" => format = ProtocolFormat::Msgpack,
_ => {}
},
"token" => session_token = Some(value.into()),
_ => {}
}
}
// Send configuration information back from this callback.
// We have to use a channel as this function does not borrow mutably.
if self
.sender
.send(ProtocolConfiguration {
protocol_version,
format,
session_token,
})
.is_ok()
{
Ok(response)
} else {
Err(handshake::server::ErrorResponse::new(None))
}
}
}

View File

@@ -0,0 +1,19 @@
use once_cell::sync::OnceCell;
use revolt_quark::{Database, DatabaseInfo};
static DBCONN: OnceCell<Database> = OnceCell::new();
/// Connect Bonfire to the database.
pub async fn connect() {
let database = DatabaseInfo::Auto
.connect()
.await
.expect("Failed to connect to the database.");
DBCONN.set(database).expect("Setting `Database`");
}
/// Get a reference to the current database.
pub fn get_db() -> &'static Database {
DBCONN.get().expect("Valid `Database`")
}

View File

@@ -0,0 +1,34 @@
use std::env;
use async_std::net::TcpListener;
use revolt_quark::presence::presence_clear_region;
#[macro_use]
extern crate log;
pub mod config;
mod database;
mod websocket;
#[async_std::main]
async fn main() {
// Configure requirements for Bonfire.
revolt_quark::configure!();
database::connect().await;
// Clean up the current region information.
presence_clear_region(None).await;
// Setup a TCP listener to accept WebSocket connections on.
// By default, we bind to port 9000 on all interfaces.
let bind = env::var("HOST").unwrap_or_else(|_| "0.0.0.0:9000".into());
info!("Listening on host {bind}");
let try_socket = TcpListener::bind(bind).await;
let listener = try_socket.expect("Failed to bind");
// 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);
}
}

View File

@@ -0,0 +1,253 @@
use std::net::SocketAddr;
use futures::{channel::oneshot, pin_mut, select, FutureExt, SinkExt, StreamExt, TryStreamExt};
use revolt_quark::{
events::{
client::EventV1,
server::ClientMessage,
state::{State, SubscriptionStateChange},
},
models::{user::UserHint, User},
presence::{presence_create_session, presence_delete_session},
redis_kiss, Database,
};
use async_std::{net::TcpStream, sync::Mutex, task};
use crate::config::WebsocketHandshakeCallback;
/// Spawn 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:?}");
// 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()
);
// Split the socket for simultaneously read and write.
let (write, mut read) = ws.split();
let write = Mutex::new(write);
// 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;
}
}
}
// 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);
// Create local state.
let mut state = State::from(user);
let user_id = state.cache.user_id.clone();
// Create presence session.
let (first_session, session_id) =
presence_create_session(&user_id, 0).await;
// 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(|item| {
(
item.get_channel_name().to_string(),
redis_kiss::decode_payload::<EventV1>(&item),
)
}) {
Some((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}!");
}
}
// 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 = presence_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();
}
}
}
}
// * Disconnect the WebSocket if it isn't already.
/*ws.close(Some(CloseFrame {
code: CloseCode::Normal,
reason: std::borrow::Cow::from(""),
}))
.await
.unwrap();*/
}
info!("User disconnected from {addr:?}");
});
}

65
crates/delta/Cargo.toml Normal file
View File

@@ -0,0 +1,65 @@
[package]
name = "revolt-delta"
version = "0.5.16"
license = "AGPL-3.0-or-later"
authors = ["Paul Makles <paulmakles@gmail.com>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
# Utility
lru = "0.7.0"
url = "2.2.2"
log = "0.4.11"
dotenv = "0.15.0"
dashmap = "5.2.0"
linkify = "0.6.0"
once_cell = "1.4.1"
env_logger = "0.7.1"
lazy_static = "1.4.0"
# Lang. Utilities
regex = "1"
num_enum = "0.5.1"
impl_ops = "0.1.1"
bitfield = "0.13.2"
# ID / key generation
ulid = "0.4.1"
nanoid = "0.4.0"
# serde
serde_json = "1.0.57"
serde = { version = "1.0.115", features = ["derive"] }
validator = { version = "0.14", features = ["derive"] }
# async
futures = "0.3.8"
chrono = "0.4.15"
async-channel = "1.6.1"
reqwest = { version = "0.11.4", features = ["json"] }
async-std = { version = "1.8.0", features = ["tokio1", "tokio02", "attributes"] }
# internal util
lettre = "0.10.0-alpha.4"
# redis
redis = { version = "0.21.2", features = ["async-std-comp"] }
mobc = { version = "0.7.3" }
mobc-redis = { version = "0.7.0", default-features = false, features = ["async-std-comp"] }
# web
rocket = { version = "0.5.0-rc.2", default-features = false, features = ["json"] }
rocket_empty = { version = "0.1.1", features = ["schema"] }
rocket_authifier = { version = "1.0.7" }
# spec generation
schemars = "0.8.8"
revolt_rocket_okapi = { version = "0.9.1", features = [ "swagger" ] }
# quark
revolt-quark = { path = "../quark" }
[build-dependencies]
vergen = "7.5.0"

13
crates/delta/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
# Build Stage
FROM ghcr.io/revoltchat/base:latest AS builder
RUN cargo install --locked --path crates/delta
# Bundle Stage
FROM debian:buster-slim
RUN apt-get update && apt-get install -y ca-certificates
COPY --from=builder /usr/local/cargo/bin/revolt-delta ./
EXPOSE 8000
ENV ROCKET_ADDRESS 0.0.0.0
ENV ROCKET_PORT 8000
CMD ["./revolt-delta"]

1
crates/delta/LICENSE Symbolic link
View File

@@ -0,0 +1 @@
../../LICENSE

16
crates/delta/build.rs Normal file
View File

@@ -0,0 +1,16 @@
use vergen::{vergen, Config};
use std::process::Command;
fn main() {
if let Ok(output) = Command::new("git")
.args(["config", "--get", "remote.origin.url"])
.output()
{
if let Ok(git_origin) = String::from_utf8(output.stdout) {
println!("cargo:rustc-env=GIT_ORIGIN_URL={}", git_origin);
}
}
vergen(Config::default()).ok();
}

73
crates/delta/src/main.rs Normal file
View File

@@ -0,0 +1,73 @@
#[macro_use]
extern crate rocket;
#[macro_use]
extern crate revolt_rocket_okapi;
#[macro_use]
extern crate serde_json;
#[macro_use]
extern crate lazy_static;
pub mod routes;
pub mod util;
use async_std::channel::unbounded;
use revolt_quark::authifier::{Authifier, AuthifierEvent};
use revolt_quark::events::client::EventV1;
use revolt_quark::DatabaseInfo;
#[launch]
async fn rocket() -> _ {
// Configure logging and environment
revolt_quark::configure!();
// Ensure environment variables are present
revolt_quark::variables::delta::preflight_checks();
// Setup database
let db = DatabaseInfo::Auto.connect().await.unwrap();
db.migrate_database().await.unwrap();
// Setup Authifier event channel
let (sender, receiver) = unbounded();
// Setup Authifier
let authifier = Authifier {
database: db.clone().into(),
config: revolt_quark::util::authifier::config(),
event_channel: Some(sender),
};
// Launch a listener for Authifier events
async_std::task::spawn(async move {
while let Ok(event) = receiver.recv().await {
match &event {
AuthifierEvent::CreateSession { .. } | AuthifierEvent::CreateAccount { .. } => {
EventV1::Auth(event).global().await
}
AuthifierEvent::DeleteSession { user_id, .. }
| AuthifierEvent::DeleteAllSessions { user_id, .. } => {
let id = user_id.to_string();
EventV1::Auth(event).private(id).await
}
}
}
});
// Launch background task workers
async_std::task::spawn(revolt_quark::tasks::start_workers(db.clone()));
// Configure CORS
let cors = revolt_quark::web::cors::new();
// Configure Rocket
let rocket = rocket::build();
routes::mount(rocket)
.mount("/", revolt_quark::web::cors::catch_all_options_routes())
.mount("/", revolt_quark::web::ratelimiter::routes())
.mount("/swagger/", revolt_quark::web::swagger::routes())
.manage(authifier)
.manage(db)
.manage(cors.clone())
.attach(revolt_quark::web::ratelimiter::RatelimitFairing)
.attach(cors)
}

View File

@@ -0,0 +1,31 @@
use revolt_quark::{
models::{
message::{BulkMessageResponse, MessageQuery},
User,
},
Db, Error, Result,
};
use rocket::serde::json::Json;
/// # Globally Fetch Messages
///
/// This is a privileged route to globally fetch messages.
#[openapi(tag = "Admin")]
#[post("/messages", data = "<data>")]
pub async fn message_query(
db: &Db,
user: User,
data: Json<MessageQuery>,
) -> Result<Json<BulkMessageResponse>> {
// Must be privileged for this route
if !user.privileged {
return Err(Error::NotPrivileged);
}
// Fetch data using query
let data = data.into_inner();
let messages = db.fetch_messages(data).await?;
BulkMessageResponse::transform(db, None, messages, Some(true))
.await
.map(Json)
}

View File

@@ -0,0 +1,9 @@
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
use rocket::Route;
mod message_query;
mod stats;
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![stats::stats, message_query::message_query]
}

View File

@@ -0,0 +1,13 @@
use revolt_quark::models::stats::Stats;
use revolt_quark::{Db, Result};
use rocket::serde::json::Json;
/// # Query Stats
///
/// Fetch various technical statistics.
#[openapi(tag = "Admin")]
#[get("/stats")]
pub async fn stats(db: &Db) -> Result<Json<Stats>> {
Ok(Json(db.generate_stats().await?))
}

View File

@@ -0,0 +1,65 @@
use crate::util::regex::RE_USERNAME;
use nanoid::nanoid;
use revolt_quark::{
models::{user::BotInformation, Bot, User},
variables::delta::MAX_BOT_COUNT,
Db, Error, Result,
};
use rocket::serde::json::Json;
use serde::Deserialize;
use ulid::Ulid;
use validator::Validate;
/// # Bot Details
#[derive(Validate, Deserialize, JsonSchema)]
pub struct DataCreateBot {
/// Bot username
#[validate(length(min = 2, max = 32), regex = "RE_USERNAME")]
name: String,
}
/// # Create Bot
///
/// Create a new Revolt bot.
#[openapi(tag = "Bots")]
#[post("/create", data = "<info>")]
pub async fn create_bot(db: &Db, user: User, info: Json<DataCreateBot>) -> Result<Json<Bot>> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let info = info.into_inner();
info.validate()
.map_err(|error| Error::FailedValidation { error })?;
if db.get_number_of_bots_by_user(&user.id).await? >= *MAX_BOT_COUNT {
return Err(Error::ReachedMaximumBots);
}
if db.is_username_taken(&info.name).await? {
return Err(Error::UsernameTaken);
}
let id = Ulid::new().to_string();
let bot_user = User {
id: id.clone(),
username: info.name.trim().to_string(),
bot: Some(BotInformation {
owner: user.id.clone(),
}),
..Default::default()
};
let bot = Bot {
id,
owner: user.id,
token: nanoid!(64),
..Default::default()
};
db.insert_user(&bot_user).await?;
db.insert_bot(&bot).await?;
Ok(Json(bot))
}

View File

@@ -0,0 +1,19 @@
use revolt_quark::{models::User, Db, EmptyResponse, Error, Ref, Result};
/// # Delete Bot
///
/// Delete a bot by its id.
#[openapi(tag = "Bots")]
#[delete("/<target>")]
pub async fn delete_bot(db: &Db, user: User, target: Ref) -> Result<EmptyResponse> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let bot = target.as_bot(db).await?;
if bot.owner != user.id {
return Err(Error::NotFound);
}
bot.delete(db).await.map(|_| EmptyResponse)
}

View File

@@ -0,0 +1,107 @@
use crate::util::regex::RE_USERNAME;
use revolt_quark::{
models::{
bot::{FieldsBot, PartialBot},
Bot, User,
},
Db, Error, Ref, Result,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use validator::Validate;
/// # Bot Details
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct DataEditBot {
/// Bot username
#[validate(length(min = 2, max = 32), regex = "RE_USERNAME")]
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
/// Whether the bot can be added by anyone
public: Option<bool>,
/// Whether analytics should be gathered for this bot
///
/// Must be enabled in order to show up on [Revolt Discover](https://rvlt.gg).
analytics: Option<bool>,
/// Interactions URL
#[validate(length(min = 1, max = 2048))]
interactions_url: Option<String>,
/// Fields to remove from bot object
#[validate(length(min = 1))]
remove: Option<Vec<FieldsBot>>,
}
/// # Edit Bot
///
/// Edit bot details by its id.
#[openapi(tag = "Bots")]
#[patch("/<target>", data = "<data>")]
pub async fn edit_bot(
db: &Db,
user: User,
target: Ref,
data: Json<DataEditBot>,
) -> Result<Json<Bot>> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let data = data.into_inner();
data.validate()
.map_err(|error| Error::FailedValidation { error })?;
let mut bot = target.as_bot(db).await?;
if bot.owner != user.id {
return Err(Error::NotFound);
}
if let Some(name) = data.name {
if db.is_username_taken(&name).await? {
return Err(Error::UsernameTaken);
}
let mut user = db.fetch_user(&bot.id).await?;
user.update_username(db, name).await?;
}
if data.public.is_none()
&& data.analytics.is_none()
&& data.interactions_url.is_none()
&& data.remove.is_none()
{
return Ok(Json(bot));
}
let DataEditBot {
public,
analytics,
interactions_url,
remove,
..
} = data;
let mut partial = PartialBot {
public,
analytics,
interactions_url,
..Default::default()
};
if let Some(remove) = &remove {
for field in remove {
bot.remove(field);
}
if remove.iter().any(|x| x == &FieldsBot::Token) {
partial.token = Some(bot.token.clone());
}
}
db.update_bot(&bot.id, &partial, remove.unwrap_or_default())
.await?;
bot.apply_options(partial);
Ok(Json(bot))
}

View File

@@ -0,0 +1,36 @@
use revolt_quark::{
models::{Bot, User},
Db, Error, Ref, Result,
};
use rocket::serde::json::Json;
use serde::Serialize;
/// # Bot Response
#[derive(Serialize, JsonSchema)]
pub struct BotResponse {
/// Bot object
bot: Bot,
/// User object
user: User,
}
/// # Fetch Bot
///
/// Fetch details of a bot you own by its id.
#[openapi(tag = "Bots")]
#[get("/<target>")]
pub async fn fetch_bot(db: &Db, user: User, target: Ref) -> Result<Json<BotResponse>> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let bot = target.as_bot(db).await?;
if bot.owner != user.id {
return Err(Error::NotFound);
}
Ok(Json(BotResponse {
user: db.fetch_user(&bot.id).await?.foreign(),
bot,
}))
}

View File

@@ -0,0 +1,42 @@
use revolt_quark::{
models::{Bot, User},
Db, Error, Result,
};
use rocket::serde::json::Json;
use serde::Serialize;
/// # Owned Bots Response
///
/// Both lists are sorted by their IDs.
#[derive(Serialize, JsonSchema)]
pub struct OwnedBotsResponse {
/// Bot objects
bots: Vec<Bot>,
/// User objects
users: Vec<User>,
}
/// # Fetch Owned Bots
///
/// Fetch all of the bots that you have control over.
#[openapi(tag = "Bots")]
#[get("/@me")]
pub async fn fetch_owned_bots(db: &Db, user: User) -> Result<Json<OwnedBotsResponse>> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let mut bots = db.fetch_bots_by_user(&user.id).await?;
let user_ids = bots
.iter()
.map(|x| x.id.to_owned())
.collect::<Vec<String>>();
let mut users = db.fetch_users(&user_ids).await?;
// Ensure the lists match up exactly.
bots.sort_by(|a, b| a.id.cmp(&b.id));
users.sort_by(|a, b| a.id.cmp(&b.id));
Ok(Json(OwnedBotsResponse { users, bots }))
}

View File

@@ -0,0 +1,44 @@
use revolt_quark::{
models::{File, User},
Db, Error, Ref, Result,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
/// # Public Bot
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct PublicBot {
/// Bot Id
#[serde(rename = "_id")]
id: String,
/// Bot Username
username: String,
/// Profile Avatar
#[serde(skip_serializing_if = "Option::is_none")]
avatar: Option<File>,
/// Profile Description
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
}
/// # Fetch Public Bot
///
/// Fetch details of a public (or owned) bot by its id.
#[openapi(tag = "Bots")]
#[get("/<target>/invite")]
pub async fn fetch_public_bot(db: &Db, user: Option<User>, target: Ref) -> Result<Json<PublicBot>> {
let bot = target.as_bot(db).await?;
if !bot.public && user.map_or(true, |x| x.id != bot.owner) {
return Err(Error::NotFound);
}
let user = db.fetch_user(&bot.id).await?;
Ok(Json(PublicBot {
id: bot.id,
username: user.username,
avatar: user.avatar,
description: user.profile.and_then(|p| p.content),
}))
}

View File

@@ -0,0 +1,71 @@
use revolt_quark::{models::User, perms, Db, EmptyResponse, Error, Permission, Ref, Result};
use rocket::serde::json::Json;
use serde::Deserialize;
/// # Invite Destination
#[derive(Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum InviteBotDestination {
/// Invite to a server
Server {
/// Server Id
server: String,
},
/// Invite to a group
Group {
/// Group Id
group: String,
},
}
/// # Invite Bot
///
/// Invite a bot to a server or group by its id.`
#[openapi(tag = "Bots")]
#[post("/<target>/invite", data = "<dest>")]
pub async fn invite_bot(
db: &Db,
user: User,
target: Ref,
dest: Json<InviteBotDestination>,
) -> Result<EmptyResponse> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let bot = target.as_bot(db).await?;
if !bot.public && bot.owner != user.id {
return Err(Error::BotIsPrivate);
}
match dest.into_inner() {
InviteBotDestination::Server { server } => {
let server = db.fetch_server(&server).await?;
perms(&user)
.server(&server)
.throw_permission(db, Permission::ManageServer)
.await?;
let user = db.fetch_user(&bot.id).await?;
server
.create_member(db, user, None)
.await
.map(|_| EmptyResponse)
}
InviteBotDestination::Group { group } => {
let mut channel = db.fetch_channel(&group).await?;
perms(&user)
.channel(&channel)
.throw_permission_and_view_channel(db, Permission::InviteOthers)
.await?;
channel
.add_user_to_group(db, &bot.id, &user.id)
.await
.map(|_| EmptyResponse)
}
}
}

View File

@@ -1,15 +1,16 @@
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
use rocket::Route;
mod create;
mod invite;
mod fetch_public;
mod delete;
mod edit;
mod fetch;
mod fetch_owned;
mod edit;
mod delete;
mod fetch_public;
mod invite;
pub fn routes() -> Vec<Route> {
routes![
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![
create::create_bot,
invite::invite_bot,
fetch_public::fetch_public_bot,

View File

@@ -0,0 +1,23 @@
use revolt_quark::{models::User, perms, Db, EmptyResponse, Error, Permission, Ref, Result};
/// # Acknowledge Message
///
/// Lets the server and all other clients know that we've seen this message id in this channel.
#[openapi(tag = "Messaging")]
#[put("/<target>/ack/<message>")]
pub async fn req(db: &Db, user: User, target: Ref, message: Ref) -> Result<EmptyResponse> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let channel = target.as_channel(db).await?;
perms(&user)
.channel(&channel)
.throw_permission(db, Permission::ViewChannel)
.await?;
channel
.ack(&user.id, &message.id)
.await
.map(|_| EmptyResponse)
}

View File

@@ -0,0 +1,60 @@
use revolt_quark::{
models::{channel::PartialChannel, Channel, User},
perms, Db, EmptyResponse, Error, Permission, Ref, Result,
};
use serde::{Deserialize, Serialize};
use validator::Validate;
/// # Query Parameters
#[derive(Validate, Serialize, Deserialize, JsonSchema, FromForm)]
pub struct OptionsChannelDelete {
/// Whether to not send a leave message
leave_silently: Option<bool>,
}
/// # Close Channel
///
/// Deletes a server channel, leaves a group or closes a group.
#[openapi(tag = "Channel Information")]
#[delete("/<target>?<options..>")]
pub async fn req(
db: &Db,
user: User,
target: Ref,
options: OptionsChannelDelete,
) -> Result<EmptyResponse> {
let mut channel = target.as_channel(db).await?;
let mut perms = perms(&user).channel(&channel);
perms.throw_permission(db, Permission::ViewChannel).await?;
match &channel {
Channel::SavedMessages { .. } => Err(Error::NoEffect),
Channel::DirectMessage { .. } => channel
.update(
db,
PartialChannel {
active: Some(false),
..Default::default()
},
vec![],
)
.await
.map(|_| EmptyResponse),
Channel::Group { .. } => channel
.remove_user_from_group(
db,
&user.id,
None,
options.leave_silently.unwrap_or_default(),
)
.await
.map(|_| EmptyResponse),
Channel::TextChannel { .. } | Channel::VoiceChannel { .. } => {
perms
.throw_permission(db, Permission::ManageChannel)
.await?;
channel.delete(db).await.map(|_| EmptyResponse)
}
}
}

View File

@@ -0,0 +1,210 @@
use revolt_quark::{
models::{
channel::{Channel, FieldsChannel, PartialChannel},
message::SystemMessage,
File, User,
},
perms, Database, Error, Permission, Ref, Result,
};
use rocket::{serde::json::Json, State};
use serde::{Deserialize, Serialize};
use validator::Validate;
/// # Channel Details
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct DataEditChannel {
/// Channel name
#[validate(length(min = 1, max = 32))]
name: Option<String>,
/// Channel description
#[validate(length(min = 0, max = 1024))]
description: Option<String>,
/// Group owner
owner: Option<String>,
/// Icon
///
/// Provide an Autumn attachment Id.
#[validate(length(min = 1, max = 128))]
icon: Option<String>,
/// Whether this channel is age-restricted
nsfw: Option<bool>,
/// Whether this channel is archived
archived: Option<bool>,
#[validate(length(min = 1))]
remove: Option<Vec<FieldsChannel>>,
}
/// # Edit Channel
///
/// Edit a channel object by its id.
#[openapi(tag = "Channel Information")]
#[patch("/<target>", data = "<data>")]
pub async fn req(
db: &State<Database>,
user: User,
target: Ref,
data: Json<DataEditChannel>,
) -> Result<Json<Channel>> {
let data = data.into_inner();
data.validate()
.map_err(|error| Error::FailedValidation { error })?;
let mut channel = target.as_channel(db).await?;
perms(&user)
.channel(&channel)
.throw_permission_and_view_channel(db, Permission::ManageChannel)
.await?;
if data.name.is_none()
&& data.description.is_none()
&& data.icon.is_none()
&& data.nsfw.is_none()
&& data.owner.is_none()
&& data.remove.is_none()
{
return Ok(Json(channel));
}
let mut partial: PartialChannel = Default::default();
// Transfer group ownership
if let Some(new_owner) = data.owner {
if let Channel::Group {
owner, recipients, ..
} = &mut channel
{
// Make sure we are the owner of this group
if owner != &user.id {
return Err(Error::NotOwner);
}
// Ensure user is part of group
if !recipients.contains(&new_owner) {
return Err(Error::NotInGroup);
}
// Transfer ownership
partial.owner = Some(new_owner.to_string());
let old_owner = std::mem::replace(owner, new_owner.to_string());
// Notify clients
SystemMessage::ChannelOwnershipChanged {
from: old_owner,
to: new_owner,
}
} else {
return Err(Error::InvalidOperation);
}
.into_message(channel.id().to_string())
.create(db, &channel, None)
.await
.ok();
}
match &mut channel {
Channel::Group {
id,
name,
description,
icon,
nsfw,
..
}
| Channel::TextChannel {
id,
name,
description,
icon,
nsfw,
..
}
| Channel::VoiceChannel {
id,
name,
description,
icon,
nsfw,
..
} => {
if let Some(fields) = &data.remove {
if fields.contains(&FieldsChannel::Icon) {
if let Some(icon) = &icon {
db.mark_attachment_as_deleted(&icon.id).await?;
}
}
for field in fields {
match field {
FieldsChannel::Description => {
description.take();
}
FieldsChannel::Icon => {
icon.take();
}
_ => {}
}
}
}
if let Some(icon_id) = data.icon {
partial.icon = Some(File::use_icon(db, &icon_id, id).await?);
*icon = partial.icon.clone();
}
if let Some(new_name) = data.name {
*name = new_name.clone();
partial.name = Some(new_name);
}
if let Some(new_description) = data.description {
partial.description = Some(new_description);
*description = partial.description.clone();
}
if let Some(new_nsfw) = data.nsfw {
*nsfw = new_nsfw;
partial.nsfw = Some(new_nsfw);
}
// Send out mutation system messages.
if let Channel::Group { .. } = &channel {
if let Some(name) = &partial.name {
SystemMessage::ChannelRenamed {
name: name.to_string(),
by: user.id.clone(),
}
.into_message(channel.id().to_string())
.create(db, &channel, None)
.await
.ok();
}
if partial.description.is_some() {
SystemMessage::ChannelDescriptionChanged {
by: user.id.clone(),
}
.into_message(channel.id().to_string())
.create(db, &channel, None)
.await
.ok();
}
if partial.icon.is_some() {
SystemMessage::ChannelIconChanged { by: user.id }
.into_message(channel.id().to_string())
.create(db, &channel, None)
.await
.ok();
}
}
channel
.update(db, partial, data.remove.unwrap_or_default())
.await?;
}
_ => return Err(Error::InvalidOperation),
};
Ok(Json(channel))
}

View File

@@ -0,0 +1,21 @@
use revolt_quark::{
models::{Channel, User},
perms, Database, Permission, Ref, Result,
};
use rocket::{serde::json::Json, State};
/// # Fetch Channel
///
/// Fetch channel by its id.
#[openapi(tag = "Channel Information")]
#[get("/<target>")]
pub async fn req(db: &State<Database>, user: User, target: Ref) -> Result<Json<Channel>> {
let channel = target.as_channel(db).await?;
perms(&user)
.channel(&channel)
.throw_permission(db, Permission::ViewChannel)
.await?;
Ok(Json(channel))
}

View File

@@ -0,0 +1,32 @@
use revolt_quark::{
models::{Channel, User},
perms, Db, EmptyResponse, Error, Permission, Ref, Result,
};
/// # Add Member to Group
///
/// Adds another user to the group.
#[openapi(tag = "Groups")]
#[put("/<target>/recipients/<member>")]
pub async fn req(db: &Db, user: User, target: Ref, member: Ref) -> Result<EmptyResponse> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let mut channel = target.as_channel(db).await?;
perms(&user)
.channel(&channel)
.throw_permission_and_view_channel(db, Permission::InviteOthers)
.await?;
match &channel {
Channel::Group { .. } => {
let member = member.as_user(db).await?;
channel
.add_user_to_group(db, &member.id, &user.id)
.await
.map(|_| EmptyResponse)
}
_ => Err(Error::InvalidOperation),
}
}

View File

@@ -1,44 +1,57 @@
use crate::database::*;
use crate::util::idempotency::IdempotencyKey;
use crate::util::result::{Error, Result};
use crate::util::variables::MAX_GROUP_SIZE;
use std::{collections::HashSet, iter::FromIterator};
use mongodb::bson::doc;
use rocket::serde::json::{Json, Value};
use revolt_quark::{
get_relationship,
models::{user::RelationshipStatus, Channel, User},
variables::delta::MAX_GROUP_SIZE,
Db, Error, Result,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::iter::FromIterator;
use ulid::Ulid;
use validator::Validate;
#[derive(Validate, Serialize, Deserialize)]
pub struct Data {
/// # Group Data
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct DataCreateGroup {
/// Group name
#[validate(length(min = 1, max = 32))]
name: String,
/// Group description
#[validate(length(min = 0, max = 1024))]
description: Option<String>,
/// Array of user IDs to add to the group
///
/// Must be friends with these users.
#[validate(length(min = 0, max = 49))]
users: Vec<String>,
/// Whether this group is age-restricted
#[serde(skip_serializing_if = "Option::is_none")]
nsfw: Option<bool>
nsfw: Option<bool>,
}
/// # Create Group
///
/// Create a new group channel.
#[openapi(tag = "Groups")]
#[post("/create", data = "<info>")]
pub async fn req(_idempotency: IdempotencyKey, user: User, info: Json<Data>) -> Result<Value> {
pub async fn req(db: &Db, user: User, info: Json<DataCreateGroup>) -> Result<Json<Channel>> {
if user.bot.is_some() {
return Err(Error::IsBot)
return Err(Error::IsBot);
}
let info = info.into_inner();
info.validate()
.map_err(|error| Error::FailedValidation { error })?;
let mut set: HashSet<String> = HashSet::from_iter(info.users.iter().cloned());
let mut set: HashSet<String> = HashSet::from_iter(info.users.into_iter());
set.insert(user.id.clone());
if set.len() > *MAX_GROUP_SIZE {
Err(Error::GroupTooLarge {
return Err(Error::GroupTooLarge {
max: *MAX_GROUP_SIZE,
})?
});
}
for target in &set {
@@ -50,20 +63,22 @@ pub async fn req(_idempotency: IdempotencyKey, user: User, info: Json<Data>) ->
}
}
let id = Ulid::new().to_string();
let channel = Channel::Group {
id,
let group = Channel::Group {
id: Ulid::new().to_string(),
name: info.name,
description: info.description,
owner: user.id,
description: info.description,
recipients: set.into_iter().collect::<Vec<String>>(),
icon: None,
last_message_id: None,
permissions: None,
nsfw: info.nsfw.unwrap_or_default()
nsfw: info.nsfw.unwrap_or(false),
};
channel.clone().publish().await?;
Ok(json!(channel))
group.create(db).await?;
Ok(Json(group))
}

View File

@@ -0,0 +1,42 @@
use revolt_quark::{
models::{Channel, User},
Db, EmptyResponse, Error, Permission, Ref, Result,
};
/// # Remove Member from Group
///
/// Removes a user from the group.
#[openapi(tag = "Groups")]
#[delete("/<target>/recipients/<member>")]
pub async fn req(db: &Db, user: User, target: Ref, member: Ref) -> Result<EmptyResponse> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let channel = target.as_channel(db).await?;
match &channel {
Channel::Group {
owner, recipients, ..
} => {
if &user.id != owner {
return Error::from_permission(Permission::ManageChannel);
}
let member = member.as_user(db).await?;
if user.id == member.id {
return Err(Error::CannotRemoveYourself);
}
if !recipients.iter().any(|x| *x == member.id) {
return Err(Error::NotInGroup);
}
channel
.remove_user_from_group(db, &member.id, Some(&user.id), false)
.await
.map(|_| EmptyResponse)
}
_ => Err(Error::InvalidOperation),
}
}

View File

@@ -0,0 +1,27 @@
use revolt_quark::{
models::{Invite, User},
perms, Db, Error, Permission, Ref, Result,
};
use rocket::serde::json::Json;
/// # Create Invite
///
/// Creates an invite to this channel.
///
/// Channel must be a `TextChannel`.
#[openapi(tag = "Channel Invites")]
#[post("/<target>/invites")]
pub async fn req(db: &Db, user: User, target: Ref) -> Result<Json<Invite>> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let channel = target.as_channel(db).await?;
perms(&user)
.channel(&channel)
.throw_permission_and_view_channel(db, Permission::InviteOthers)
.await?;
Invite::create(db, &user, &channel).await.map(Json)
}

View File

@@ -0,0 +1,35 @@
use revolt_quark::{
models::{Channel, User},
perms, Db, Error, Permission, Ref, Result,
};
use rocket::serde::json::Json;
/// # Fetch Group Members
///
/// Retrieves all users who are part of this group.
#[openapi(tag = "Groups")]
#[get("/<target>/members")]
pub async fn req(db: &Db, user: User, target: Ref) -> Result<Json<Vec<User>>> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let channel = target.as_channel(db).await?;
perms(&user)
.channel(&channel)
.throw_permission(db, Permission::ViewChannel)
.await?;
if let Channel::Group { recipients, .. } = channel {
Ok(Json(
db.fetch_users(&recipients)
.await?
.into_iter()
.map(|x| x.with_relationship(&user))
.collect::<Vec<User>>(),
))
} else {
Err(Error::InvalidOperation)
}
}

View File

@@ -0,0 +1,59 @@
use chrono::Utc;
use revolt_quark::{
models::{Message, User},
perms, Db, EmptyResponse, Error, Permission, Ref, Result,
};
use rocket::serde::json::Json;
use serde::Deserialize;
use validator::Validate;
/// # Search Parameters
#[derive(Validate, Deserialize, JsonSchema)]
pub struct OptionsBulkDelete {
/// Message IDs
#[validate(length(min = 1, max = 100))]
ids: Vec<String>,
}
/// # Bulk Delete Messages
///
/// Delete multiple messages you've sent or one you have permission to delete.
///
/// This will always require `ManageMessages` permission regardless of whether you own the message or not.
///
/// Messages must have been sent within the past 1 week.
#[openapi(tag = "Messaging")]
#[delete("/<target>/messages/bulk", data = "<options>", rank = 1)]
pub async fn req(
db: &Db,
user: User,
target: Ref,
options: Json<OptionsBulkDelete>,
) -> Result<EmptyResponse> {
let options = options.into_inner();
options
.validate()
.map_err(|error| Error::FailedValidation { error })?;
for id in &options.ids {
if ulid::Ulid::from_string(id)
.map_err(|_| Error::InvalidOperation)?
.datetime()
.signed_duration_since(Utc::now())
.num_days()
.abs()
> 7
{
return Err(Error::InvalidOperation);
}
}
perms(&user)
.channel(&target.as_channel(db).await?)
.throw_permission(db, Permission::ManageMessages)
.await?;
Message::bulk_delete(db, &target.id, options.ids)
.await
.map(|_| EmptyResponse)
}

View File

@@ -0,0 +1,34 @@
use revolt_quark::{
models::{message::PartialMessage, User},
perms, Db, EmptyResponse, Permission, Ref, Result,
};
/// # Remove All Reactions from Message
///
/// Remove your own, someone else's or all of a given reaction.
///
/// Requires `ManageMessages` permission.
#[openapi(tag = "Interactions")]
#[delete("/<target>/messages/<msg>/reactions")]
pub async fn clear_reactions(db: &Db, user: User, target: Ref, msg: Ref) -> Result<EmptyResponse> {
let channel = target.as_channel(db).await?;
perms(&user)
.channel(&channel)
.throw_permission_and_view_channel(db, Permission::ManageMessages)
.await?;
// Fetch relevant message
let mut message = msg.as_message_in(db, channel.id()).await?;
// Clear reactions
message
.update(
db,
PartialMessage {
reactions: Some(Default::default()),
..Default::default()
},
)
.await
.map(|_| EmptyResponse)
}

View File

@@ -0,0 +1,22 @@
use revolt_quark::{models::User, perms, Db, EmptyResponse, Error, Permission, Ref, Result};
/// # Delete Message
///
/// Delete a message you've sent or one you have permission to delete.
#[openapi(tag = "Messaging")]
#[delete("/<target>/messages/<msg>", rank = 2)]
pub async fn req(db: &Db, user: User, target: Ref, msg: Ref) -> Result<EmptyResponse> {
let message = msg.as_message(db).await?;
if message.channel != target.id {
return Err(Error::NotFound);
}
if message.author != user.id {
perms(&user)
.channel(&target.as_channel(db).await?)
.throw_permission(db, Permission::ManageMessages)
.await?;
}
message.delete(db).await.map(|_| EmptyResponse)
}

View File

@@ -0,0 +1,103 @@
use revolt_quark::{
models::message::{PartialMessage, SendableEmbed},
models::{Message, User},
perms,
types::january::Embed,
Db, Error, Permission, Ref, Result, Timestamp,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use validator::Validate;
/// # Message Details
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct DataEditMessage {
/// New message content
#[validate(length(min = 1, max = 2000))]
content: Option<String>,
/// Embeds to include in the message
#[validate(length(min = 0, max = 10))]
embeds: Option<Vec<SendableEmbed>>,
}
/// # Edit Message
///
/// Edits a message that you've previously sent.
#[openapi(tag = "Messaging")]
#[patch("/<target>/messages/<msg>", data = "<edit>")]
pub async fn req(
db: &Db,
user: User,
target: Ref,
msg: Ref,
edit: Json<DataEditMessage>,
) -> Result<Json<Message>> {
let edit = edit.into_inner();
edit.validate()
.map_err(|error| Error::FailedValidation { error })?;
// Ensure we have permissions to send a message
let channel = target.as_channel(db).await?;
let mut permissions = perms(&user).channel(&channel);
permissions
.throw_permission_and_view_channel(db, Permission::SendMessage)
.await?;
Message::validate_sum(&edit.content, &edit.embeds)?;
let mut message = msg.as_message(db).await?;
if message.channel != channel.id() {
return Err(Error::NotFound);
}
if message.author != user.id {
return Err(Error::CannotEditMessage);
}
message.edited = Some(Timestamp::now_utc());
let mut partial = PartialMessage {
edited: message.edited,
..Default::default()
};
// 1. Handle content update
if let Some(content) = &edit.content {
partial.content = Some(content.clone());
}
// 2. Clear any auto generated embeds
let mut new_embeds: Vec<Embed> = vec![];
if let Some(embeds) = &message.embeds {
for embed in embeds {
if let Embed::Text(embed) = embed {
new_embeds.push(Embed::Text(embed.clone()))
}
}
}
// 3. Replace if we are given new embeds
if let Some(embeds) = edit.embeds {
new_embeds.clear();
for embed in embeds {
new_embeds.push(embed.clone().into_embed(db, message.id.clone()).await?);
}
}
partial.embeds = Some(new_embeds);
message.update(db, partial).await?;
// Queue up a task for processing embeds
if let Some(content) = edit.content {
revolt_quark::tasks::process_embeds::queue(
message.channel.to_string(),
message.id.to_string(),
content,
)
.await;
}
Ok(Json(message))
}

View File

@@ -0,0 +1,26 @@
use revolt_quark::{
models::{Message, User},
perms, Db, Error, Permission, Ref, Result,
};
use rocket::serde::json::Json;
/// # Fetch Message
///
/// Retrieves a message by its id.
#[openapi(tag = "Messaging")]
#[get("/<target>/messages/<msg>")]
pub async fn req(db: &Db, user: User, target: Ref, msg: Ref) -> Result<Json<Message>> {
let channel = target.as_channel(db).await?;
perms(&user)
.channel(&channel)
.throw_permission(db, Permission::ViewChannel)
.await?;
let message = msg.as_message(db).await?;
if message.channel != channel.as_id() {
return Err(Error::NotFound);
}
Ok(Json(message))
}

View File

@@ -0,0 +1,97 @@
use revolt_quark::{
models::{
message::{
BulkMessageResponse, MessageFilter, MessageQuery, MessageSort, MessageTimePeriod,
},
User,
},
perms, Db, Error, Permission, Ref, Result,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use validator::Validate;
/// # Query Parameters
#[derive(Validate, Serialize, Deserialize, JsonSchema, FromForm)]
pub struct OptionsQueryMessages {
/// Maximum number of messages to fetch
///
/// For fetching nearby messages, this is \`(limit + 1)\`.
#[validate(range(min = 1, max = 100))]
limit: Option<i64>,
/// Message id before which messages should be fetched
#[validate(length(min = 26, max = 26))]
before: Option<String>,
/// Message id after which messages should be fetched
#[validate(length(min = 26, max = 26))]
after: Option<String>,
/// Message sort direction
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.
#[validate(length(min = 26, max = 26))]
nearby: Option<String>,
/// Whether to include user (and member, if server channel) objects
include_users: Option<bool>,
}
/// # Fetch Messages
///
/// Fetch multiple messages.
#[openapi(tag = "Messaging")]
#[get("/<target>/messages?<options..>")]
pub async fn req(
db: &Db,
user: User,
target: Ref,
options: OptionsQueryMessages,
) -> Result<Json<BulkMessageResponse>> {
options
.validate()
.map_err(|error| Error::FailedValidation { error })?;
if let Some(MessageSort::Relevance) = options.sort {
return Err(Error::InvalidOperation);
}
let channel = target.as_channel(db).await?;
perms(&user)
.channel(&channel)
.throw_permission_and_view_channel(db, Permission::ReadMessageHistory)
.await?;
let OptionsQueryMessages {
limit,
before,
after,
sort,
nearby,
include_users,
} = options;
let messages = db
.fetch_messages(MessageQuery {
filter: MessageFilter {
channel: Some(channel.id().to_string()),
..Default::default()
},
time_period: if let Some(nearby) = nearby {
MessageTimePeriod::Relative { nearby }
} else {
MessageTimePeriod::Absolute {
before,
after,
sort,
}
},
limit,
})
.await?;
BulkMessageResponse::transform(db, Some(&channel), messages, include_users)
.await
.map(Json)
}

View File

@@ -0,0 +1,26 @@
use revolt_quark::{models::User, Ref, Result};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use validator::Validate;
/// # Query Parameters
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct OptionsQueryStale {
/// Array of message IDs
#[validate(length(min = 0, max = 150))]
ids: Vec<String>,
}
/// # Poll Message Changes
///
/// This route returns any changed message objects and tells you if any have been deleted.
///
/// Don't actually poll this route, instead use this to update your local database.
///
/// **DEPRECATED**
#[openapi(tag = "Messaging")]
#[post("/<_target>/messages/stale", data = "<_data>")]
pub async fn req(_user: User, _target: Ref, _data: Json<OptionsQueryStale>) -> Result<()> {
Ok(())
}

View File

@@ -0,0 +1,29 @@
use revolt_quark::{models::User, perms, Db, EmptyResponse, Permission, Ref, Result};
/// # Add Reaction to Message
///
/// React to a given message.
#[openapi(tag = "Interactions")]
#[put("/<target>/messages/<msg>/reactions/<emoji>")]
pub async fn react_message(
db: &Db,
user: User,
target: Ref,
msg: Ref,
emoji: Ref,
) -> Result<EmptyResponse> {
let channel = target.as_channel(db).await?;
perms(&user)
.channel(&channel)
.throw_permission_and_view_channel(db, Permission::React)
.await?;
// Fetch relevant message
let message = msg.as_message_in(db, channel.id()).await?;
// Add the reaction
message
.add_reaction(db, &user, &emoji.id)
.await
.map(|_| EmptyResponse)
}

View File

@@ -0,0 +1,96 @@
use revolt_quark::{
models::{
message::{
BulkMessageResponse, MessageFilter, MessageQuery, MessageSort, MessageTimePeriod,
},
User,
},
perms, Db, Error, Permission, Ref, Result,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use validator::Validate;
/// # Search Parameters
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct OptionsMessageSearch {
/// Full-text search query
///
/// See [MongoDB documentation](https://docs.mongodb.com/manual/text-search/#-text-operator) for more information.
#[validate(length(min = 1, max = 64))]
query: String,
/// Maximum number of messages to fetch
#[validate(range(min = 1, max = 100))]
limit: Option<i64>,
/// Message id before which messages should be fetched
#[validate(length(min = 26, max = 26))]
before: Option<String>,
/// Message id after which messages should be fetched
#[validate(length(min = 26, max = 26))]
after: Option<String>,
/// Message sort direction
///
/// By default, it will be sorted by latest.
#[serde(default = "MessageSort::default")]
sort: MessageSort,
/// Whether to include user (and member, if server channel) objects
include_users: Option<bool>,
}
/// # Search for Messages
///
/// This route searches for messages within the given parameters.
#[openapi(tag = "Messaging")]
#[post("/<target>/search", data = "<options>")]
pub async fn req(
db: &Db,
user: User,
target: Ref,
options: Json<OptionsMessageSearch>,
) -> Result<Json<BulkMessageResponse>> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
let options = options.into_inner();
options
.validate()
.map_err(|error| Error::FailedValidation { error })?;
let channel = target.as_channel(db).await?;
perms(&user)
.channel(&channel)
.throw_permission_and_view_channel(db, Permission::ReadMessageHistory)
.await?;
let OptionsMessageSearch {
query,
limit,
before,
after,
sort,
include_users,
} = options;
let messages = db
.fetch_messages(MessageQuery {
filter: MessageFilter {
channel: Some(channel.id().to_string()),
query: Some(query),
..Default::default()
},
time_period: MessageTimePeriod::Absolute {
before,
after,
sort: Some(sort),
},
limit,
})
.await?;
BulkMessageResponse::transform(db, Some(&channel), messages, include_users)
.await
.map(Json)
}

View File

@@ -0,0 +1,231 @@
use std::collections::HashSet;
use revolt_quark::{
models::{
message::{Interactions, Masquerade, Reply, SendableEmbed},
Message, User,
},
perms,
web::idempotency::IdempotencyKey,
Db, Error, Permission, Ref, Result,
};
use regex::Regex;
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use ulid::Ulid;
use validator::Validate;
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct DataMessageSend {
/// Unique token to prevent duplicate message sending
///
/// **This is deprecated and replaced by `Idempotency-Key`!**
#[validate(length(min = 1, max = 64))]
nonce: Option<String>,
/// Message content to send
#[validate(length(min = 0, max = 2000))]
content: Option<String>,
/// Attachments to include in message
#[validate(length(min = 1, max = 128))]
attachments: Option<Vec<String>>,
/// Messages to reply to
replies: Option<Vec<Reply>>,
/// Embeds to include in message
///
/// Text embed content contributes to the content length cap
#[validate(length(min = 1, max = 10))]
embeds: Option<Vec<SendableEmbed>>,
/// Masquerade to apply to this message
#[validate]
masquerade: Option<Masquerade>,
/// Information about how this message should be interacted with
interactions: Option<Interactions>,
}
lazy_static! {
// ignoring I L O and U is intentional
static ref RE_MENTION: Regex = Regex::new(r"<@([0-9A-HJKMNP-TV-Z]{26})>").unwrap();
}
/// # Send Message
///
/// Sends a message to the given channel.
#[openapi(tag = "Messaging")]
#[post("/<target>/messages", data = "<data>")]
pub async fn message_send(
db: &Db,
user: User,
target: Ref,
data: Json<DataMessageSend>,
mut idempotency: IdempotencyKey,
) -> Result<Json<Message>> {
let data = data.into_inner();
data.validate()
.map_err(|error| Error::FailedValidation { error })?;
// Validate Message is within reasonable length limits
Message::validate_sum(&data.content, &data.embeds)?;
// Ensure the request is unique
idempotency.consume_nonce(data.nonce).await?;
// Ensure we have permissions to send a message
let channel = target.as_channel(db).await?;
let mut permissions = perms(&user).channel(&channel);
permissions
.throw_permission_and_view_channel(db, Permission::SendMessage)
.await?;
// 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(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(Error::InvalidProperty);
}
}
}
// Start constructing the message
let message_id = Ulid::new().to_string();
let mut message = Message {
id: message_id.clone(),
channel: channel.id().to_string(),
author: user.id.clone(),
masquerade: data.masquerade,
interactions: data.interactions.unwrap_or_default(),
..Default::default()
};
// 1. Parse mentions in message.
let mut mentions = HashSet::new();
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());
}
}
}
// 2. Verify permissions for masquerade.
if let Some(masq) = &message.masquerade {
permissions
.throw_permission(db, Permission::Masquerade)
.await?;
if masq.colour.is_some() {
permissions
.throw_permission(db, Permission::ManageRole)
.await?;
}
}
// 3. Ensure interactions information is correct
message.interactions.validate(db, &mut permissions).await?;
// 4. Verify replies are valid.
let mut replies = HashSet::new();
if let Some(entries) = data.replies {
if entries.len() > 5 {
return Err(Error::TooManyReplies);
}
for Reply { id, mention } in entries {
let message = Ref::from_unchecked(id).as_message(db).await?;
replies.insert(message.id);
if mention {
mentions.insert(message.author);
}
}
}
if !mentions.is_empty() {
message.mentions.replace(
mentions
.into_iter()
.filter(|id| !user.has_blocked(id))
.collect::<Vec<String>>(),
);
}
if !replies.is_empty() {
message
.replies
.replace(replies.into_iter().collect::<Vec<String>>());
}
// 5. Process included embeds.
let mut embeds = vec![];
if let Some(sendable_embeds) = data.embeds {
for sendable_embed in sendable_embeds {
embeds.push(sendable_embed.into_embed(db, message_id.clone()).await?)
}
}
if !embeds.is_empty() {
message.embeds.replace(embeds);
}
// 6. Add attachments to message.
let mut attachments = vec![];
if let Some(ids) = &data.attachments {
if !ids.is_empty() {
permissions
.throw_permission(db, Permission::UploadFiles)
.await?;
}
// ! FIXME: move this to app config
if ids.len() > 5 {
return Err(Error::TooManyAttachments);
}
for attachment_id in ids {
attachments.push(
db.find_and_use_attachment(attachment_id, "attachments", "message", &message_id)
.await?,
);
}
}
if !attachments.is_empty() {
message.attachments.replace(attachments);
}
// 7. Set content
message.content = data.content;
// 8. Pass-through nonce value for clients
message.nonce = Some(idempotency.into_key());
message.create(db, &channel, Some(&user)).await?;
// Queue up a task for processing embeds
if let Some(content) = &message.content {
revolt_quark::tasks::process_embeds::queue(
channel.id().to_string(),
message.id.to_string(),
content.clone(),
)
.await;
}
Ok(Json(message))
}

View File

@@ -0,0 +1,58 @@
use revolt_quark::{models::User, perms, Db, EmptyResponse, Permission, Ref, Result};
use serde::{Deserialize, Serialize};
/// # Query Parameters
#[derive(Serialize, Deserialize, JsonSchema, FromForm)]
pub struct OptionsUnreact {
/// Remove a specific user's reaction
user_id: Option<String>,
/// Remove all reactions
remove_all: Option<bool>,
}
/// # Remove Reaction(s) to Message
///
/// Remove your own, someone else's or all of a given reaction.
///
/// Requires `ManageMessages` if changing others' reactions.
#[openapi(tag = "Interactions")]
#[delete("/<target>/messages/<msg>/reactions/<emoji>?<options..>")]
pub async fn unreact_message(
db: &Db,
user: User,
target: Ref,
msg: Ref,
emoji: Ref,
options: OptionsUnreact,
) -> Result<EmptyResponse> {
let channel = target.as_channel(db).await?;
let mut permissions = perms(&user).channel(&channel);
permissions
.throw_permission_and_view_channel(db, Permission::React)
.await?;
// Check if we need to escalate permissions
let remove_all = options.remove_all.unwrap_or_default();
if options.user_id.is_some() || remove_all {
permissions
.throw_permission(db, Permission::ManageMessages)
.await?;
}
// Fetch relevant message
let message = msg.as_message_in(db, channel.id()).await?;
// Check if we should wipe all of this reaction
if remove_all {
return message
.clear_reaction(db, &emoji.id)
.await
.map(|_| EmptyResponse);
}
// Remove the reaction
message
.remove_reaction(db, options.user_id.as_ref().unwrap_or(&user.id), &emoji.id)
.await
.map(|_| EmptyResponse)
}

View File

@@ -1,27 +1,32 @@
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
use rocket::Route;
mod channel_ack;
mod channel_delete;
mod channel_edit;
mod channel_fetch;
mod members_fetch;
mod group_add_member;
mod group_create;
mod group_remove_member;
mod invite_create;
mod voice_join;
mod members_fetch;
mod message_bulk_delete;
mod message_clear_reactions;
mod message_delete;
mod message_edit;
mod message_fetch;
mod message_query;
mod message_search;
mod message_query_stale;
mod message_react;
mod message_search;
mod message_send;
mod message_unreact;
mod permissions_set;
mod permissions_set_default;
mod voice_join;
pub fn routes() -> Vec<Route> {
routes![
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![
channel_ack::req,
channel_fetch::req,
members_fetch::req,
@@ -34,6 +39,7 @@ pub fn routes() -> Vec<Route> {
message_query_stale::req,
message_fetch::req,
message_edit::req,
message_bulk_delete::req,
message_delete::req,
group_create::req,
group_add_member::req,
@@ -41,5 +47,8 @@ pub fn routes() -> Vec<Route> {
voice_join::req,
permissions_set::req,
permissions_set_default::req,
message_react::react_message,
message_unreact::unreact_message,
message_clear_reactions::clear_reactions
]
}

View File

@@ -0,0 +1,59 @@
use rocket::serde::json::Json;
use serde::Deserialize;
use revolt_quark::{
models::{Channel, User},
perms, Db, Error, Override, Permission, Ref, Result,
};
/// # Permission Value
#[derive(Deserialize, JsonSchema)]
pub struct Data {
/// Allow / deny values to set for this role
permissions: Override,
}
/// # Set Role Permission
///
/// Sets permissions for the specified role in this channel.
///
/// Channel must be a `TextChannel` or `VoiceChannel`.
#[openapi(tag = "Channel Permissions")]
#[put("/<target>/permissions/<role_id>", data = "<data>", rank = 2)]
pub async fn req(
db: &Db,
user: User,
target: Ref,
role_id: String,
data: Json<Data>,
) -> Result<Json<Channel>> {
let mut channel = target.as_channel(db).await?;
let mut permissions = perms(&user).channel(&channel);
permissions
.throw_permission_and_view_channel(db, Permission::ManagePermissions)
.await?;
if let Some(server) = permissions.server.get() {
if let Some(role) = server.roles.get(&role_id) {
if role.rank <= permissions.get_member_rank().unwrap_or(i64::MIN) {
return Err(Error::NotElevated);
}
let current_value: Override = role.permissions.into();
permissions
.throw_permission_override(db, current_value, data.permissions)
.await?;
channel
.set_role_permission(db, &role_id, data.permissions.into())
.await?;
Ok(Json(channel))
} else {
Err(Error::NotFound)
}
} else {
Err(Error::InvalidOperation)
}
}

View File

@@ -0,0 +1,95 @@
use rocket::serde::json::Json;
use serde::Deserialize;
use revolt_quark::{
models::{channel::PartialChannel, Channel, User},
perms, Db, Error, Override, Permission, Ref, Result,
};
/// # Permission Value
#[derive(Deserialize, JsonSchema)]
#[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,
},
}
/// # Set Default Permission
///
/// Sets permissions for the default role in this channel.
///
/// Channel must be a `Group`, `TextChannel` or `VoiceChannel`.
#[openapi(tag = "Channel Permissions")]
#[put("/<target>/permissions/default", data = "<data>", rank = 1)]
pub async fn req(
db: &Db,
user: User,
target: Ref,
data: Json<DataDefaultChannelPermissions>,
) -> Result<Json<Channel>> {
let data = data.into_inner();
let mut channel = target.as_channel(db).await?;
let mut perm = perms(&user).channel(&channel);
perm.throw_permission_and_view_channel(db, Permission::ManagePermissions)
.await?;
match &channel {
Channel::Group { .. } => {
if let DataDefaultChannelPermissions::Value { permissions } = data {
channel
.update(
db,
PartialChannel {
permissions: Some(permissions as i64),
..Default::default()
},
vec![],
)
.await?;
} else {
return Err(Error::InvalidOperation);
}
}
Channel::TextChannel {
default_permissions,
..
}
| Channel::VoiceChannel {
default_permissions,
..
} => {
if let DataDefaultChannelPermissions::Field { permissions } = data {
perm.throw_permission_override(
db,
default_permissions.map(|x| x.into()),
permissions,
)
.await?;
channel
.update(
db,
PartialChannel {
default_permissions: Some(permissions.into()),
..Default::default()
},
vec![],
)
.await?;
} else {
return Err(Error::InvalidOperation);
}
}
_ => return Err(Error::InvalidOperation),
}
Ok(Json(channel))
}

View File

@@ -1,44 +1,50 @@
use crate::database::*;
use crate::util::result::{Error, Result};
use crate::util::variables::{USE_VOSO, VOSO_MANAGE_TOKEN, VOSO_URL};
use revolt_quark::{
models::{Channel, User},
perms,
variables::delta::{USE_VOSO, VOSO_MANAGE_TOKEN, VOSO_URL},
Db, Error, Permission, Ref, Result,
};
use rocket::serde::json::Value;
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct CreateUserResponse {
/// # Voice Server Token Response
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct CreateVoiceUserResponse {
/// Token for authenticating with the voice server
token: String,
}
/// # Join Call
///
/// Asks the voice server for a token to join the call.
#[openapi(tag = "Voice")]
#[post("/<target>/join_call")]
pub async fn req(user: User, target: Ref) -> Result<Value> {
pub async fn req(db: &Db, user: User, target: Ref) -> Result<Json<CreateVoiceUserResponse>> {
let channel = target.as_channel(db).await?;
let mut permissions = perms(&user).channel(&channel);
permissions
.throw_permission_and_view_channel(db, Permission::Connect)
.await?;
if !*USE_VOSO {
return Err(Error::VosoUnavailable);
}
let target = target.fetch_channel().await?;
match target {
match channel {
Channel::SavedMessages { .. } | Channel::TextChannel { .. } => {
return Err(Error::CannotJoinCall)
}
_ => {}
}
let perm = permissions::PermissionCalculator::new(&user)
.with_channel(&target)
.for_channel()
.await?;
if !perm.get_voice_call() {
return Err(Error::MissingPermission);
}
// To join a call:
// - Check if the room exists.
// - If not, create it.
let client = reqwest::Client::new();
let result = client
.get(&format!("{}/room/{}", *VOSO_URL, target.id()))
.get(&format!("{}/room/{}", *VOSO_URL, channel.id()))
.header(
reqwest::header::AUTHORIZATION,
VOSO_MANAGE_TOKEN.to_string(),
@@ -51,14 +57,15 @@ pub async fn req(user: User, target: Ref) -> Result<Value> {
Ok(result) => match result.status() {
reqwest::StatusCode::OK => (),
reqwest::StatusCode::NOT_FOUND => {
if let Err(_) = client
.post(&format!("{}/room/{}", *VOSO_URL, target.id()))
if (client
.post(&format!("{}/room/{}", *VOSO_URL, channel.id()))
.header(
reqwest::header::AUTHORIZATION,
VOSO_MANAGE_TOKEN.to_string(),
)
.send()
.await
.await)
.is_err()
{
return Err(Error::VosoUnavailable);
}
@@ -72,7 +79,7 @@ pub async fn req(user: User, target: Ref) -> Result<Value> {
.post(&format!(
"{}/room/{}/user/{}",
*VOSO_URL,
target.id(),
channel.id(),
user.id
))
.header(
@@ -82,9 +89,11 @@ pub async fn req(user: User, target: Ref) -> Result<Value> {
.send()
.await
{
let res: CreateUserResponse = response.json().await.map_err(|_| Error::InvalidOperation)?;
Ok(json!(res))
response
.json()
.await
.map_err(|_| Error::InvalidOperation)
.map(Json)
} else {
Err(Error::VosoUnavailable)
}

View File

@@ -0,0 +1,81 @@
use revolt_quark::models::emoji::EmojiParent;
use revolt_quark::models::{Emoji, File, User};
use revolt_quark::{perms, Db, Error, Permission, Result};
use serde::Deserialize;
use validator::Validate;
use crate::util::regex::RE_EMOJI;
use rocket::serde::json::Json;
/// # Emoji Data
#[derive(Validate, Deserialize, JsonSchema)]
pub struct DataCreateEmoji {
/// Server name
#[validate(length(min = 1, max = 32), regex = "RE_EMOJI")]
name: String,
/// Parent information
parent: EmojiParent,
/// Whether the emoji is mature
#[serde(default)]
nsfw: bool,
}
/// # Create New Emoji
///
/// Create an emoji by its Autumn upload id.
#[openapi(tag = "Emojis")]
#[put("/emoji/<id>", data = "<data>")]
pub async fn create_emoji(
db: &Db,
user: User,
id: String,
data: Json<DataCreateEmoji>,
) -> Result<Json<Emoji>> {
let data = data.into_inner();
data.validate()
.map_err(|error| Error::FailedValidation { error })?;
// Bots cannot manage emojis
if user.bot.is_some() {
return Err(Error::IsBot);
}
// Validate we have permission to write into parent
match &data.parent {
EmojiParent::Server { id } => {
let server = db.fetch_server(id).await?;
// Check for permission
perms(&user)
.server(&server)
.throw_permission(db, Permission::ManageCustomisation)
.await?;
// Check that there are no more than 100 emoji
// ! FIXME: hardcoded upper limit
let emojis = db.fetch_emoji_by_parent_id(&server.id).await?;
if emojis.len() > 99 {
return Err(Error::TooManyEmoji);
}
}
EmojiParent::Detached => return Err(Error::InvalidOperation),
};
// Find the relevant attachment
let attachment = File::use_emoji(db, &id, &id).await?;
// Create the emoji object
let emoji = Emoji {
id,
parent: data.parent,
creator_id: user.id,
name: data.name,
animated: "image/gif" == &attachment.content_type,
nsfw: data.nsfw,
};
// Save emoji
emoji.create(db).await?;
Ok(Json(emoji))
}

View File

@@ -0,0 +1,37 @@
use revolt_quark::models::{emoji::EmojiParent, User};
use revolt_quark::{perms, Db, EmptyResponse, Error, Permission, Ref, Result};
/// # Delete Emoji
///
/// Delete an emoji by its id.
#[openapi(tag = "Emojis")]
#[delete("/emoji/<id>")]
pub async fn delete_emoji(db: &Db, user: User, id: Ref) -> Result<EmptyResponse> {
// Bots cannot manage emoji
if user.bot.is_some() {
return Err(Error::IsBot);
}
// Fetch the emoji
let emoji = id.as_emoji(db).await?;
// If we uploaded the emoji, then we have permission to delete it
if emoji.creator_id != user.id {
// Otherwise, validate we have permission to delete from parent
match &emoji.parent {
EmojiParent::Server { id } => {
let server = db.fetch_server(id).await?;
// Check for permission
perms(&user)
.server(&server)
.throw_permission(db, Permission::ManageCustomisation)
.await?;
}
EmojiParent::Detached => return Ok(EmptyResponse),
};
}
// Delete the emoji
emoji.delete(db).await.map(|_| EmptyResponse)
}

View File

@@ -0,0 +1,13 @@
use revolt_quark::models::{Emoji, User};
use revolt_quark::{Db, Ref, Result};
use rocket::serde::json::Json;
/// # Fetch Emoji
///
/// Fetch an emoji by its id.
#[openapi(tag = "Emojis")]
#[get("/emoji/<id>")]
pub async fn fetch_emoji(db: &Db, _user: User, id: Ref) -> Result<Json<Emoji>> {
id.as_emoji(db).await.map(Json)
}

View File

@@ -0,0 +1,14 @@
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
use rocket::Route;
mod emoji_create;
mod emoji_delete;
mod emoji_fetch;
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![
emoji_create::create_emoji,
emoji_delete::delete_emoji,
emoji_fetch::fetch_emoji
]
}

View File

@@ -0,0 +1,31 @@
use revolt_quark::{
models::{Invite, User},
perms, Db, EmptyResponse, Permission, Ref, Result,
};
/// # Delete Invite
///
/// Delete an invite by its id.
#[openapi(tag = "Invites")]
#[delete("/<target>")]
pub async fn req(db: &Db, user: User, target: Ref) -> Result<EmptyResponse> {
let invite = target.as_invite(db).await?;
if user.id == invite.creator() {
db.delete_invite(invite.code()).await
} else {
match invite {
Invite::Server { code, server, .. } => {
let server = db.fetch_server(&server).await?;
perms(&user)
.server(&server)
.throw_permission(db, Permission::ManageServer)
.await?;
db.delete_invite(&code).await
}
_ => unreachable!(),
}
}
.map(|_| EmptyResponse)
}

View File

@@ -0,0 +1,135 @@
use revolt_quark::models::{Channel, File, Invite};
use revolt_quark::{Db, Ref, Result};
use rocket::serde::json::Json;
use serde::Serialize;
/// # Invite
#[allow(clippy::large_enum_variant)]
#[derive(Serialize, Debug, Clone, JsonSchema)]
#[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>,
},
}
/// # Fetch Invite
///
/// Fetch an invite by its id.
#[openapi(tag = "Invites")]
#[get("/<target>")]
pub async fn req(db: &Db, target: Ref) -> Result<Json<InviteResponse>> {
Ok(Json(match target.as_invite(db).await? {
Invite::Server {
channel, creator, ..
} => {
let channel = db.fetch_channel(&channel).await?;
let user = db.fetch_user(&creator).await?;
match channel {
Channel::TextChannel {
id,
server,
name,
description,
..
}
| Channel::VoiceChannel {
id,
server,
name,
description,
..
} => {
let server = db.fetch_server(&server).await?;
InviteResponse::Server {
code: target.id,
member_count: db.fetch_member_count(&server.id).await? as i64,
server_id: server.id,
server_name: server.name,
server_icon: server.icon,
server_banner: server.banner,
server_flags: server.flags,
channel_id: id,
channel_name: name,
channel_description: description,
user_name: user.username,
user_avatar: user.avatar,
}
}
_ => unreachable!(),
}
}
Invite::Group {
channel, creator, ..
} => {
let channel = db.fetch_channel(&channel).await?;
let user = db.fetch_user(&creator).await?;
match channel {
Channel::Group {
id,
name,
description,
..
} => InviteResponse::Group {
code: target.id,
channel_id: id,
channel_name: name,
channel_description: description,
user_name: user.username,
user_avatar: user.avatar,
},
_ => unreachable!(),
}
}
}))
}

View File

@@ -0,0 +1,47 @@
use revolt_quark::{
models::{Channel, Invite, Server, User},
variables::delta::MAX_SERVER_COUNT,
Db, Error, Ref, Result,
};
use rocket::serde::json::Json;
use serde::Serialize;
/// # Join Response
#[derive(Serialize, JsonSchema)]
#[serde(tag = "type")]
pub enum InviteJoinResponse {
Server {
/// Channels in the server
channels: Vec<Channel>,
/// Server we are joining
server: Server,
},
}
/// # Join Invite
///
/// Join an invite by its ID.
#[openapi(tag = "Invites")]
#[post("/<target>")]
pub async fn req(db: &Db, user: User, target: Ref) -> Result<Json<InviteJoinResponse>> {
if user.bot.is_some() {
return Err(Error::IsBot);
}
if !user.can_acquire_server(db).await? {
return Err(Error::TooManyServers {
max: *MAX_SERVER_COUNT,
});
}
let invite = target.as_invite(db).await?;
match &invite {
Invite::Server { server, .. } => {
let server = db.fetch_server(server).await?;
let channels = server.create_member(db, user, None).await?;
Ok(Json(InviteJoinResponse::Server { channels, server }))
}
_ => unreachable!(),
}
}

View File

@@ -0,0 +1,10 @@
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
use rocket::Route;
mod invite_delete;
mod invite_fetch;
mod invite_join;
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![invite_fetch::req, invite_join::req, invite_delete::req]
}

View File

@@ -0,0 +1,294 @@
use revolt_rocket_okapi::{revolt_okapi::openapi3::OpenApi, settings::OpenApiSettings};
pub use rocket::http::Status;
pub use rocket::response::Redirect;
use rocket::{Build, Rocket};
mod admin;
mod bots;
mod channels;
mod customisation;
mod invites;
mod onboard;
mod push;
mod root;
mod safety;
mod servers;
mod sync;
mod users;
pub fn mount(mut rocket: Rocket<Build>) -> Rocket<Build> {
let settings = OpenApiSettings::default();
mount_endpoints_and_merged_docs! {
rocket, "/".to_owned(), settings,
"/" => (vec![], custom_openapi_spec()),
"" => openapi_get_routes_spec![root::root, root::ping],
"/admin" => admin::routes(),
"/users" => users::routes(),
"/bots" => bots::routes(),
"/channels" => channels::routes(),
"/servers" => servers::routes(),
"/invites" => invites::routes(),
"/custom" => customisation::routes(),
"/safety" => safety::routes(),
"/auth/account" => rocket_authifier::routes::account::routes(),
"/auth/session" => rocket_authifier::routes::session::routes(),
"/auth/mfa" => rocket_authifier::routes::mfa::routes(),
"/onboard" => onboard::routes(),
"/push" => push::routes(),
"/sync" => sync::routes(),
};
rocket
}
fn custom_openapi_spec() -> OpenApi {
use revolt_rocket_okapi::revolt_okapi::openapi3::*;
let mut extensions = schemars::Map::new();
extensions.insert(
"x-logo".to_owned(),
json!({
"url": "https://revolt.chat/header.png",
"altText": "Revolt Header"
}),
);
extensions.insert(
"x-tagGroups".to_owned(),
json!([
{
"name": "Revolt",
"tags": [
"Core"
]
},
{
"name": "Users",
"tags": [
"User Information",
"Direct Messaging",
"Relationships"
]
},
{
"name": "Bots",
"tags": [
"Bots"
]
},
{
"name": "Channels",
"tags": [
"Channel Information",
"Channel Invites",
"Channel Permissions",
"Messaging",
"Interactions",
"Groups",
"Voice"
]
},
{
"name": "Servers",
"tags": [
"Server Information",
"Server Members",
"Server Permissions"
]
},
{
"name": "Invites",
"tags": [
"Invites"
]
},
{
"name": "Customisation",
"tags": [
"Emojis"
]
},
{
"name": "Platform Administration",
"tags": [
"Admin",
"User Safety"
]
},
{
"name": "Authentication",
"tags": [
"Account",
"Session",
"Onboarding",
"MFA"
]
},
{
"name": "Miscellaneous",
"tags": [
"Sync",
"Web Push"
]
}
]),
);
OpenApi {
openapi: OpenApi::default_version(),
info: Info {
title: "Revolt API".to_owned(),
description: Some("Open source user-first chat platform.".to_owned()),
terms_of_service: Some("https://revolt.chat/terms".to_owned()),
contact: Some(Contact {
name: Some("Revolt Support".to_owned()),
url: Some("https://revolt.chat".to_owned()),
email: Some("contact@revolt.chat".to_owned()),
..Default::default()
}),
license: Some(License {
name: "AGPLv3".to_owned(),
url: Some("https://github.com/revoltchat/delta/blob/master/LICENSE".to_owned()),
..Default::default()
}),
version: env!("CARGO_PKG_VERSION").to_string(),
..Default::default()
},
servers: vec![
Server {
url: "https://api.revolt.chat".to_owned(),
description: Some("Revolt API".to_owned()),
..Default::default()
},
Server {
url: "http://local.revolt.chat:8000".to_owned(),
description: Some("Local Revolt Environment".to_owned()),
..Default::default()
},
],
external_docs: Some(ExternalDocs {
url: "https://developers.revolt.chat".to_owned(),
description: Some("Revolt Developer Documentation".to_owned()),
..Default::default()
}),
extensions,
tags: vec![
Tag {
name: "Core".to_owned(),
description: Some(
"Use in your applications to determine information about the Revolt node"
.to_owned(),
),
..Default::default()
},
Tag {
name: "User Information".to_owned(),
description: Some("Query and fetch users on Revolt".to_owned()),
..Default::default()
},
Tag {
name: "Direct Messaging".to_owned(),
description: Some("Direct message other users on Revolt".to_owned()),
..Default::default()
},
Tag {
name: "Relationships".to_owned(),
description: Some(
"Manage your friendships and block list on the platform".to_owned(),
),
..Default::default()
},
Tag {
name: "Bots".to_owned(),
description: Some("Create and edit bots".to_owned()),
..Default::default()
},
Tag {
name: "Channel Information".to_owned(),
description: Some("Query and fetch channels on Revolt".to_owned()),
..Default::default()
},
Tag {
name: "Channel Invites".to_owned(),
description: Some("Create and manage invites for channels".to_owned()),
..Default::default()
},
Tag {
name: "Channel Permissions".to_owned(),
description: Some("Manage permissions for channels".to_owned()),
..Default::default()
},
Tag {
name: "Messaging".to_owned(),
description: Some("Send and manipulate messages".to_owned()),
..Default::default()
},
Tag {
name: "Groups".to_owned(),
description: Some("Create, invite users and manipulate groups".to_owned()),
..Default::default()
},
Tag {
name: "Voice".to_owned(),
description: Some("Join and talk with other users".to_owned()),
..Default::default()
},
Tag {
name: "Server Information".to_owned(),
description: Some("Query and fetch servers on Revolt".to_owned()),
..Default::default()
},
Tag {
name: "Server Members".to_owned(),
description: Some("Find and edit server members".to_owned()),
..Default::default()
},
Tag {
name: "Server Permissions".to_owned(),
description: Some("Manage permissions for servers".to_owned()),
..Default::default()
},
Tag {
name: "Invites".to_owned(),
description: Some("View, join and delete invites".to_owned()),
..Default::default()
},
Tag {
name: "Account".to_owned(),
description: Some("Manage your account".to_owned()),
..Default::default()
},
Tag {
name: "Session".to_owned(),
description: Some("Create and manage sessions".to_owned()),
..Default::default()
},
Tag {
name: "MFA".to_owned(),
description: Some("Multi-factor Authentication".to_owned()),
..Default::default()
},
Tag {
name: "Onboarding".to_owned(),
description: Some(
"After signing up to Revolt, users must pick a unique username".to_owned(),
),
..Default::default()
},
Tag {
name: "Sync".to_owned(),
description: Some("Upload and retrieve any JSON data between clients".to_owned()),
..Default::default()
},
Tag {
name: "Web Push".to_owned(),
description: Some(
"Subscribe to and receive Revolt push notifications while offline".to_owned(),
),
..Default::default()
},
],
..Default::default()
}
}

View File

@@ -0,0 +1,45 @@
use crate::util::regex::RE_USERNAME;
use revolt_quark::{
authifier::models::Session, models::User, Database, EmptyResponse, Error, Result,
};
use rocket::{serde::json::Json, State};
use serde::{Deserialize, Serialize};
use validator::Validate;
/// # New User Data
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct DataOnboard {
/// New username which will be used to identify the user on the platform
#[validate(length(min = 2, max = 32), regex = "RE_USERNAME")]
username: String,
}
/// # Complete Onboarding
///
/// This sets a new username, completes onboarding and allows a user to start using Revolt.
#[openapi(tag = "Onboarding")]
#[post("/complete", data = "<data>")]
pub async fn req(
db: &State<Database>,
session: Session,
user: Option<User>,
data: Json<DataOnboard>,
) -> Result<EmptyResponse> {
if user.is_some() {
return Err(Error::AlreadyOnboarded);
}
let data = data.into_inner();
data.validate()
.map_err(|error| Error::FailedValidation { error })?;
let username = User::validate_username(db, data.username).await?;
let user = User {
id: session.user_id,
username,
..Default::default()
};
db.insert_user(&user).await.map(|_| EmptyResponse)
}

View File

@@ -0,0 +1,21 @@
use revolt_quark::{authifier::models::Session, models::User};
use rocket::serde::json::Json;
use serde::Serialize;
/// # Onboarding Status
#[derive(Serialize, JsonSchema)]
pub struct DataHello {
/// Whether onboarding is required
onboarding: bool,
}
/// # Check Onboarding Status
///
/// This will tell you whether the current account requires onboarding or whether you can continue to send requests as usual. You may skip calling this if you're restoring an existing session.
#[openapi(tag = "Onboarding")]
#[get("/hello")]
pub async fn req(_session: Session, user: Option<User>) -> Json<DataHello> {
Json(DataHello {
onboarding: user.is_none(),
})
}

View File

@@ -0,0 +1,9 @@
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
use rocket::Route;
mod complete;
mod hello;
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![hello::req, complete::req]
}

View File

@@ -0,0 +1,9 @@
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
use rocket::Route;
mod subscribe;
mod unsubscribe;
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![subscribe::req, unsubscribe::req]
}

View File

@@ -0,0 +1,32 @@
use revolt_quark::{
authifier::{
models::{Session, WebPushSubscription},
Authifier,
},
EmptyResponse, Error, Result,
};
use rocket::{serde::json::Json, State};
/// # Push Subscribe
///
/// Create a new Web Push subscription.
///
/// If an existing subscription exists on this session, it will be removed.
#[openapi(tag = "Web Push")]
#[post("/subscribe", data = "<data>")]
pub async fn req(
authifier: &State<Authifier>,
mut session: Session,
data: Json<WebPushSubscription>,
) -> Result<EmptyResponse> {
session.subscription = Some(data.into_inner());
session
.save(authifier)
.await
.map(|_| EmptyResponse)
.map_err(|_| Error::DatabaseError {
operation: "save",
with: "session",
})
}

View File

@@ -0,0 +1,23 @@
use revolt_quark::{
authifier::{models::Session, Authifier},
EmptyResponse, Error, Result,
};
use rocket::State;
/// # Unsubscribe
///
/// Remove the Web Push subscription associated with the current session.
#[openapi(tag = "Web Push")]
#[post("/unsubscribe")]
pub async fn req(authifier: &State<Authifier>, mut session: Session) -> Result<EmptyResponse> {
session.subscription = None;
session
.save(authifier)
.await
.map(|_| EmptyResponse)
.map_err(|_| Error::DatabaseError {
operation: "save",
with: "session",
})
}

View File

@@ -0,0 +1,146 @@
use revolt_quark::variables::delta::{
APP_URL, AUTUMN_URL, EXTERNAL_WS_URL, HCAPTCHA_SITEKEY, INVITE_ONLY, JANUARY_URL, USE_AUTUMN,
USE_EMAIL, USE_HCAPTCHA, USE_JANUARY, USE_VOSO, VAPID_PUBLIC_KEY, VOSO_URL, VOSO_WS_HOST,
};
use revolt_quark::Result;
use rocket::http::Status;
use rocket::serde::json::Json;
use serde::Serialize;
/// # hCaptcha Configuration
#[derive(Serialize, JsonSchema, Debug)]
pub struct CaptchaFeature {
/// Whether captcha is enabled
pub enabled: bool,
/// Client key used for solving captcha
pub key: String,
}
/// # Generic Service Configuration
#[derive(Serialize, JsonSchema, Debug)]
pub struct Feature {
/// Whether the service is enabled
pub enabled: bool,
/// URL pointing to the service
pub url: String,
}
/// # Voice Server Configuration
#[derive(Serialize, JsonSchema, Debug)]
pub struct VoiceFeature {
/// Whether voice is enabled
pub enabled: bool,
/// URL pointing to the voice API
pub url: String,
/// URL pointing to the voice WebSocket server
pub ws: String,
}
/// # Feature Configuration
#[derive(Serialize, JsonSchema, Debug)]
pub struct RevoltFeatures {
/// hCaptcha configuration
pub captcha: CaptchaFeature,
/// Whether email verification is enabled
pub email: bool,
/// Whether this server is invite only
pub invite_only: bool,
/// File server service configuration
pub autumn: Feature,
/// Proxy service configuration
pub january: Feature,
/// Voice server configuration
pub voso: VoiceFeature,
}
/// # Build Information
#[derive(Serialize, JsonSchema, Debug)]
pub struct BuildInformation {
/// Commit Hash
pub commit_sha: String,
/// Commit Timestamp
pub commit_timestamp: String,
/// Git Semver
pub semver: String,
/// Git Origin URL
pub origin_url: String,
/// Build Timestamp
pub timestamp: String,
}
/// # Server Configuration
#[derive(Serialize, JsonSchema, Debug)]
pub struct RevoltConfig {
/// Revolt API Version
pub revolt: String,
/// Features enabled on this Revolt node
pub features: RevoltFeatures,
/// WebSocket URL
pub ws: String,
/// URL pointing to the client serving this node
pub app: String,
/// Web Push VAPID public key
pub vapid: String,
/// Build information
pub build: BuildInformation,
}
/// # Query Node
///
/// Fetch the server configuration for this Revolt instance.
#[openapi(tag = "Core")]
#[get("/")]
pub async fn root() -> Result<Json<RevoltConfig>> {
Ok(Json(RevoltConfig {
revolt: env!("CARGO_PKG_VERSION").to_string(),
features: RevoltFeatures {
captcha: CaptchaFeature {
enabled: *USE_HCAPTCHA,
key: HCAPTCHA_SITEKEY.to_string(),
},
email: *USE_EMAIL,
invite_only: *INVITE_ONLY,
autumn: Feature {
enabled: *USE_AUTUMN,
url: AUTUMN_URL.to_string(),
},
january: Feature {
enabled: *USE_JANUARY,
url: JANUARY_URL.to_string(),
},
voso: VoiceFeature {
enabled: *USE_VOSO,
url: VOSO_URL.to_string(),
ws: VOSO_WS_HOST.to_string(),
},
},
ws: EXTERNAL_WS_URL.to_string(),
app: APP_URL.to_string(),
vapid: VAPID_PUBLIC_KEY.to_string(),
build: BuildInformation {
commit_sha: option_env!("VERGEN_GIT_SHA")
.unwrap_or_else(|| "<failed to generate>")
.to_string(),
commit_timestamp: option_env!("VERGEN_GIT_COMMIT_TIMESTAMP")
.unwrap_or_else(|| "<failed to generate>")
.to_string(),
semver: option_env!("VERGEN_GIT_SEMVER")
.unwrap_or_else(|| "<failed to generate>")
.to_string(),
origin_url: option_env!("GIT_ORIGIN_URL")
.unwrap_or_else(|| "<failed to generate>")
.to_string(),
timestamp: option_env!("VERGEN_BUILD_TIMESTAMP")
.unwrap_or_else(|| "<failed to generate>")
.to_string(),
},
}))
}
/// Example endpoint.
#[openapi(skip)]
#[get("/ping")]
pub async fn ping(/*_limitguard: Ratelimiter*/) -> Status {
Status::Ok
}

View File

@@ -0,0 +1,56 @@
use revolt_quark::{
models::{
report::{PartialReport, ReportStatus},
Report, User,
},
Db, Error, Ref, Result,
};
use rocket::serde::json::Json;
use serde::Deserialize;
use validator::Validate;
/// # Report Data
#[derive(Validate, Deserialize, JsonSchema)]
pub struct DataEditReport {
/// New report status
status: Option<ReportStatus>,
/// Report notes
notes: Option<String>,
}
/// # Edit Report
///
/// Edit a report.
#[openapi(tag = "User Safety")]
#[patch("/reports/<report>", data = "<edit>")]
pub async fn edit_report(
db: &Db,
user: User,
report: Ref,
edit: Json<DataEditReport>,
) -> Result<Json<Report>> {
// Must be privileged for this route
if !user.privileged {
return Err(Error::NotPrivileged);
}
// Validate data
let edit = edit.into_inner();
edit.validate()
.map_err(|error| Error::FailedValidation { error })?;
// Create and apply update to report
let mut report = report.as_report(db).await?;
report
.update(
db,
PartialReport {
status: edit.status,
notes: edit.notes,
..Default::default()
},
)
.await?;
Ok(Json(report))
}

View File

@@ -0,0 +1,17 @@
use revolt_quark::models::{Report, User};
use revolt_quark::{Db, Error, Result};
use rocket::serde::json::Json;
/// # Fetch Report
///
/// Fetch a report by its ID
#[openapi(tag = "User Safety")]
#[get("/report/<id>")]
pub async fn fetch_report(db: &Db, user: User, id: String) -> Result<Json<Report>> {
// Must be privileged for this route
if !user.privileged {
return Err(Error::NotPrivileged);
}
db.fetch_report(&id).await.map(Json)
}

View File

@@ -0,0 +1,17 @@
use revolt_quark::models::{Report, User};
use revolt_quark::{Db, Error, Result};
use rocket::serde::json::Json;
/// # Fetch Reports
///
/// Fetch all available reports
#[openapi(tag = "User Safety")]
#[get("/reports")]
pub async fn fetch_reports(db: &Db, user: User) -> Result<Json<Vec<Report>>> {
// Must be privileged for this route
if !user.privileged {
return Err(Error::NotPrivileged);
}
db.fetch_reports().await.map(Json)
}

View File

@@ -0,0 +1,82 @@
use std::collections::HashSet;
use revolt_quark::models::snapshot::{SnapshotContent, SnapshotWithContext};
use revolt_quark::models::{Channel, User};
use revolt_quark::{Db, Error, Result};
use rocket::serde::json::Json;
/// # Fetch Snapshot
///
/// Fetch a snapshot for a given report
#[openapi(tag = "User Safety")]
#[get("/snapshot/<report_id>")]
pub async fn fetch_snapshot(
db: &Db,
user: User,
report_id: String,
) -> Result<Json<SnapshotWithContext>> {
// Must be privileged for this route
if !user.privileged {
return Err(Error::NotPrivileged);
}
// Fetch snapshot
let snapshot = db.fetch_snapshot(&report_id).await?;
// Resolve and fetch IDs of associated content
let mut user_ids: HashSet<&str> = HashSet::new();
let mut channel_ids: HashSet<&str> = HashSet::new();
match &snapshot.content {
SnapshotContent::Message {
prior_context,
leading_context,
message,
} => {
for msg in prior_context {
user_ids.insert(&msg.author);
}
for msg in leading_context {
user_ids.insert(&msg.author);
}
user_ids.insert(&message.author);
channel_ids.insert(&message.channel);
}
SnapshotContent::User(user) => {
user_ids.insert(&user.id);
}
SnapshotContent::Server(server) => {
for channel in &server.channels {
channel_ids.insert(channel);
}
}
}
// Collect user and channel IDs
let user_ids: Vec<String> = user_ids.into_iter().map(|s| s.to_owned()).collect();
let channel_ids: Vec<String> = channel_ids.into_iter().map(|s| s.to_owned()).collect();
// Fetch users and channels
let users = db.fetch_users(&user_ids).await?;
let channels = db.fetch_channels(&channel_ids).await?;
// Pull out first server from channels if possible
let server = if let Some(server_id) = channels.iter().find_map(|channel| match channel {
Channel::TextChannel { server, .. } => Some(server),
_ => None,
}) {
Some(db.fetch_server(server_id).await?)
} else {
None
};
// Return snapshot with context
Ok(Json(SnapshotWithContext {
snapshot,
users,
channels,
server,
}))
}

View File

@@ -0,0 +1,21 @@
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
use rocket::Route;
mod edit_report;
mod fetch_report;
mod fetch_reports;
mod report_content;
mod fetch_snapshot;
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![
// Reports
edit_report::edit_report,
fetch_report::fetch_report,
fetch_reports::fetch_reports,
report_content::report_content,
// Snapshots
fetch_snapshot::fetch_snapshot
]
}

View File

@@ -0,0 +1,170 @@
use revolt_quark::events::client::EventV1;
use revolt_quark::models::message::{MessageFilter, MessageQuery, MessageSort, MessageTimePeriod};
use revolt_quark::models::report::{ReportStatus, ReportedContent};
use revolt_quark::models::snapshot::{Snapshot, SnapshotContent};
use revolt_quark::models::{Report, User};
use revolt_quark::{Db, Error, Result};
use serde::Deserialize;
use ulid::Ulid;
use validator::Validate;
use rocket::serde::json::Json;
/// # Report Data
#[derive(Validate, Deserialize, JsonSchema)]
pub struct DataReportContent {
/// Content being reported
content: ReportedContent,
/// Additional report description
#[validate(length(min = 0, max = 1000))]
#[serde(default)]
additional_context: String,
}
/// # Report Content
///
/// Report a piece of content to the moderation team.
#[openapi(tag = "User Safety")]
#[post("/report", data = "<data>")]
pub async fn report_content(db: &Db, user: User, data: Json<DataReportContent>) -> Result<()> {
let data = data.into_inner();
data.validate()
.map_err(|error| Error::FailedValidation { error })?;
// Bots cannot create reports
if user.bot.is_some() {
return Err(Error::IsBot);
}
// Find the content and create a snapshot of it
// Also retrieve any references to Files
let (content, files): (SnapshotContent, Vec<String>) = match &data.content {
ReportedContent::Message { id, .. } => {
let message = db.fetch_message(id).await?;
// Users cannot report themselves
if message.author == user.id {
return Err(Error::CannotReportYourself);
}
// Collect message attachments
let files = message
.attachments
.as_ref()
.map(|attachments| attachments.iter().map(|x| x.id.to_string()).collect())
.unwrap_or_default();
// Collect prior context
let prior_context = db
.fetch_messages(MessageQuery {
filter: MessageFilter {
channel: Some(message.channel.to_string()),
..Default::default()
},
limit: Some(15),
time_period: MessageTimePeriod::Absolute {
before: Some(message.id.to_string()),
after: None,
sort: Some(MessageSort::Latest),
},
})
.await?;
// Collect leading context
let leading_context = db
.fetch_messages(MessageQuery {
filter: MessageFilter {
channel: Some(message.channel.to_string()),
..Default::default()
},
limit: Some(15),
time_period: MessageTimePeriod::Absolute {
before: None,
after: Some(message.id.to_string()),
sort: Some(MessageSort::Oldest),
},
})
.await?;
(
SnapshotContent::Message {
message,
prior_context,
leading_context,
},
files,
)
}
ReportedContent::Server { id, .. } => {
let server = db.fetch_server(id).await?;
// Users cannot report their own server
if server.owner == user.id {
return Err(Error::CannotReportYourself);
}
// Collect server's icon and banner
let files = [&server.icon, &server.banner]
.iter()
.filter_map(|x| x.as_ref().map(|x| x.id.to_string()))
.collect();
(SnapshotContent::Server(server), files)
}
ReportedContent::User { id, .. } => {
let reported_user = db.fetch_user(id).await?;
// Users cannot report themselves
if reported_user.id == user.id {
return Err(Error::CannotReportYourself);
}
// Collect user's avatar and profile background
let files = [
reported_user.avatar.as_ref(),
reported_user
.profile
.as_ref()
.and_then(|profile| profile.background.as_ref()),
]
.iter()
.filter_map(|x| x.as_ref().map(|x| x.id.to_string()))
.collect();
(SnapshotContent::User(reported_user), files)
}
};
// Mark all the attachments as reported
for file in files {
db.mark_attachment_as_reported(&file).await?;
}
// Generate an id for the report
let id = Ulid::new().to_string();
// Save a snapshot of the content
let snapshot = Snapshot {
id: Ulid::new().to_string(),
report_id: id.to_string(),
content,
};
db.insert_snapshot(&snapshot).await?;
// Save the report
let report = Report {
id,
author_id: user.id,
content: data.content,
additional_context: data.additional_context,
status: ReportStatus::Created {},
notes: String::new(),
};
db.insert_report(&report).await?;
EventV1::ReportCreate(report).global().await;
Ok(())
}

View File

@@ -0,0 +1,73 @@
use revolt_quark::{
models::{server_member::MemberCompositeKey, ServerBan, User},
perms, Db, Error, Permission, Ref, Result,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use validator::Validate;
/// # Ban Information
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct DataBanCreate {
/// Ban reason
#[validate(length(min = 1, max = 1024))]
reason: Option<String>,
}
/// # Ban User
///
/// Ban a user by their id.
#[openapi(tag = "Server Members")]
#[put("/<server>/bans/<target>", data = "<data>")]
pub async fn req(
db: &Db,
user: User,
server: Ref,
target: Ref,
data: Json<DataBanCreate>,
) -> Result<Json<ServerBan>> {
let data = data.into_inner();
data.validate()
.map_err(|error| Error::FailedValidation { error })?;
let server = server.as_server(db).await?;
if target.id == user.id {
return Err(Error::CannotRemoveYourself);
}
if target.id == server.owner {
return Err(Error::InvalidOperation);
}
let mut permissions = perms(&user).server(&server);
permissions
.throw_permission(db, Permission::BanMembers)
.await?;
// If member exists, check privileges against them
if let Ok(member) = target.as_member(db, &server.id).await {
if member.get_ranking(permissions.server.get().unwrap())
<= permissions.get_member_rank().unwrap_or(i64::MIN)
{
return Err(Error::NotElevated);
}
server.ban_member(db, member, data.reason).await.map(Json)
} else {
let server_id = server.id.to_string();
server
.ban_user(
db,
MemberCompositeKey {
server: server_id,
user: target.id,
},
data.reason,
)
.await
.map(Json)
}
}

View File

@@ -0,0 +1,67 @@
use revolt_quark::models::{File, ServerBan, User};
use revolt_quark::{perms, Db, Permission, Ref, Result};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
/// # Banned User
///
/// Just enoguh user information to list bans.
#[derive(Serialize, Deserialize, JsonSchema)]
struct BannedUser {
/// Id of the banned user
#[serde(rename = "_id")]
pub id: String,
/// Username of the banned user
pub username: String,
/// Avatar of the banned user
pub avatar: Option<File>,
}
/// # Ban List Result
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct BanListResult {
/// Users objects
users: Vec<BannedUser>,
/// Ban objects
bans: Vec<ServerBan>,
}
impl From<User> for BannedUser {
fn from(user: User) -> Self {
BannedUser {
id: user.id,
username: user.username,
avatar: user.avatar,
}
}
}
/// # Fetch Bans
///
/// Fetch all bans on a server.
#[openapi(tag = "Server Members")]
#[get("/<target>/bans")]
pub async fn req(db: &Db, user: User, target: Ref) -> Result<Json<BanListResult>> {
let server = target.as_server(db).await?;
perms(&user)
.server(&server)
.throw_permission(db, Permission::BanMembers)
.await?;
let bans = db.fetch_bans(&server.id).await?;
let users = db
.fetch_users(
&bans
.iter()
.map(|x| &x.id.user)
.cloned()
.collect::<Vec<String>>(),
)
.await?
.into_iter()
.map(|x| x.into())
.collect();
Ok(Json(BanListResult { users, bans }))
}

View File

@@ -0,0 +1,17 @@
use revolt_quark::{models::User, perms, Db, EmptyResponse, Permission, Ref, Result};
/// # Unban user
///
/// Remove a user's ban.
#[openapi(tag = "Server Members")]
#[delete("/<server>/bans/<target>")]
pub async fn req(db: &Db, user: User, server: Ref, target: Ref) -> Result<EmptyResponse> {
let server = server.as_server(db).await?;
perms(&user)
.server(&server)
.throw_permission(db, Permission::BanMembers)
.await?;
let ban = target.as_ban(db, &server.id).await?;
db.delete_ban(&ban.id).await.map(|_| EmptyResponse)
}

View File

@@ -0,0 +1,120 @@
use std::collections::HashMap;
use revolt_quark::{
models::{server::PartialServer, Channel, User},
perms, Db, Error, Permission, Ref, Result,
};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use ulid::Ulid;
use validator::Validate;
/// # Channel Type
#[derive(Serialize, Deserialize, JsonSchema)]
enum ChannelType {
/// Text Channel
Text,
/// Voice Channel
Voice,
}
impl Default for ChannelType {
fn default() -> Self {
ChannelType::Text
}
}
/// # Channel Data
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct DataCreateChannel {
/// Channel type
#[serde(rename = "type", default = "ChannelType::default")]
channel_type: ChannelType,
/// Channel name
#[validate(length(min = 1, max = 32))]
name: String,
/// Channel description
#[validate(length(min = 0, max = 1024))]
description: Option<String>,
/// Whether this channel is age restricted
#[serde(skip_serializing_if = "Option::is_none")]
nsfw: Option<bool>,
}
/// # Create Channel
///
/// Create a new Text or Voice channel.
#[openapi(tag = "Server Information")]
#[post("/<target>/channels", data = "<info>")]
pub async fn req(
db: &Db,
user: User,
target: Ref,
info: Json<DataCreateChannel>,
) -> Result<Json<Channel>> {
let info = info.into_inner();
info.validate()
.map_err(|error| Error::FailedValidation { error })?;
let mut server = target.as_server(db).await?;
perms(&user)
.server(&server)
.throw_permission(db, Permission::ManageChannel)
.await?;
let id = Ulid::new().to_string();
let mut channels = server.channels.clone();
channels.push(id.clone());
let DataCreateChannel {
name,
description,
nsfw,
channel_type,
} = info;
let channel = match channel_type {
ChannelType::Text => Channel::TextChannel {
id,
server: server.id.clone(),
name,
description,
icon: None,
last_message_id: None,
default_permissions: None,
role_permissions: HashMap::new(),
nsfw: nsfw.unwrap_or(false),
},
ChannelType::Voice => Channel::VoiceChannel {
id,
server: server.id.clone(),
name,
description,
icon: None,
default_permissions: None,
role_permissions: HashMap::new(),
nsfw: nsfw.unwrap_or(false),
},
};
channel.create(db).await?;
server
.update(
db,
PartialServer {
channels: Some(channels),
..Default::default()
},
vec![],
)
.await?;
Ok(Json(channel))
}

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