Compare commits

...

88 Commits

Author SHA1 Message Date
stoat-release[bot]
ee4575470b chore(main): release 0.13.4 (#754)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-16 18:27:44 +01:00
Paul Makles
6cfee1f601 fix: add TLS feature to livekit-api crate (#753) 2026-05-16 18:23:45 +01:00
stoat-release[bot]
ab9b8ccfca chore(main): release 0.13.3 (#750)
* chore(main): release 0.13.3

* chore: update Cargo.lock

Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

---------

Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: stoat-release[bot] <245062572+stoat-release[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-15 12:22:27 -07:00
Tom
7647cfc8d9 fix: don't automatically set up rabbitmq in delta (#749)
fix: don't declare queues which seem to cause the backend to crash in prod
for now, these exchanges/queues/bindings will need to be declared manually. Hopefully the lapin rewrite will fix this.

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>
2026-05-15 12:17:36 -07:00
stoat-release[bot]
8157e1f6e9 chore(main): release 0.13.2 (#747)
* chore(main): release 0.13.2

* chore: update Cargo.lock

Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

---------

Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: stoat-release[bot] <245062572+stoat-release[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-11 15:26:03 +01:00
Paul Makles
fcb8091cd7 fix: update default exchange to revolt.default (#746)
Signed-off-by: Paul Makles <me@insrt.uk>
2026-05-11 15:21:34 +01:00
stoat-release[bot]
260036488d chore(main): release 0.13.1 (#745)
Co-authored-by: stoat-release[bot] <245062572+stoat-release[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-10 15:34:41 +01:00
Tom
1100eaf46f fix: amqprs startup bug (#744) 2026-05-10 15:22:26 +01:00
stoat-release[bot]
d52e84c5d3 chore(main): release 0.13.0 (#722)
Co-authored-by: stoat-release[bot] <245062572+stoat-release[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-09 17:26:38 +01:00
İspik
d76a71141f fix: don't strip ICC from exif (#735)
* fix: don't strip ICC from exif

Signed-off-by: ispik <ispik@ispik.dev>

* fix: refactor ICC profile handling in image processing

Signed-off-by: ispik <ispik@ispik.dev>

---------

Signed-off-by: ispik <ispik@ispik.dev>
2026-05-08 15:49:31 -07:00
Taureon
6f3441cf4a feat: Add webhook endpoints for editing and deleting messages (#682)
* feat: ErrorType.CannotDeleteMessage, needed later

Signed-off-by: Taureon <taureon@noreply.codeberg.org>

* feat: webhook edit/delete message endpoints

Signed-off-by: Taureon <taureon@noreply.codeberg.org>

* lol, lmao even

Signed-off-by: Taureon <taureon@noreply.codeberg.org>

* fix contradictory comment

Signed-off-by: Taureon <taureon@noreply.codeberg.org>

---------

Signed-off-by: Taureon <taureon@noreply.codeberg.org>
Co-authored-by: Taureon <taureon@noreply.codeberg.org>
2026-05-08 15:37:00 -07:00
Gabriel
23ad135983 feat: add emoji rename endpoint (#714)
* feat: add rename endpoint with rename-only update

Signed-off-by: Gabriel <60961939+gabrielfordevelopment@users.noreply.github.com>

* fix: enforce detached emoji edit restrictions

Signed-off-by: Gabriel <60961939+gabrielfordevelopment@users.noreply.github.com>

* fix: always enforce emoji edit permissions

Signed-off-by: Gabriel <60961939+gabrielfordevelopment@users.noreply.github.com>

---------

Signed-off-by: Gabriel <60961939+gabrielfordevelopment@users.noreply.github.com>
2026-05-08 15:23:57 -07:00
İspik
d46c7f7f3c feat: add embed support for YouTube Shorts (#734)
* feat: add embed support for YouTube Shorts

Signed-off-by: ispik <ispik@ispik.dev>

* feat: blacklist private ip ranges and add january domain blocklist

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* fix: remove duplicates

Signed-off-by: ispik <ispik@ispik.dev>

---------

Signed-off-by: ispik <ispik@ispik.dev>
Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>
Co-authored-by: IAmTomahawkx <iamtomahawkx@gmail.com>
2026-05-08 15:23:30 -07:00
Tom
0719985ac5 fix: docker compose file had personal url in it (#742) 2026-05-08 15:22:10 -07:00
Tom
ab5bd47a39 feat: Rewrite acks (#741)
* feat: rewrite ack system

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* feat: rewrite acks to crond + rabbit task

* fix: review changes

---------

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>
2026-05-08 14:39:16 -07:00
İspik
9fd7128f80 fix: encode filenames in redirects (#737)
* fix: encode filenames in redirects

Signed-off-by: ispik <ispik@ispik.dev>

* refactor: don't add another dependency when the one needed exists

Signed-off-by: ispik <ispik@ispik.dev>

---------

Signed-off-by: ispik <ispik@ispik.dev>
2026-05-08 14:38:57 -07:00
İspik
df276ac40b chore: update emoji list (#740)
Signed-off-by: ispik <ispik@ispik.dev>
2026-05-08 14:37:35 -07:00
Tom
356491e934 fix: january ip redirects & domain resolver (#738)
* fix: properly block private ip ranges
I'm a dunce and forgot that domains do in fact resolve to ips.

* fix: reimplement max redirects

* fix: remove my debug error

* fix: actually check redirect urls
kind of the whole point of this thing.
2026-05-06 21:33:52 -07:00
İspik
21d82018cf feat: add legal links to root payload (#733)
Signed-off-by: ispik <ispik@ispik.dev>
2026-05-06 16:35:20 -07:00
Tom
6b41db984b feat: blacklist private ip ranges and add january domain blocklist (#731)
Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>
2026-05-06 16:25:31 -07:00
İspik
5378cd22b4 fix: use correct response for NoEffect errors (#732)
Signed-off-by: ispik <ispik@ispik.dev>
2026-05-05 11:57:42 -07:00
İspik
841985d3b9 feat: add role icon support (#724)
Signed-off-by: ispik <ispik@ispik.dev>
2026-05-02 17:04:28 -07:00
İspik
279f5d5fd7 fix: add new_user_hours to configuration limits (#729)
Signed-off-by: ispik <ispik@ispik.dev>
2026-04-26 22:47:52 -07:00
jarvarvarvis
e93769786c feat: automatically sanitise usernames on create/update (#689)
* feat: automatically sanitise usernames on create/update

Signed-off-by: higgs01 <6546697+higgs01@users.noreply.github.com>

* test: add tests for validation and sanitasion

Signed-off-by: higgs01 <6546697+higgs01@users.noreply.github.com>

* fix: Return only the sanitised string in sanitise_username (#424)

* fix: Use as_str for role.id in insert_role
* Run rustfmt for code changes

Signed-off-by: jarvarvarvis <jarvistrigo@gmail.com>

* feat: Make minimum username length configurable in Revolt.toml (#424)

* fix: Remove redundant call to is_match with blocked username regex

Signed-off-by: jarvarvarvis <jarvistrigo@gmail.com>

* Update crates/core/database/src/models/users/model.rs

Co-authored-by: Tom <iamtomahawkx@gmail.com>
Signed-off-by: jarvarvarvis <53998846+jarvarvarvis@users.noreply.github.com>

* Update crates/core/database/src/models/users/model.rs

Co-authored-by: Tom <iamtomahawkx@gmail.com>
Signed-off-by: jarvarvarvis <53998846+jarvarvarvis@users.noreply.github.com>

* Update crates/core/database/src/models/users/model.rs

Co-authored-by: Tom <iamtomahawkx@gmail.com>
Signed-off-by: jarvarvarvis <53998846+jarvarvarvis@users.noreply.github.com>

* Update crates/core/database/src/models/users/model.rs

Co-authored-by: Tom <iamtomahawkx@gmail.com>
Signed-off-by: jarvarvarvis <53998846+jarvarvarvis@users.noreply.github.com>

* fix: Implement suggested changes and clean up last 4 commits

Signed-off-by: jarvarvarvis <jarvistrigo@gmail.com>

* fix: Disallow stoat as username, update create_user test

Signed-off-by: jarvarvarvis <jarvistrigo@gmail.com>

* fix: Use sanitised username to find updated discriminator

Signed-off-by: jarvarvarvis <jarvistrigo@gmail.com>

* feat: Sanitise revolt.chat in username

Signed-off-by: jarvarvarvis <jarvistrigo@gmail.com>

* fix: Implement discussed changes

Signed-off-by: jarvarvarvis <jarvistrigo@gmail.com>

* test: Fix create_user test

Signed-off-by: jarvarvarvis <jarvistrigo@gmail.com>

* fix: don't overflow the stack
not entirely sure why this fixes it and I don't like it. But work it does.

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* fix: revert odd file mode change

Signed-off-by: jarvarvarvis <jarvistrigo@gmail.com>

---------

Signed-off-by: higgs01 <6546697+higgs01@users.noreply.github.com>
Signed-off-by: jarvarvarvis <jarvistrigo@gmail.com>
Signed-off-by: jarvarvarvis <53998846+jarvarvarvis@users.noreply.github.com>
Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>
Co-authored-by: higgs01 <6546697+higgs01@users.noreply.github.com>
Co-authored-by: Tom <iamtomahawkx@gmail.com>
2026-04-23 21:02:28 -07:00
İspik
ed4fd5ebfe fix: update message length validation to remove upper limit (#723)
Signed-off-by: ispik <ispik@ispik.dev>
2026-04-23 20:39:13 -07:00
Angelo Kontaxis
89171e9bd0 fix: dont send notification in fcm (#721)
* fix: dont send notification in fcm

Signed-off-by: Zomatree <me@zomatree.live>

* fix: add notification type to fcm

Signed-off-by: Zomatree <me@zomatree.live>

* fix: switch to structured notification data

Signed-off-by: Zomatree <me@zomatree.live>

---------

Signed-off-by: Zomatree <me@zomatree.live>
2026-04-22 23:48:26 -07:00
Infiland
057f2bb8b3 fix: add reconnection policy to Redis subscriber to prevent ghost state (#708)
* fix: add reconnection policy to Redis subscriber to prevent ghost state

- Add ReconnectPolicy::new_exponential(0, 100, 30_000, 2) to the subscriber builder, unlimited retries with exponential backoff (100ms min, 30s max)
- Add on_reconnect handler that signals the listener loop to force a subscription reset, re-subscribing to all topics on the new connection
- Add warn-level logging to on_error for all Redis subscriber errors (previously only Canceled was handled, others were silently ignored)

Signed-off-by: Infiland <ljubica.citydesign@gmail.com>

* Update websocket.rs

Signed-off-by: Infiland <88491175+Infiland@users.noreply.github.com>

* Auto-manage subscriptions on reconnect

Call subscriber.manage_subscriptions() so the subscriber will automatically re-subscribe tracked channels after a Redis reconnect. Remove the manual reconnect channel and on_reconnect handler along with the select branch that forced SubscriptionStateChange::Reset.

Signed-off-by: Infiland <88491175+Infiland@users.noreply.github.com>

---------

Signed-off-by: Infiland <ljubica.citydesign@gmail.com>
Signed-off-by: Infiland <88491175+Infiland@users.noreply.github.com>
2026-04-17 20:15:54 -07:00
Angelo Kontaxis
3675ff1a1f chore: migrate all local dependancies to workspace dependancies (#710)
* chore: start moving all deps to workspace deps

Signed-off-by: Zomatree <me@zomatree.live>

* chore: migrate all deps to workspace deps

Signed-off-by: Zomatree <me@zomatree.live>

* chore: add more dep groups

Signed-off-by: Zomatree <me@zomatree.live>

* fix: add migration to update existing files to be animated (#705)

* fix: add migration to update existing files to be animated

Signed-off-by: Zomatree <me@zomatree.live>

* Revert "fix: add migration to update existing files to be animated"

This reverts commit 4e1f1c116c.

Signed-off-by: Zomatree <me@zomatree.live>

* fix: calculate animated for existing files when fetched

Signed-off-by: Zomatree <me@zomatree.live>

---------

Signed-off-by: Zomatree <me@zomatree.live>

* fix: mise start + missing docker image (#564)

* fix: mise start + missing docker image

Signed-off-by: Damocles078 <hellodamocles078@gmail.com>

* fix: bump livekit version

Signed-off-by: Damocles <106018783+Damocles078@users.noreply.github.com>

---------

Signed-off-by: Damocles078 <hellodamocles078@gmail.com>
Signed-off-by: Tom <iamtomahawkx@gmail.com>
Signed-off-by: Damocles <106018783+Damocles078@users.noreply.github.com>
Co-authored-by: Tom <iamtomahawkx@gmail.com>

* docs: update donation link (#709)

Signed-off-by: Zomatree <me@zomatree.live>

* fix: remove usage of deprecated functions

Signed-off-by: Zomatree <me@zomatree.live>

---------

Signed-off-by: Zomatree <me@zomatree.live>
Signed-off-by: Damocles078 <hellodamocles078@gmail.com>
Signed-off-by: Tom <iamtomahawkx@gmail.com>
Signed-off-by: Damocles <106018783+Damocles078@users.noreply.github.com>
Co-authored-by: Damocles <106018783+Damocles078@users.noreply.github.com>
Co-authored-by: Tom <iamtomahawkx@gmail.com>
Co-authored-by: Paul Makles <me@insrt.uk>
2026-04-17 19:02:18 -07:00
infi
144e939c6b docs: add run in yaak button to endpoints page (#711) 2026-04-12 00:46:05 +01:00
stoat-release[bot]
61fd13629f chore(main): release 0.12.1 (#700)
Co-authored-by: stoat-release[bot] <245062572+stoat-release[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-11 12:31:11 +01:00
Paul Makles
47336a7940 docs: pr template (#713) 2026-04-10 20:44:29 +01:00
Paul Makles
4ea8f90c0d docs: update donation link (#709) 2026-04-03 00:28:49 +01:00
Damocles
fb8fe16557 fix: mise start + missing docker image (#564)
* fix: mise start + missing docker image

Signed-off-by: Damocles078 <hellodamocles078@gmail.com>

* fix: bump livekit version

Signed-off-by: Damocles <106018783+Damocles078@users.noreply.github.com>

---------

Signed-off-by: Damocles078 <hellodamocles078@gmail.com>
Signed-off-by: Tom <iamtomahawkx@gmail.com>
Signed-off-by: Damocles <106018783+Damocles078@users.noreply.github.com>
Co-authored-by: Tom <iamtomahawkx@gmail.com>
2026-04-01 19:51:14 -07:00
Angelo Kontaxis
f2c056a151 fix: add migration to update existing files to be animated (#705)
* fix: add migration to update existing files to be animated

Signed-off-by: Zomatree <me@zomatree.live>

* Revert "fix: add migration to update existing files to be animated"

This reverts commit 4e1f1c116c.

Signed-off-by: Zomatree <me@zomatree.live>

* fix: calculate animated for existing files when fetched

Signed-off-by: Zomatree <me@zomatree.live>

---------

Signed-off-by: Zomatree <me@zomatree.live>
2026-04-01 19:48:22 -07:00
Tom
f30b729ca9 fix: don't send self dm notifications (#706)
* fix: don't send self dm notifications

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* fix: test failure due to wrong assertion (#707)

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* fix: RA auto import moment

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

---------

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>
2026-03-31 15:25:36 -07:00
Tom
f81e3291bd fix: test failure due to wrong assertion (#707) 2026-03-31 11:06:39 +01:00
Tom
1e80916b65 Fix/release please fix 4 (#703)
* fix: release-please-3

* fix: release please bs v4
2026-03-28 23:05:01 -07:00
Tom
8814bf4c23 fix: release-please-3 (#702) 2026-03-28 22:53:12 -07:00
Tom
5466ada9ae Fix: release please bs 2 electric boogaloo (#701)
* fix: update release (me from this) please

* fix: add dep to release please

* Revert "fix: update release (me from this) please"

This reverts commit 0618c492c7.
2026-03-28 22:42:13 -07:00
Tom
f0e513ccae fix: update release (me from this) please (#699) 2026-03-28 22:35:51 -07:00
stoat-release[bot]
4d4b0dd864 chore(main): release 0.12.0 (#602)
Co-authored-by: stoat-release[bot] <245062572+stoat-release[bot]@users.noreply.github.com>
2026-03-28 22:22:37 -07:00
Tom
f181edc8f2 feat: update livekit (#698) 2026-03-27 21:14:22 -07:00
Angelo Kontaxis
ccda6f5c53 fix: pass &str to Reference (#697)
Signed-off-by: Zomatree <me@zomatree.live>
2026-03-27 18:39:00 -07:00
Damocles
52ed5100c2 fix: improve generated openapi.json (#584)
* fix: /bots/{bot}

Paths "/bots/{bot}" and "/bots/{target}" must not be equivalent.

Signed-off-by: Damocles078 <hellodamocles078@gmail.com>

* fix: /channels/{group_id}/recipients/{member_id}

Paths "/channels/{group_id}/recipients/{member_id}" and "/channels/{target}/recipients/{member}" must not be equivalent.

Signed-off-by: Damocles078 <hellodamocles078@gmail.com>

* fix: /channels/{channel_id}/webhook

Paths "/channels/{target}/webhooks" and "/channels/{channel_id}/webhooks" must not be equivalent.

Signed-off-by: Damocles078 <hellodamocles078@gmail.com>

* fix: /servers/{server_id}/members/{member_id}

Paths "/servers/{target}/members/{member}" and "/servers/{server}/members/{member}" must not be equivalent.

Signed-off-by: Damocles078 <hellodamocles078@gmail.com>

* fix: /custom/emoji/{emoji_id}

Paths "/custom/emoji/{id}" and "/custom/emoji/{emoji_id}" must not be equivalent.

Signed-off-by: Damocles078 <hellodamocles078@gmail.com>

---------

Signed-off-by: Damocles078 <hellodamocles078@gmail.com>
Signed-off-by: Damocles <106018783+Damocles078@users.noreply.github.com>
2026-03-27 17:31:57 -07:00
newt (
c2d4369e16 feat: compute thumbhash for images (#596)
* feat: compute thumbhash for images

Signed-off-by: newt <hi@newty.dev>

* Merge branch 'main' into feat/thumbhash

Signed-off-by: newt <hi@newty.dev>

* style: move comment onto newline

Signed-off-by: newt <hi@newty.dev>

* feat: make thumbhash optional

Signed-off-by: newt <hi@newty.dev>

---------

Signed-off-by: newt <hi@newty.dev>
Signed-off-by: newt (: <hi@newty.dev>
Signed-off-by: Tom <iamtomahawkx@gmail.com>
Co-authored-by: Tom <iamtomahawkx@gmail.com>
2026-03-27 17:29:55 -07:00
Angelo Kontaxis
91783b9066 fix: only show first line on commit messages (#696)
Signed-off-by: Zomatree <me@zomatree.live>
2026-03-27 17:29:37 -07:00
LazyCat
735d644e04 feat: Transfer ownership (#396)
* feat: Transfer ownership

Signed-off-by: LazyCat2 <68156188+LazyCat2@users.noreply.github.com>

* Allow privileged users to change ownership

Signed-off-by: LazyCat2 <68156188+LazyCat2@users.noreply.github.com>

* Require TOTP

Signed-off-by: LazyCat2 <68156188+LazyCat2@users.noreply.github.com>

* Require TOTP or password

Signed-off-by: LazyCat2 <68156188+LazyCat2@users.noreply.github.com>

---------

Signed-off-by: LazyCat2 <68156188+LazyCat2@users.noreply.github.com>
Signed-off-by: Tom <iamtomahawkx@gmail.com>
Co-authored-by: Tom <iamtomahawkx@gmail.com>
2026-03-27 17:24:53 -07:00
Tom
d1e72cee42 feat: parse message push notification content and replace internal formatting (#693)
* parse message push notification content and replace internal formatting

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* fix: don't unwrap the error I was using for db debugging

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* fix: emojis & remove compiler warning

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* fix: emojis in pushd

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* feat: report errors in the render function to sentry

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

---------

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>
2026-03-27 17:15:05 -07:00
İspik
6107f242fd feat: Add slowmode functionality to text channels (#680)
* feat: Add slowmode functionality to text channels

Signed-off-by: arsabutispik <ispik@ispik.dev>

* fix: use atomic check-and-set to prevent spamming with scripts

Signed-off-by: arsabutispik <ispik@ispik.dev>

* feat: Add BypassSlowmode permission to channel permissions

Signed-off-by: arsabutispik <ispik@ispik.dev>

* refactor: Use set_options instead of manually building the command

Signed-off-by: arsabutispik <ispik@ispik.dev>

---------

Signed-off-by: arsabutispik <ispik@ispik.dev>
2026-03-27 17:09:15 -07:00
Tom
a5cd08a655 fix: add flag for disabling events instead of commenting them out (#695)
Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>
2026-03-27 17:03:48 -07:00
İspik
98c7b1b5a5 feat: implement time based message sweep on user ban (#670)
* feat: implement time based message sweep on user ban
- Adds `delete_message_seconds` (0 to 7 days in seconds) to the ban request payload.

Signed-off-by: arsabutispik <ispik@ispik.dev>

* refactor: pass ulid conversion to database

Signed-off-by: arsabutispik <ispik@ispik.dev>

* fix: use COL constant instead of hardcoded string in error mapper

Signed-off-by: arsabutispik <ispik@ispik.dev>

* refactor: broadcast bulk delete events during ban sweep

Updates the `delete_messages_by_author_since` trait to return a
HashMap of deleted message IDs grouped by channel.

The MongoDB implementation now uses a two-step process: it first
runs a projected `find` query to gather the target `_id` and
`channel` fields, then executes the `delete_many` operation.
This allows the ban route to loop through the affected channels and
dispatch `EventV1::BulkMessageDelete` WebSocket events, ensuring
that the swept messages are instantly removed from the UI for
all connected clients.

Signed-off-by: arsabutispik <ispik@ispik.dev>

* refactor: optimize message deletion by using $group and aggregate

Signed-off-by: arsabutispik <ispik@ispik.dev>

* refactor: use with_type in query

Signed-off-by: arsabutispik <ispik@ispik.dev>

* refactor: abstract function to Message model and mark attachments as deleted

Signed-off-by: arsabutispik <ispik@ispik.dev>

---------

Signed-off-by: arsabutispik <ispik@ispik.dev>
2026-03-27 17:02:12 -07:00
flexxyfluxx
ec22deb2cd docs(readme): remove outdated reference to scripts/install.sh; add instruction to run mise install (#691)
* chore: fill in missing install step: mise install

Signed-off-by: flexxyfluxx <82163962+flexxyfluxx@users.noreply.github.com>

* chore: remove outdated reference to scripts/start.sh

Signed-off-by: flexxyfluxx <82163962+flexxyfluxx@users.noreply.github.com>

---------

Signed-off-by: flexxyfluxx <82163962+flexxyfluxx@users.noreply.github.com>
2026-03-27 13:25:57 -07:00
stoat-tofu[bot]
ecb94e278c chore: modify .github/workflows/validate-pr-title.yml 2026-03-24 19:03:59 +00:00
stoat-tofu[bot]
1a9d5b4b65 chore: modify .github/workflows/release-webhook.yml 2026-03-24 19:03:39 +00:00
stoat-tofu[bot]
abfb3925fd chore: modify .github/workflows/git-town.yml 2026-03-24 19:03:37 +00:00
stoat-tofu[bot]
2a73d82250 chore: modify .github/workflows/git-town.yml 2026-03-24 18:59:15 +00:00
Tom
edfa97db10 feat: Allow restricting server creation to specific users (#685)
Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>
2026-03-23 12:25:33 -07:00
Tom
cf2cedcee1 chore: silence git town errors (#687)
Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>
2026-03-23 11:51:22 -07:00
sinus-x
b830631bd2 fix(docs): Update GitHub links (#647)
Updates user-facing links to point to the new organization. Does not
update references in source code (user-agent, dependency references),
as they should be handled separately.

Signed-off-by: sinus-x <14353790+sinus-x@users.noreply.github.com>
2026-03-22 22:18:25 -07:00
Erik LaBine
3fa0abf47f feat: Detect animation in image files for fetch_preview (#574)
* Implement animated metadata TODOs for database and thumbnailing.

Signed-off-by: Assisting <erik@eriklabine.com>

* Run linter for code changes

Signed-off-by: Assisting <erik@eriklabine.com>

---------

Signed-off-by: Assisting <erik@eriklabine.com>
2026-03-22 22:07:22 -07:00
Nico
5191bd16b2 feat: load config from env vars (#576)
Signed-off-by: Nico <nico.macioszek@gmail.com>
2026-03-22 20:33:01 -07:00
Paul Makles
2547fc6cd3 docs: add LLM policy to contrib.md (#683) 2026-03-22 14:02:58 +00:00
Tom
5701b5c18c fix: don't use a bitop for OR (#676)
* fix: uname is missing from crond

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* fix: don't use a bitop
lol, two of us missed that.

---------

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>
2026-03-15 09:15:54 -07:00
Tom
dc4438bc3c fix: uname is missing from crond (#675)
Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>
2026-03-15 09:04:37 -07:00
Damocles
f2fc1ee00c docs: documentation generation (#575)
Signed-off-by: Damocles078 <hellodamocles078@gmail.com>
2026-03-14 00:53:51 -07:00
İspik
ab525699bd fix: add masquerade permission to default direct message settings (#665)
Signed-off-by: arsabutispik <ispik@ispik.dev>
2026-03-14 00:41:31 -07:00
Christopher Hultin
d56135e0cb fix: Check for appropriate permission for removing other users avatar (#657)
* Check for appropriate permission for removing other users avatar

Signed-off-by: Chris Hultin <chris.hultin@gmail.com>

* limit check for non-self to just removal

Signed-off-by: Chris Hultin <chris.hultin@gmail.com>

* else if change

Signed-off-by: Chris Hultin <chris.hultin@gmail.com>

---------

Signed-off-by: Chris Hultin <chris.hultin@gmail.com>
2026-03-10 15:38:41 -07:00
Alex
52c0d2f266 fix: send push notifications for DM and group messages (#660)
DMs and group messages never triggered push notifications because
the condition only checked for explicit @mentions. DMs don't use
mentions, so users were never notified of new direct messages.

Signed-off-by: sanasol <mail@sanasol.ws>
2026-03-10 15:15:00 -07:00
Angelo Kontaxis
49c6289580 fix: store server id in redis and in room metadata to be able to delete voice state in all scenarios (#656)
* fix: store server id in redis values and in room metadata to be able to fully delete voice state in all scenarios

* fix: undo logging change
2026-03-10 15:14:32 -07:00
Tom
b0b728fb0d fix: redis_url vs redis_uri in config (#666) 2026-03-10 15:00:06 -07:00
Damocles
d629e89304 fix: replace some links and Revolt mentions to current Stoat (#515) 2026-03-08 01:28:50 +00:00
Tom
24d0d2b726 fix: no node state set on channel creation (#653)
* fix: no node state set on channel creation

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* Update "join_call" logic to handle issue of "user is already connected to a channel that cannot be found"

Fixes stoatchat/for-web#951

Signed-off-by: Chris Hultin <chris.hultin@gmail.com>

* feat: remove node key when channel is empty or deleted

---------

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>
Signed-off-by: Chris Hultin <chris.hultin@gmail.com>
Co-authored-by: Chris Hultin <chris.hultin@gmail.com>
2026-03-02 20:45:42 -08:00
Kristian Kjærgård
f777e2863c feat: add bug report template for issue tracking (#627)
Signed-off-by: Bobby <31723128+kris6673@users.noreply.github.com>
2026-03-01 11:16:45 -08:00
Jacob Schlecht
a80ad1cbe5 fix: Fix typo for p256dh in vapid notification flow (#622)
* fix: Fix typo for p256dh in vapid notification flow

This commit was made without the use of generative AI.

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>

* fix: Revert .clone() change as it works fine without that change

This commit was made without the use of generative AI.

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>

---------

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>
2026-03-01 11:06:13 -08:00
Christopher Hultin
0b522ebddc feat: expose global and user limits in root API response (#644)
* feat: expose global and user limits in root API response

Signed-off-by: Chris Hultin <chris.hultin@gmail.com>

* add missing file upload limits

Signed-off-by: Chris Hultin <chris.hultin@gmail.com>

* extract logic to generate "UserLimits" struct from "config"

Signed-off-by: Chris Hultin <chris.hultin@gmail.com>

* convert user limits from feature limits function to impl function

Signed-off-by: Chris Hultin <chris.hultin@gmail.com>

* picking nits

Signed-off-by: Chris Hultin <chris.hultin@gmail.com>

---------

Signed-off-by: Chris Hultin <chris.hultin@gmail.com>
2026-03-01 11:03:49 -08:00
stoat-tofu[bot]
6bd045eb79 chore: modify .github/workflows/release-webhook.yml 2026-02-20 20:45:25 +00:00
Tom
0698e115e8 fix: default video resolution is a non-existent size (#601)
fixes #588
2026-02-18 10:07:35 +00:00
stoat-release[bot]
8e3765df4c chore(main): release 0.11.5 (#598)
* chore(main): release 0.11.5

* chore: update Cargo.lock

Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

---------

Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: stoat-release[bot] <245062572+stoat-release[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-02-17 22:14:57 +00:00
Paul Makles
1c98ead695 revert: disable user update events (#593)
* Revert "fix: cut presence traffic too while we engineer a new events architecture (#561)"

This reverts commit 1f8ea963ad.

* Revert "fix: cut events traffic while we engineer a new events architecture (#559)"

This reverts commit a11986ba1a.

* chore: only revert user update
2026-02-17 22:13:17 +00:00
stoat-release[bot]
8849d03cd2 chore(main): release 0.11.4 (#591)
* chore(main): release 0.11.4

* chore: update Cargo.lock

Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

---------

Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: stoat-release[bot] <245062572+stoat-release[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-02-16 20:02:01 +00:00
Paul Makles
a75e4eabfc fix: add separate config option for redis events replica url (#590) 2026-02-16 19:37:04 +00:00
stoat-release[bot]
74f0c537e6 chore(main): release 0.11.3 (#562)
* chore(main): release 0.11.3

* chore: update Cargo.lock

Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

---------

Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: stoat-release[bot] <245062572+stoat-release[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-02-13 19:50:32 +00:00
Paul Makles
1f8ea963ad fix: cut presence traffic too while we engineer a new events architecture (#561)
Signed-off-by: izzy <me@insrt.uk>
2026-02-13 19:49:23 +00:00
stoat-release[bot]
7c7f4f24de chore(main): release 0.11.2 (#560)
* chore(main): release 0.11.2

* chore: update Cargo.lock

Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

---------

Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: stoat-release[bot] <245062572+stoat-release[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-02-13 19:42:13 +00:00
Paul Makles
a11986ba1a fix: cut events traffic while we engineer a new events architecture (#559)
Signed-off-by: izzy <me@insrt.uk>
2026-02-13 19:40:51 +00:00
stoat-release[bot]
2e87696c83 chore(main): release 0.11.1 (#554) 2026-02-13 00:21:29 +00:00
Angelo Kontaxis
94cb916231 fix: bots in multiple voice channel logic (#544) 2026-02-13 00:11:15 +00:00
Paul Makles
95c7dbc793 ci: do not interpolate directly into release workflows (#545) 2026-02-12 16:47:05 +00:00
149 changed files with 10673 additions and 4636 deletions

147
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View File

@@ -0,0 +1,147 @@
name: Bug Report
description: Report a bug in Stoat — please fill out every required field to help us triage quickly.
title: "[Bug]: "
labels:
- bug
- unconfirmed
body:
# ── Thank you section ────────────────────────────────────────────────────────
- type: markdown
attributes:
value: |
**Thank you for taking the time to report a bug for Stoat!**
Please follow the instructions below and provide as much detail as possible to help us understand and reproduce the issue.
# ── Pre-flight checks ────────────────────────────────────────────────────────
- type: checkboxes
id: preflight
attributes:
label: Pre-flight checklist
description: Please confirm all of the following before submitting.
options:
- label: I am on the latest available version of Stoat.
required: true
- label: I searched existing issues and did not find a duplicate.
required: true
- label: This is a bug report, not a support question or feature request.
required: true
# ── Bug description ──────────────────────────────────────────────────────────
- type: textarea
id: description
attributes:
label: Bug description
description: >
What happened? What did you expect to happen instead?
Keep this brief — detailed steps go in the next field.
placeholder: "Example: When I open a DM thread, new messages from the other person do not appear unless I reload the page."
validations:
required: true
# ── Steps to reproduce ───────────────────────────────────────────────────────
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: >
Provide a numbered list of every step needed to trigger the bug.
The clearer this is, the faster we can fix it.
placeholder: |
1. Log in to Stoat on the web client.
2. Open a direct message conversation.
3. Ask the other person to send a message.
4. Observe that the message does not appear without reloading.
validations:
required: true
# ── Expected behavior ────────────────────────────────────────────────────────
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What should have happened?
placeholder: "New messages should appear in real time without a page reload."
validations:
required: true
# ── Client / platform ────────────────────────────────────────────────────────
- type: dropdown
id: platform
attributes:
label: Client / platform
description: Which Stoat client are you using?
options:
- Web (browser)
- Stoat for Desktop
- Android
- iOS
- Self-hosted server
validations:
required: true
# ── Client version ───────────────────────────────────────────────────────────
- type: input
id: version
attributes:
label: Client version
description: >
Find this in **Settings**. For the web client, include the build
number shown in the footer or About page.
placeholder: "e.g. Stoat for Desktop 1.3.0 or 0.2.1 (2025-10-10)"
validations:
required: true
# ── OS / browser details ─────────────────────────────────────────────────────
- type: input
id: os
attributes:
label: OS / browser details
description: Your operating system and, for web bugs, your browser and its version.
placeholder: "e.g. Windows 11, Chrome 121 or macOS 14.3, Stoat for Desktop 1.3.0 or Android 14"
validations:
required: true
# ── Reproducibility ──────────────────────────────────────────────────────────
- type: dropdown
id: reproducibility
attributes:
label: Reproducibility
description: How consistently does this bug occur?
options:
- Always
- Usually
- Rarely
- Only once
- Unknown
validations:
required: true
# ── Screenshots or video ─────────────────────────────────────────────────────
- type: textarea
id: screenshots
attributes:
label: Screenshots or video
description: Paste images or drag-and-drop a screen recording here. GitHub accepts common image and video formats.
validations:
required: false
# ── Additional context ───────────────────────────────────────────────────────
- type: textarea
id: additional
attributes:
label: Additional context
description: >
Anything else that might help: workarounds you found, links to related issues,
self-hosted configuration details (Redis, MongoDB, LiveKit, SMTP), network environment, etc.
validations:
required: false
# ── Closing note ─────────────────────────────────────────────────────────────
- type: markdown
attributes:
value: |
---
A maintainer will review your report. Please watch for follow-up questions — issues
that go unanswered are harder to resolve and may be closed after 30 days of inactivity.

24
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,24 @@
<!-- Describe your changes -->
Fixes # (issue)
## How was this PR tested?
<!-- What did you do to test your changes? -->
- [ ] Test A
- [ ] Test B
## Checklist:
- [ ] I have carefully read [the contributing guidelines](https://developers.stoat.chat/developing/contrib/)
- [ ] I have performed a self-review of my own code
- [ ] I have made corresponding changes to the documentation if applicable
- [ ] I have no unrelated changes in the PR
- [ ] I have confirmed that any new dependencies are strictly necessary
- [ ] I have written tests for new code (if applicable)
- [ ] I have followed naming conventions/patterns in the surrounding code
## Please declare, if any, LLM usage involved in creating this PR
...

View File

@@ -1,3 +1,6 @@
# DO NOT EDIT DIRECTLY IN REPOSITORY
# Managed in Terraform templates
name: Git Town
on:
@@ -8,7 +11,7 @@ jobs:
name: Display the branch stack
runs-on: ubuntu-slim
if: ${{ !startsWith(github.head_ref, 'release-please--') }}
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !startsWith(github.head_ref, 'release-please--') }}
permissions:
contents: read
@@ -17,3 +20,4 @@ jobs:
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: stoatchat/action-git-town@4bc5c942e4603bffa0806b51d5fe5f0bc5deb0ac
continue-on-error: true

View File

@@ -45,8 +45,9 @@ jobs:
if: ${{ steps.rp.outputs.prs_created == 'true' || steps.rp.outputs.prs_updated == 'true' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
RP_PR_OUTPUT_JSON: ${{ steps.rp.outputs.prs }}
run: |
PR_NUMBER=$(echo '${{ steps.rp.outputs.prs }}' | jq -r '.[0].number')
PR_NUMBER=$(echo "$RP_PR_OUTPUT_JSON" | jq -r '.[0].number')
gh pr checkout "$PR_NUMBER"
cargo update -w

View File

@@ -1,3 +1,6 @@
# DO NOT EDIT DIRECTLY IN REPOSITORY
# Managed in Terraform templates
name: Release Webhook
on:
@@ -12,8 +15,12 @@ jobs:
steps:
- name: Send release notification webhook
env:
TAG_NAME: ${{ github.event.release.tag_name }}
REPOSITORY: ${{ github.repository }}
WEBHOOK_URL: ${{ secrets.STOAT_WEBHOOK_UPDATES_URL }}
run: |
RELEASE_URL="https://github.com/${{ github.repository }}/releases/tag/${{ github.event.release.tag_name }}"
curl -X POST "${{ secrets.STOAT_WEBHOOK_UPDATES_URL }}" \
RELEASE_URL="https://github.com/${REPOSITORY}/releases/tag/${TAG_NAME}"
curl -X POST "$WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d "{\"content\": \"$RELEASE_URL\"}"
-d "{\"content\": \"$RELEASE_URL\"}"

View File

@@ -1,3 +1,6 @@
# DO NOT EDIT DIRECTLY IN REPOSITORY
# Managed in Terraform templates
name: "Lint PR"
on:
@@ -15,6 +18,6 @@ jobs:
permissions:
pull-requests: read
steps:
- uses: amannn/action-semantic-pull-request@v6
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -16,4 +16,13 @@ idiomatic_version_file_enable_tools = ["rust"]
[tasks.start]
description = "Run all services"
depends = ["docker:start", "build"]
wait_for = ["docker:start", "build"]
run = [{ task = "service:*" }]
[env]
BUILDER = "cargo"
DOCKER_NETWORK_NAME = "stoatchat_default"
DATABASE_PORT = "27017"
RABBIT_PORT = "5672"
REDIS_PORT = "6379"
_.file = { path = ".env", tools = true }

View File

@@ -2,4 +2,4 @@
#MISE description="Build project"
set -e
cargo build "$@"
${BUILDER} build "$@"

View File

@@ -3,3 +3,11 @@
set -e
docker compose up -d
docker run \
--network=${DOCKER_NETWORK_NAME} \
--name wait \
--rm dokku/wait -c \
rabbit:${RABBIT_PORT},\
database:${DATABASE_PORT},\
redis:${REDIS_PORT}

View File

@@ -1,3 +1,3 @@
{
".": "0.11.0"
".": "0.13.4"
}

View File

@@ -1,5 +1,143 @@
# Changelog
## [0.13.4](https://github.com/stoatchat/stoatchat/compare/v0.13.3...v0.13.4) (2026-05-16)
### Bug Fixes
* add TLS feature to livekit-api crate ([#753](https://github.com/stoatchat/stoatchat/issues/753)) ([6cfee1f](https://github.com/stoatchat/stoatchat/commit/6cfee1f601c1e084df7c8f1e7a5e8a560d1dd514))
## [0.13.3](https://github.com/stoatchat/stoatchat/compare/v0.13.2...v0.13.3) (2026-05-15)
### Bug Fixes
* don't automatically set up rabbitmq in delta ([#749](https://github.com/stoatchat/stoatchat/issues/749)) ([7647cfc](https://github.com/stoatchat/stoatchat/commit/7647cfc8d93aba99f5faef13eb3d970097540d76))
* don't declare queues which seem to cause the backend to crash in prod ([7647cfc](https://github.com/stoatchat/stoatchat/commit/7647cfc8d93aba99f5faef13eb3d970097540d76))
## [0.13.2](https://github.com/stoatchat/stoatchat/compare/v0.13.1...v0.13.2) (2026-05-11)
### Bug Fixes
* update default exchange to `revolt.default` ([#746](https://github.com/stoatchat/stoatchat/issues/746)) ([fcb8091](https://github.com/stoatchat/stoatchat/commit/fcb8091cd7a00d7f26c798daa33aae4b923b2a8b))
## [0.13.1](https://github.com/stoatchat/stoatchat/compare/v0.13.0...v0.13.1) (2026-05-10)
### Bug Fixes
* amqprs startup bug ([#744](https://github.com/stoatchat/stoatchat/issues/744)) ([1100eaf](https://github.com/stoatchat/stoatchat/commit/1100eaf46f849f2509ae01ac497556ca33bde778))
## [0.13.0](https://github.com/stoatchat/stoatchat/compare/v0.12.1...v0.13.0) (2026-05-08)
### Features
* add embed support for YouTube Shorts ([#734](https://github.com/stoatchat/stoatchat/issues/734)) ([d46c7f7](https://github.com/stoatchat/stoatchat/commit/d46c7f7f3c04524c0639c3e0a122626f8e0b3bf7))
* add emoji rename endpoint ([#714](https://github.com/stoatchat/stoatchat/issues/714)) ([23ad135](https://github.com/stoatchat/stoatchat/commit/23ad1359834bb7d07a460b8678d6a6ebffc73eb0))
* add legal links to root payload ([#733](https://github.com/stoatchat/stoatchat/issues/733)) ([21d8201](https://github.com/stoatchat/stoatchat/commit/21d82018cf84ab0fdd10613d254b9562aea8eea3))
* add role icon support ([#724](https://github.com/stoatchat/stoatchat/issues/724)) ([841985d](https://github.com/stoatchat/stoatchat/commit/841985d3b994df1c6eefab2fc7ecbd77ab22c493))
* Add webhook endpoints for editing and deleting messages ([#682](https://github.com/stoatchat/stoatchat/issues/682)) ([6f3441c](https://github.com/stoatchat/stoatchat/commit/6f3441cf4acac2a8e6e1bf07a279a153b80f7956))
* automatically sanitise usernames on create/update ([#689](https://github.com/stoatchat/stoatchat/issues/689)) ([e937697](https://github.com/stoatchat/stoatchat/commit/e93769786c7669485a659ee471630740d3cea702))
* blacklist private ip ranges and add january domain blocklist ([#731](https://github.com/stoatchat/stoatchat/issues/731)) ([6b41db9](https://github.com/stoatchat/stoatchat/commit/6b41db984bb491b2e58324309cc70d8c14e0b814))
* Rewrite acks ([#741](https://github.com/stoatchat/stoatchat/issues/741)) ([ab5bd47](https://github.com/stoatchat/stoatchat/commit/ab5bd47a39ee889de0b5ae6e7b560620853daead))
### Bug Fixes
* add new_user_hours to configuration limits ([#729](https://github.com/stoatchat/stoatchat/issues/729)) ([279f5d5](https://github.com/stoatchat/stoatchat/commit/279f5d5fd7af2df55902c706859ec07f569cdb1e))
* add reconnection policy to Redis subscriber to prevent ghost state ([#708](https://github.com/stoatchat/stoatchat/issues/708)) ([057f2bb](https://github.com/stoatchat/stoatchat/commit/057f2bb8b359f8b942741a30ff54eeb8fbe3e0b1))
* docker compose file had personal url in it ([#742](https://github.com/stoatchat/stoatchat/issues/742)) ([0719985](https://github.com/stoatchat/stoatchat/commit/0719985ac5636590f91e6f9ec4b68f3eded70c13))
* don't strip ICC from exif ([#735](https://github.com/stoatchat/stoatchat/issues/735)) ([d76a711](https://github.com/stoatchat/stoatchat/commit/d76a71141f3e508f6308ba52fa28eaeb56fb3438))
* dont send notification in fcm ([#721](https://github.com/stoatchat/stoatchat/issues/721)) ([89171e9](https://github.com/stoatchat/stoatchat/commit/89171e9bd0f15711157e78c6eec0fe7b480de93a))
* encode filenames in redirects ([#737](https://github.com/stoatchat/stoatchat/issues/737)) ([9fd7128](https://github.com/stoatchat/stoatchat/commit/9fd7128f800badbd184baf943d4f799e601201e4))
* january ip redirects & domain resolver ([#738](https://github.com/stoatchat/stoatchat/issues/738)) ([356491e](https://github.com/stoatchat/stoatchat/commit/356491e934b274f9e895df883dd63ef0b3123510))
* update message length validation to remove upper limit ([#723](https://github.com/stoatchat/stoatchat/issues/723)) ([ed4fd5e](https://github.com/stoatchat/stoatchat/commit/ed4fd5ebfe6d0ea534a0898da4afdc1f4e2cd6c5))
* use correct response for NoEffect errors ([#732](https://github.com/stoatchat/stoatchat/issues/732)) ([5378cd2](https://github.com/stoatchat/stoatchat/commit/5378cd22b4c7d85f44c31a6af0dda00941b80d5c))
## [0.12.1](https://github.com/stoatchat/stoatchat/compare/v0.12.0...v0.12.1) (2026-04-10)
### Bug Fixes
* add migration to update existing files to be animated ([#705](https://github.com/stoatchat/stoatchat/issues/705)) ([f2c056a](https://github.com/stoatchat/stoatchat/commit/f2c056a1515be493b195f3f5db5886c2ddf36700))
* don't send self dm notifications ([#706](https://github.com/stoatchat/stoatchat/issues/706)) ([f30b729](https://github.com/stoatchat/stoatchat/commit/f30b729ca90d0be6853c57ab4935694e5e59ae56))
* mise start + missing docker image ([#564](https://github.com/stoatchat/stoatchat/issues/564)) ([fb8fe16](https://github.com/stoatchat/stoatchat/commit/fb8fe1655776791421284a6a093e86f0320c258a))
* test failure due to wrong assertion ([#707](https://github.com/stoatchat/stoatchat/issues/707)) ([f81e329](https://github.com/stoatchat/stoatchat/commit/f81e3291bdd57af9ceedb2987b111acc7051d69c))
## [0.12.0](https://github.com/stoatchat/stoatchat/compare/v0.11.5...v0.12.0) (2026-03-28)
### Features
* add bug report template for issue tracking ([#627](https://github.com/stoatchat/stoatchat/issues/627)) ([f777e28](https://github.com/stoatchat/stoatchat/commit/f777e2863c6ca50057c8b5d0a5be14915d287724))
* Add slowmode functionality to text channels ([#680](https://github.com/stoatchat/stoatchat/issues/680)) ([6107f24](https://github.com/stoatchat/stoatchat/commit/6107f242fd3ebaff71a15f9a16330ffbcb4f2d7b))
* Allow restricting server creation to specific users ([#685](https://github.com/stoatchat/stoatchat/issues/685)) ([edfa97d](https://github.com/stoatchat/stoatchat/commit/edfa97db108c9c81828547f98a1db5315cb5ba4a))
* compute thumbhash for images ([#596](https://github.com/stoatchat/stoatchat/issues/596)) ([c2d4369](https://github.com/stoatchat/stoatchat/commit/c2d4369e160f32d79bce0a0b0f14677f89de3669))
* Detect animation in image files for fetch_preview ([#574](https://github.com/stoatchat/stoatchat/issues/574)) ([3fa0abf](https://github.com/stoatchat/stoatchat/commit/3fa0abf47f5f42ddd8ee041fe4c44fbc5ba800c1))
* expose global and user limits in root API response ([#644](https://github.com/stoatchat/stoatchat/issues/644)) ([0b522eb](https://github.com/stoatchat/stoatchat/commit/0b522ebddc17f2e3f792ff5e2347793e9849fa23))
* implement time based message sweep on user ban ([#670](https://github.com/stoatchat/stoatchat/issues/670)) ([98c7b1b](https://github.com/stoatchat/stoatchat/commit/98c7b1b5a5b9fdac5c0ab83be10f0e23114dbfc9))
* load config from env vars ([#576](https://github.com/stoatchat/stoatchat/issues/576)) ([5191bd1](https://github.com/stoatchat/stoatchat/commit/5191bd16b2a905b8409838e34eb0baca96f08580))
* parse message push notification content and replace internal formatting ([#693](https://github.com/stoatchat/stoatchat/issues/693)) ([d1e72ce](https://github.com/stoatchat/stoatchat/commit/d1e72cee42c54e16f4e49af569897528b10a28ca))
* Transfer ownership ([#396](https://github.com/stoatchat/stoatchat/issues/396)) ([735d644](https://github.com/stoatchat/stoatchat/commit/735d644e043793cb86e74aab5b88bb4b8bc17ba2))
* update livekit ([#698](https://github.com/stoatchat/stoatchat/issues/698)) ([f181edc](https://github.com/stoatchat/stoatchat/commit/f181edc8f2ff3ce4b6d48938dfc73931ecfa2279))
### Bug Fixes
* add flag for disabling events instead of commenting them out ([#695](https://github.com/stoatchat/stoatchat/issues/695)) ([a5cd08a](https://github.com/stoatchat/stoatchat/commit/a5cd08a655dece4269f3ac84fa2387ae356709a5))
* add masquerade permission to default direct message settings ([#665](https://github.com/stoatchat/stoatchat/issues/665)) ([ab52569](https://github.com/stoatchat/stoatchat/commit/ab525699bd6663333f0e9fed6d2455e482e6a09f))
* Check for appropriate permission for removing other users avatar ([#657](https://github.com/stoatchat/stoatchat/issues/657)) ([d56135e](https://github.com/stoatchat/stoatchat/commit/d56135e0cbc713884c9378832952f7ad490fa315))
* default video resolution is a non-existent size ([#601](https://github.com/stoatchat/stoatchat/issues/601)) ([0698e11](https://github.com/stoatchat/stoatchat/commit/0698e115e8d003d615e468c4fb9654e6bbc9107f)), closes [#588](https://github.com/stoatchat/stoatchat/issues/588)
* **docs:** Update GitHub links ([#647](https://github.com/stoatchat/stoatchat/issues/647)) ([b830631](https://github.com/stoatchat/stoatchat/commit/b830631bd25a546844b7bdd30386084bb365e4de))
* don't use a bitop for OR ([#676](https://github.com/stoatchat/stoatchat/issues/676)) ([5701b5c](https://github.com/stoatchat/stoatchat/commit/5701b5c18c513f796af365169ceaea372a22638c))
* Fix typo for p256dh in vapid notification flow ([#622](https://github.com/stoatchat/stoatchat/issues/622)) ([a80ad1c](https://github.com/stoatchat/stoatchat/commit/a80ad1cbe58b8af5e45751e51d94d93c1cea1c9f))
* improve generated openapi.json ([#584](https://github.com/stoatchat/stoatchat/issues/584)) ([52ed510](https://github.com/stoatchat/stoatchat/commit/52ed5100c2446e0b261085639e123e7e124cab2c))
* no node state set on channel creation ([#653](https://github.com/stoatchat/stoatchat/issues/653)) ([24d0d2b](https://github.com/stoatchat/stoatchat/commit/24d0d2b7266f6f8a692d0a52704acfecf517674c))
* only show first line on commit messages ([#696](https://github.com/stoatchat/stoatchat/issues/696)) ([91783b9](https://github.com/stoatchat/stoatchat/commit/91783b906697fc85305dee683f7c15dda55f0c50))
* pass &str to Reference ([#697](https://github.com/stoatchat/stoatchat/issues/697)) ([ccda6f5](https://github.com/stoatchat/stoatchat/commit/ccda6f5c53ee043705f7ff6b5f6c393f020781de))
* redis_url vs redis_uri in config ([#666](https://github.com/stoatchat/stoatchat/issues/666)) ([b0b728f](https://github.com/stoatchat/stoatchat/commit/b0b728fb0dbc9ee28360301de1c3ea501bbbff1d))
* replace some links and Revolt mentions to current Stoat ([#515](https://github.com/stoatchat/stoatchat/issues/515)) ([d629e89](https://github.com/stoatchat/stoatchat/commit/d629e89304be2f0011e189293b278f07d346aa7d))
* send push notifications for DM and group messages ([#660](https://github.com/stoatchat/stoatchat/issues/660)) ([52c0d2f](https://github.com/stoatchat/stoatchat/commit/52c0d2f266b76d8975bba2d5e75c62bb30149c45))
* store server id in redis and in room metadata to be able to delete voice state in all scenarios ([#656](https://github.com/stoatchat/stoatchat/issues/656)) ([49c6289](https://github.com/stoatchat/stoatchat/commit/49c628958070e4f0a5edc764d3b48158589219d9))
* uname is missing from crond ([#675](https://github.com/stoatchat/stoatchat/issues/675)) ([dc4438b](https://github.com/stoatchat/stoatchat/commit/dc4438bc3c7b2cad8d442b3cd438afb9ed566a5e))
## [0.11.5](https://github.com/stoatchat/stoatchat/compare/v0.11.4...v0.11.5) (2026-02-17)
### Reverts
* disable user update events ([#593](https://github.com/stoatchat/stoatchat/issues/593)) ([1c98ead](https://github.com/stoatchat/stoatchat/commit/1c98ead69579b4700be0b51c9020bb8402336cc6))
## [0.11.4](https://github.com/stoatchat/stoatchat/compare/v0.11.3...v0.11.4) (2026-02-16)
### Bug Fixes
* add separate config option for redis events replica url ([#590](https://github.com/stoatchat/stoatchat/issues/590)) ([a75e4ea](https://github.com/stoatchat/stoatchat/commit/a75e4eabfc4b34aba7620c82ba77558a32d9e10a))
## [0.11.3](https://github.com/stoatchat/stoatchat/compare/v0.11.2...v0.11.3) (2026-02-13)
### Bug Fixes
* cut presence traffic too while we engineer a new events architecture ([#561](https://github.com/stoatchat/stoatchat/issues/561)) ([1f8ea96](https://github.com/stoatchat/stoatchat/commit/1f8ea963ad742f693f405e6438f1c343c81e6579))
## [0.11.2](https://github.com/stoatchat/stoatchat/compare/v0.11.1...v0.11.2) (2026-02-13)
### Bug Fixes
* cut events traffic while we engineer a new events architecture ([#559](https://github.com/stoatchat/stoatchat/issues/559)) ([a11986b](https://github.com/stoatchat/stoatchat/commit/a11986ba1ad16b672ff1080913a684567d88adbb))
## [0.11.1](https://github.com/stoatchat/stoatchat/compare/v0.11.0...v0.11.1) (2026-02-13)
### Bug Fixes
* bots in multiple voice channel logic ([#544](https://github.com/stoatchat/stoatchat/issues/544)) ([94cb916](https://github.com/stoatchat/stoatchat/commit/94cb916231b9b8befb2e94065917ff40815bec52))
## [0.11.0](https://github.com/stoatchat/stoatchat/compare/v0.10.3...v0.11.0) (2026-02-10)

4774
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
[workspace]
resolver = "2"
resolver = "3"
members = [
"crates/delta",
@@ -20,24 +20,130 @@ lto = true
[workspace.dependencies]
# Async
async-trait = "0.1.89"
tokio = { version = "1.49.0", features = ["macros", "rt"] }
tokio = "1.49.0"
async-channel = "2.3.1"
futures = "0.3.32"
async-std = "1.8.0"
async-tungstenite = "0.17.0"
futures-locks = "0.7.1"
async-lock = "2.8.0"
async-recursion = "1.0.4"
# Error Handling
anyhow = "1.0.100"
thiserror = "2.0.18"
sentry = "0.31.5"
sentry-anyhow = "0.38.1"
# Other Utilities
uuid = { version = "1.19.0", features = ["v4"] }
# Data Validation
regex = "1.12.3"
validator = "0.16"
# Data Types
uuid = "1.19.0"
ulid = "1.2.1"
nanoid = "0.4.0"
typenum = "1.17.0"
num_enum = "0.6.1"
bitfield = "0.13.2"
# Time
chrono = "0.4.15"
iso8601-timestamp = "0.2.10"
# Data Collections
lru = "0.16.3"
indexmap = "2.13.1"
dashmap = "5.2.0"
moka = "0.12.8"
lru_time_cache = "0.11.11"
deadqueue = "0.2.4"
# Web scraping
scraper = "0.20.0"
encoding_rs = "0.8.34"
# Mail
lettre = "0.10.0-alpha.4"
# HTTP Requests
reqwest = "0.13.2"
isahc = "1.7"
# Notifications
fcm_v1 = "0.3.0"
web-push = "0.10.0"
revolt_a2 = "0.10"
# Parsing
logos = "0.15"
# SVG rendering
usvg = "0.44.0"
resvg = "0.44.0"
tiny-skia = "0.11.4"
# Logging
log = "0.4.29"
pretty_env_logger = "0.4.0"
# Redis
redis-kiss = "0.1.4"
fred = "8.0.1"
# Serialisation
bincode = "1.3.3"
serde_json = "1.0.79"
rmp-serde = "1.0.0"
serde = { version = "1", features = ["derive"] }
strum_macros = "0.26.4"
# MongoDB
bson = { version = "2.1.0" }
mongodb = { version = "3.1.0" }
# S3
aws-config = "1.5.5"
aws-sdk-s3 = "1.46.0"
# Axum (HTTP server)
axum-macros = "0.4.1"
axum_typed_multipart = "0.12.1"
axum = { version = "0.7.5", features = ["multipart"] }
tower-http = { version = "0.5.2", features = ["cors", "trace"] }
axum = "0.7.5"
axum-extra = "0.9"
tower-http = "0.5.2"
# Rocket (HTTP server)
rocket = "0.5.1"
rocket_empty = "0.1.1"
revolt_rocket_okapi = "0.10.0"
rocket_cors = { git = "https://github.com/lawliet89/rocket_cors", rev = "072d90359b23e9b291df6b672c07c93de9c46011" }
rocket_authifier = "1.0.16"
rocket_prometheus = "0.10.0-rc.3"
# Spec Generation
utoipa = "4.2.3"
revolt_okapi = "0.9.1"
schemars = "0.8.8"
utoipa-scalar = "0.1.0"
# Image Processing
jxl-oxide = { version = "0.12.5", features = ["image"] }
image = "0.25.9"
jxl-oxide = "0.12.5"
sha2 = "0.10.8"
kamadak-exif = "0.5.4"
webp = "0.3.0"
image = "0.25.2" # avif encode requires dav1d system library: features = ["avif-native"]
thumbhash = "0.1.0"
lcms2 = "6.1.1" # for color profile processing
# File processing
revolt_clamav-client = "0.1.5"
simdutf8 = "0.1.4"
# Content type processing
infer = "0.16.0"
ffprobe = "0.4.0"
imagesize = "0.13.0"
# OpenTelemetry
tracing = "0.1.44"
@@ -47,4 +153,52 @@ tracing-subscriber = { version = "0.3.22", features = [
opentelemetry = { version = "0.31.0", features = ["logs"] }
opentelemetry_sdk = { version = "0.31.0", features = ["logs"] }
opentelemetry-otlp = { version = "0.31.0", features = ["logs"] }
opentelemetry-appender-tracing = { version = "0.31.1" }
opentelemetry-appender-tracing = "0.31.1"
# Authifier
authifier = "1.0.16"
# RabbitMQ
amqprs = "1.7.0"
lapin = "4.7.1"
# Voice
livekit-api = "0.4.4"
livekit-protocol = "0.7.4"
livekit-runtime = "0.4.0"
# Other Utilities
once_cell = "1.9.0"
config = "0.13.3"
cached = "0.44.0"
rand = "0.8.5"
base64 = "0.21.3"
decancer = "3.3.3"
linkify = "0.8.1"
url-escape = "0.1.1"
revolt_optional_struct = "0.2.0"
unicode-segmentation = "1.10.1"
querystring = "1.1.0"
tempfile = "3.12.0"
aes-gcm = "0.10.3"
auto_ops = "0.3.0"
url = "2.2.2"
impl_ops = "0.1.1"
lazy_static = "1.5.0"
mime = "0.3.17"
futures-lite = "2.6.1"
# Build Dependencies
vergen = "7.5.0"
# Local packages
revolt-coalesced = { version = "0.13.4", path = "crates/core/coalesced" }
revolt-config = { version = "0.13.4", path = "crates/core/config" }
revolt-database = { version = "0.13.4", path = "crates/core/database" }
revolt-files = { version = "0.13.4", path = "crates/core/files" }
revolt-models = { version = "0.13.4", path = "crates/core/models" }
revolt-parser = { version = "0.13.4", path = "crates/core/parser" }
revolt-permissions = { version = "0.13.4", path = "crates/core/permissions" }
revolt-presence = { version = "0.13.4", path = "crates/core/presence" }
revolt-ratelimits = { version = "0.13.4", path = "crates/core/ratelimits" }
revolt-result = { version = "0.13.4", path = "crates/core/result" }

View File

@@ -2,14 +2,14 @@
<h1>
Stoat Backend
[![Stars](https://img.shields.io/github/stars/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/stargazers)
[![Forks](https://img.shields.io/github/forks/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/network/members)
[![Pull Requests](https://img.shields.io/github/issues-pr/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/pulls)
[![Issues](https://img.shields.io/github/issues/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/issues)
[![Contributors](https://img.shields.io/github/contributors/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/graphs/contributors)
[![License](https://img.shields.io/github/license/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/blob/main/LICENSE)
[![Stars](https://img.shields.io/github/stars/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/stargazers)
[![Forks](https://img.shields.io/github/forks/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/network/members)
[![Pull Requests](https://img.shields.io/github/issues-pr/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/pulls)
[![Issues](https://img.shields.io/github/issues/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/issues)
[![Contributors](https://img.shields.io/github/contributors/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/graphs/contributors)
[![License](https://img.shields.io/github/license/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/blob/main/LICENSE)
</h1>
The services and libraries that power the Revolt service.<br/>
The services and libraries that power the Stoat service.<br/>
<br/>
| Crate | Path | Description | |
@@ -39,7 +39,7 @@ Rust 1.86.0 or higher.
## Development Guide
Before contributing, make yourself familiar with [our contribution guidelines](https://developers.revolt.chat/contrib.html) and the [technical documentation for this project](https://revoltchat.github.io/backend/).
Before contributing, make yourself familiar with [our contribution guidelines](https://developers.stoat.chat/developing/contrib/) and the [technical documentation for this project](https://developers.stoat.chat/).
Before getting started, you'll want to install:
@@ -70,11 +70,20 @@ As a heads-up, the development environment uses the following ports:
Now you can clone and build the project:
```bash
git clone https://github.com/revoltchat/backend revolt-backend
cd revolt-backend
git clone https://github.com/stoatchat/stoatchat stoat-backend
cd stoat-backend
mise install
mise build
```
> [!TIP]
> You can override `BUILDER` in your `.env` file to run cargo with mold if you installed it:
>
> ```bash
> # .env
> BUILDER = "mold --run cargo"
> ```
A default configuration `Revolt.toml` is present in this project that is suited for development.
If you'd like to change anything, create a `Revolt.overrides.toml` file and specify relevant variables.
@@ -111,7 +120,7 @@ If you'd like to change anything, create a `Revolt.overrides.toml` file and spec
> - "14672:15672"
> ```
>
> And corresponding Revolt configuration:
> With the corresponding Revolt configuration:
>
> ```toml
> # Revolt.overrides.toml
@@ -123,53 +132,41 @@ If you'd like to change anything, create a `Revolt.overrides.toml` file and spec
> [rabbit]
> port = 14072
> ```
>
> And mise configuration
>
> ```bash
> #.env
> DATABASE_PORT = "14017"
> RABBIT_PORT = "14072"
> REDIS_PORT = "14079"
> ```
Then continue:
```bash
# start other necessary services
docker compose up -d
cp livekit.example.yml livekit.yml
# run everything together
./scripts/start.sh
# .. or individually
# run the API server
cargo run --bin revolt-delta
# run the events server
cargo run --bin revolt-bonfire
# run the file server
cargo run --bin revolt-autumn
# run the proxy server
cargo run --bin revolt-january
# run the tenor proxy
cargo run --bin revolt-gifbox
# run the push daemon (not usually needed in regular development)
cargo run --bin revolt-pushd
# hint:
# mold -run <cargo build, cargo run, etc...>
# mold -run ./scripts/start.sh
mise start
```
You can start a web client by doing the following:
You can start a web client by doing the following in another terminal:
```bash
# if you do not have yarn yet and have a modern Node.js:
corepack enable
# clone the web client and run it:
git clone --recursive https://github.com/revoltchat/revite
cd revite
yarn
yarn build:deps
echo "VITE_API_URL=http://local.revolt.chat:14702" > .env.local
yarn dev --port 14701
git clone --recursive https://github.com/stoatchat/for-web stoat-web
cd stoat-web
# refer to stoat-web/README.md for startup, creating an account and loging in
```
Then go to http://local.revolt.chat:14701 to create an account/login.
When signing up, go to http://localhost:14080 to find confirmation/password reset emails.
To stop all services, hit (CTRL + c) in the terminal you ran `mise start` and run `mise docker:stop`
## Deployment Guide
### Cutting new crate releases
@@ -198,14 +195,14 @@ Tag and push a new release by running:
just release
```
If you have bumped the crate versions, proceed to [GitHub releases](https://github.com/revoltchat/backend/releases/new) to create a changelog.
If you have bumped the crate versions, proceed to [GitHub releases](https://github.com/stoatchat/stoatchat/releases/new) to create a changelog.
## Testing
First, start the required services:
```sh
docker compose -f docker-compose.db.yml up -d
docker compose up -d
```
Now run tests for whichever database:
@@ -217,6 +214,6 @@ TEST_DB=MONGODB cargo nextest run
## License
The Revolt backend is generally licensed under the [GNU Affero General Public License v3.0](https://github.com/revoltchat/backend/blob/master/LICENSE).
The Stoat backend is generally licensed under the [GNU Affero General Public License v3.0](https://github.com/stoatchat/stoatchat/blob/main/LICENSE).
**Individual crates may supply their own licenses!**

View File

@@ -15,7 +15,7 @@ host = "127.0.0.1"
[hosts]
# Web locations of various services
# Defaults assume all services are reverse-proxied
# See https://github.com/revoltchat/self-hosted/blob/master/Caddyfile
# See https://github.com/stoatchat/self-hosted/blob/main/Caddyfile
#
# Remember to change these to https/wss where appropriate in production!
app = "http://local.revolt.chat:14701"

View File

@@ -8,10 +8,20 @@ services:
# MongoDB
database:
image: mongo
command: mongod --replSet rs0
ports:
- "27017:27017"
volumes:
- ./.data/db:/data/db
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'127.0.0.1:27017'}]}) }" | mongosh --port 27017 --quiet
interval: 5s
timeout: 30s
start_period: 0s
start_interval: 1s
retries: 30
ulimits:
nofile:
soft: 65536
@@ -19,8 +29,8 @@ services:
# MinIO
minio:
image: minio/minio
command: server /data
image: firstfinger/minio:latest
#command: server /data
environment:
MINIO_ROOT_USER: minioautumn
MINIO_ROOT_PASSWORD: minioautumn
@@ -70,8 +80,8 @@ services:
MAILDEV_INCOMING_PASS: smtp
livekit:
image: ghcr.io/stoatchat/livekit-server:v1.9.9
image: ghcr.io/stoatchat/livekit-server:v1.9.13
command: --config /etc/livekit.yml
network_mode: "host"
volumes:
- ./livekit.yml:/etc/livekit.yml
- ./livekit.yml:/etc/livekit.yml

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-bonfire"
version = "0.11.0"
version = "0.13.4"
license = "AGPL-3.0-or-later"
edition = "2021"
publish = false
@@ -9,42 +9,38 @@ publish = false
[dependencies]
# util
log = "*"
sentry = "0.31.5"
lru = "0.7.6"
ulid = "0.5.0"
once_cell = "1.9.0"
redis-kiss = "0.1.4"
lru_time_cache = "0.11.11"
async-channel = "2.3.1"
log = { workspace = true }
sentry = { workspace = true }
lru = { workspace = true }
ulid = { workspace = true }
once_cell = { workspace = true }
redis-kiss = { workspace = true }
lru_time_cache = { workspace = true }
async-channel = { workspace = true }
# parsing
querystring = "1.1.0"
regex = "1.11.1"
querystring = { workspace = true }
regex = { workspace = true }
# serde
bincode = "1.3.3"
serde_json = "1.0.79"
rmp-serde = "1.0.0"
serde = "1.0.136"
bincode = { workspace = true }
serde_json = { workspace = true }
rmp-serde = { workspace = true }
serde = { workspace = true }
# 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",
] }
futures = { workspace = true }
async-tungstenite = { workspace = true, features = ["async-std-runtime"] }
async-std = { workspace = true }
# core
authifier = { version = "1.0.16" }
revolt-result = { path = "../core/result" }
revolt-models = { path = "../core/models" }
revolt-config = { path = "../core/config" }
revolt-database = { path = "../core/database", features = ["voice"] }
revolt-permissions = { path = "../core/permissions" }
revolt-presence = { path = "../core/presence", features = ["redis-is-patched"] }
authifier = { workspace = true }
revolt-result = { workspace = true }
revolt-models = { workspace = true }
revolt-config = { workspace = true }
revolt-database = { workspace = true, features = ["voice"] }
revolt-permissions = { workspace = true }
revolt-presence = { workspace = true, features = ["redis-is-patched"] }
# redis
fred = { version = "8.0.1", features = ["subscriber-client"] }
fred = { workspace = true, features = ["subscriber-client"] }

View File

@@ -4,7 +4,7 @@ use futures::future::join_all;
use revolt_database::{
events::client::{EventV1, ReadyPayloadFields},
util::permissions::DatabasePermissionQuery,
voice::get_channel_voice_state,
voice::{get_channel_voice_state, UserVoiceChannel},
Channel, Database, Member, MemberCompositeKey, Presence, RelationshipStatus,
};
use revolt_models::v0;
@@ -167,9 +167,13 @@ impl State {
| Channel::TextChannel { voice: Some(_), .. }
)
}) {
if let Ok(Some(voice_state)) = get_channel_voice_state(channel).await {
if let Ok(Some(voice_state)) =
get_channel_voice_state(&UserVoiceChannel::from_channel(channel)).await
{
if let Some(server) = channel.server() {
let set = voice_state_server_members.entry(server.to_string()).or_default();
let set = voice_state_server_members
.entry(server.to_string())
.or_default();
for participant in &voice_state.participants {
user_ids.insert(participant.id.clone());
@@ -397,6 +401,11 @@ impl State {
/// Push presence change to the user and all associated server topics
pub async fn broadcast_presence_change(&self, target: bool) {
let config = revolt_config::config().await;
if config.disable_events_dont_use {
return;
}
if if let Some(status) = &self.cache.users.get(&self.cache.user_id).unwrap().status {
status.presence != Some(Presence::Invisible)
} else {

View File

@@ -1,7 +1,5 @@
use std::{
collections::{HashMap, HashSet},
sync::Arc,
time::Duration,
collections::{HashMap, HashSet}, num::NonZeroUsize, sync::Arc, time::Duration
};
use async_std::sync::{Mutex, RwLock};
@@ -57,7 +55,7 @@ impl Default for Cache {
members: Default::default(),
servers: Default::default(),
seen_events: LruCache::new(20),
seen_events: LruCache::new(NonZeroUsize::new(20).unwrap()),
}
}
}

View File

@@ -5,7 +5,7 @@ use authifier::AuthifierEvent;
use fred::{
error::RedisErrorKind,
interfaces::{ClientLike, EventInterface, PubsubInterface},
types::RedisConfig,
types::{ReconnectPolicy, RedisConfig},
};
use futures::{
channel::oneshot,
@@ -218,10 +218,16 @@ async fn listener(
kill_signal_r: async_channel::Receiver<()>,
write: &Mutex<WsWriter>,
) {
let redis_config = RedisConfig::from_url(&REDIS_URI).unwrap();
let subscriber = match report_internal_error!(
fred::types::Builder::from_config(redis_config).build_subscriber_client()
) {
let stoat_config = revolt_config::config().await;
let url = stoat_config
.database
.redis_pubsub
.unwrap_or(REDIS_URI.to_string());
let redis_config = RedisConfig::from_url(&url).unwrap();
let mut builder = fred::types::Builder::from_config(redis_config);
builder.set_policy(ReconnectPolicy::new_exponential(8, 100, 30_000, 2));
let subscriber = match report_internal_error!(builder.build_subscriber_client()) {
Ok(subscriber) => subscriber,
Err(_) => return,
};
@@ -230,16 +236,21 @@ async fn listener(
return;
}
// Let Fred automatically re-subscribe to tracked channels on reconnect.
subscriber.manage_subscriptions();
// Handle Redis connection dropping
let (clean_up_s, clean_up_r) = async_channel::bounded(1);
let clean_up_s = Arc::new(Mutex::new(clean_up_s));
subscriber.on_error(move |err| {
warn!("Redis subscriber error: {:?}", err);
if let RedisErrorKind::Canceled = err.kind() {
let clean_up_s = clean_up_s.clone();
spawn(async move {
clean_up_s.lock().await.send(()).await.ok();
});
}
// Transient errors (IO, timeout) are handled by the reconnect policy.
Ok(())
});
@@ -422,6 +433,8 @@ async fn worker(
mut read: WsReader,
write: &Mutex<WsWriter>,
) {
let revolt_config = revolt_config::config().await;
loop {
let t1 = read.try_next().fuse();
let t2 = kill_signal_r.recv().fuse();
@@ -458,6 +471,10 @@ async fn worker(
match payload {
ClientMessage::BeginTyping { channel } => {
if revolt_config.disable_events_dont_use {
continue;
}
if !subscribed.read().await.contains(&channel) {
continue;
}
@@ -470,6 +487,10 @@ async fn worker(
.await;
}
ClientMessage::EndTyping { channel } => {
if revolt_config.disable_events_dont_use {
continue;
}
if !subscribed.read().await.contains(&channel) {
continue;
}
@@ -506,4 +527,4 @@ async fn worker(
}
}
}
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-coalesced"
version = "0.11.0"
version = "0.13.4"
edition = "2021"
license = "MIT"
authors = ["Paul Makles <me@insrt.uk>", "Zomatree <me@zomatree.live>"]
@@ -15,12 +15,12 @@ cache = ["dep:lru"]
default = ["tokio"]
[dependencies]
tokio = { version = "1.47.0", features = ["sync"], optional = true }
indexmap = { version = "2.13.0", optional = true }
lru = { version = "0.16.3", optional = true }
tokio = { workspace = true, features = ["sync"], optional = true }
indexmap = { workspace = true, optional = true }
lru = { workspace = true, optional = true }
[dev-dependencies]
tokio = { version = "1.47.0", features = [
tokio = { workspace = true, features = [
"rt",
"rt-multi-thread",
"macros",

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-config"
version = "0.11.0"
version = "0.13.4"
edition = "2021"
license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"]
@@ -18,24 +18,24 @@ default = ["test", "sentry"]
[dependencies]
# Utility
config = "0.13.3"
cached = "0.44.0"
once_cell = "1.18.0"
config = { workspace = true }
cached = { workspace = true }
once_cell = { workspace = true }
# Serde
serde = { version = "1", features = ["derive"] }
serde = { workspace = true }
# Async
futures-locks = "0.7.1"
async-std = { version = "1.8.0", features = ["attributes"], optional = true }
futures-locks = { workspace = true }
async-std = { workspace = true, features = ["attributes"], optional = true }
# Logging
log = "0.4.14"
pretty_env_logger = "0.4.0"
log = { workspace = true }
pretty_env_logger = { workspace = true }
# Sentry
sentry = { version = "0.31.5", optional = true }
sentry-anyhow = { version = "0.38.1", optional = true }
sentry = { workspace = true, optional = true }
sentry-anyhow = { workspace = true, optional = true }
# Core
revolt-result = { version = "0.11.0", path = "../result", optional = true }
revolt-result = { workspace = true, optional = true }

View File

@@ -1,4 +1,5 @@
production = false
disable_events_dont_use = false
[database]
# MongoDB connection URL
@@ -11,7 +12,7 @@ redis = "redis://redis/"
[hosts]
# Web locations of various services
# Defaults assume all services are reverse-proxied
# See https://github.com/revoltchat/self-hosted/blob/master/Caddyfile
# See https://github.com/stoatchat/self-hosted/blob/main/Caddyfile
#
# Remember to change these to https/wss where appropriate in production!
app = "http://local.revolt.chat"
@@ -29,12 +30,16 @@ host = "rabbit"
port = 5672
username = "rabbituser"
password = "rabbitpass"
default_exchange = "revolt.default"
[rabbit.queues]
acks = "internal.ack"
[api]
[api.registration]
# Whether an invite should be required for registration
# See https://github.com/revoltchat/self-hosted#making-your-instance-invite-only
# See https://github.com/stoatchat/self-hosted#making-your-instance-invite-only
invite_only = false
[api.smtp]
@@ -77,6 +82,8 @@ call_ring_duration = 30
[api.livekit.nodes]
[api.users]
# Minimum allowed length of usernames
min_username_length = 2
[pushd]
# this changes the names of the queues to not overlap
@@ -88,6 +95,11 @@ production = true
# Increasing this will resolve mentions faster, but will consume more memory while resolving.
mass_mention_chunk_size = 200
# How long pushd will cache resolved names for rendered message notifications.
# Increasing this will result in lower database usage, but may result in a situation where a user/channel/role name changes
# and the notifications still resolve to the old name.
render_cache_time = 60
# none of these should need changing
exchange = "revolt.notifications"
message_queue = "notifications.origin.message"
@@ -124,6 +136,8 @@ pkcs8 = ""
key_id = ""
team_id = ""
[january]
blocked_domains = []
[files]
# Encryption key for stored files
@@ -220,6 +234,10 @@ new_user_hours = 72
# (should be greater than any one file upload limit)
body_limit_size = 20_000_000
# If any userids are entered here, only those users will be able to create servers.
# Leave empty to allow all users to create servers
restrict_server_creation = []
[features.limits.new_user]
# Limits imposed on new users
@@ -244,7 +262,7 @@ voice_quality = 16000
# Whether the user can use video streams in voice calls
video = true
# Mamimum resolution (width, height) of video streams in voice calls
# Maximum resolution (width, height) of video streams in voice calls
video_resolution = [1080, 720]
# Minimum and maximum aspect ratio of video streams in voice calls
@@ -283,8 +301,8 @@ voice_quality = 16000
# Whether the user can use video streams in voice calls
video = true
# Mamimum resolution (width, height) of video streams in voice calls
video_resolution = [1080, 720]
# Maximum resolution (width, height) of video streams in voice calls
video_resolution = [1280, 720]
# Minimum and maximum aspect ratio of video streams in voice calls
video_aspect_ratio = [0.3, 2.5]
@@ -303,6 +321,12 @@ emojis = 500_000
# default: 5
process_message_delay_limit = 5
[features.legal_links]
# URLs for legal documents
terms_of_service = ""
privacy_policy = ""
guidelines = ""
[sentry]
# Configuration for Sentry error reporting
api = ""
@@ -312,4 +336,4 @@ files = ""
proxy = ""
pushd = ""
crond = ""
gifbox = ""
gifbox = ""

View File

@@ -1,7 +1,7 @@
use std::{collections::HashMap, path::Path};
use cached::proc_macro::cached;
use config::{Config, File, FileFormat};
use config::{Config, Environment, File, FileFormat};
use futures_locks::RwLock;
use once_cell::sync::Lazy;
use serde::Deserialize;
@@ -109,6 +109,8 @@ static CONFIG_BUILDER: Lazy<RwLock<Config>> = Lazy::new(|| {
cwd = path.parent();
}
builder = builder.add_source(Environment::with_prefix("REVOLT").separator("__"));
builder.build().unwrap()
})
});
@@ -117,6 +119,12 @@ static CONFIG_BUILDER: Lazy<RwLock<Config>> = Lazy::new(|| {
pub struct Database {
pub mongodb: String,
pub redis: String,
pub redis_pubsub: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct RabbitQueues {
pub acks: String,
}
#[derive(Deserialize, Debug, Clone)]
@@ -125,6 +133,8 @@ pub struct Rabbit {
pub port: u16,
pub username: String,
pub password: String,
pub default_exchange: String,
pub queues: RabbitQueues,
}
#[derive(Deserialize, Debug, Clone)]
@@ -228,6 +238,7 @@ pub struct LiveKitNode {
#[derive(Deserialize, Debug, Clone)]
pub struct ApiUsers {
pub early_adopter_cutoff: Option<u64>,
pub min_username_length: usize,
}
#[derive(Deserialize, Debug, Clone)]
@@ -245,6 +256,7 @@ pub struct Pushd {
pub production: bool,
pub exchange: String,
pub mass_mention_chunk_size: usize,
pub render_cache_time: usize,
// Queues
pub message_queue: String,
@@ -297,6 +309,11 @@ impl Pushd {
}
}
#[derive(Deserialize, Debug, Clone)]
pub struct January {
pub blocked_domains: Vec<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FilesLimit {
pub min_file_size: usize,
@@ -341,6 +358,8 @@ pub struct GlobalLimits {
pub new_user_hours: usize,
pub body_limit_size: usize,
pub restrict_server_creation: Vec<String>,
}
#[derive(Deserialize, Debug, Clone)]
@@ -370,6 +389,16 @@ pub struct FeaturesLimitsCollection {
pub roles: HashMap<String, FeaturesLimits>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct LegalLinks {
/// Terms of Service URL
pub terms_of_service: String,
/// Privacy Policy URL
pub privacy_policy: String,
/// Guidelines URL
pub guidelines: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FeaturesAdvanced {
#[serde(default)]
@@ -387,6 +416,7 @@ impl Default for FeaturesAdvanced {
#[derive(Deserialize, Debug, Clone)]
pub struct Features {
pub limits: FeaturesLimitsCollection,
pub legal_links: LegalLinks,
pub webhooks_enabled: bool,
pub mass_mentions_send_notifications: bool,
pub mass_mentions_enabled: bool,
@@ -414,10 +444,12 @@ pub struct Settings {
pub hosts: Hosts,
pub api: Api,
pub pushd: Pushd,
pub january: January,
pub files: Files,
pub features: Features,
pub sentry: Sentry,
pub production: bool,
pub disable_events_dont_use: bool,
}
impl Settings {
@@ -448,12 +480,14 @@ pub async fn config() -> Settings {
let mut config = read().await.try_deserialize::<Settings>().unwrap();
// inject REDIS_URI for redis-kiss library
if std::env::var("REDIS_URL").is_err() {
if std::env::var("REDIS_URI").is_err() {
std::env::set_var("REDIS_URI", config.database.redis.clone());
}
// auto-detect production nodes
if config.hosts.api.contains("https") && config.hosts.api.contains("revolt.chat") {
if config.hosts.api.contains("https")
&& (config.hosts.api.contains("revolt.chat") || config.hosts.api.contains("stoat.chat"))
{
config.production = true;
}

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-database"
version = "0.11.0"
version = "0.13.4"
edition = "2021"
license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"]
@@ -32,80 +32,72 @@ default = ["mongodb", "async-std-runtime", "tasks"]
[dependencies]
# Core
revolt-config = { version = "0.11.0", path = "../config", features = [
"report-macros",
] }
revolt-result = { version = "0.11.0", path = "../result" }
revolt-models = { version = "0.11.0", path = "../models", features = [
"validator",
] }
revolt-presence = { version = "0.11.0", path = "../presence" }
revolt-permissions = { version = "0.11.0", path = "../permissions", features = [
"serde",
"bson",
] }
revolt-parser = { version = "0.11.0", path = "../parser" }
revolt-config = { workspace = true, features = ["report-macros"] }
revolt-result = { workspace = true }
revolt-models = { workspace = true, features = ["validator"] }
revolt-presence = { workspace = true }
revolt-permissions = { workspace = true, features = ["serde", "bson"] }
revolt-parser = { workspace = true }
revolt-coalesced = { workspace = true }
# Utility
log = "0.4"
lru = "0.11.0"
rand = "0.8.5"
ulid = "1.0.0"
nanoid = "0.4.0"
base64 = "0.21.3"
once_cell = "1.17"
indexmap = "1.9.1"
decancer = "1.6.2"
deadqueue = "0.2.4"
linkify = { optional = true, version = "0.8.1" }
url-escape = { optional = true, version = "0.1.1" }
validator = { version = "0.16", features = ["derive"] }
isahc = { optional = true, version = "1.7", features = ["json"] }
log = { workspace = true }
lru = { workspace = true }
rand = { workspace = true }
ulid = { workspace = true }
nanoid = { workspace = true }
base64 = { workspace = true }
once_cell = { workspace = true }
indexmap = { workspace = true }
decancer = { workspace = true }
deadqueue = { workspace = true }
linkify = { workspace = true, optional = true }
url-escape = { workspace = true, optional = true }
validator = { workspace = true, features = ["derive"] }
isahc = { workspace = true, features = ["json"], optional = true }
# Serialisation
serde_json = "1"
revolt_optional_struct = "0.2.0"
serde = { version = "1", features = ["derive"] }
iso8601-timestamp = { version = "0.2.10", features = ["serde", "bson"] }
serde_json = { workspace = true }
revolt_optional_struct = { workspace = true }
serde = { workspace = true }
iso8601-timestamp = { workspace = true, features = ["serde", "bson"] }
# Events
redis-kiss = { version = "0.1.4" }
redis-kiss = { workspace = true }
# Database
bson = { optional = true, version = "2.1.0" }
mongodb = { optional = true, version = "3.1.0" }
bson = { workspace = true, optional = true }
mongodb = { workspace = true, optional = true }
# Database Migration
unicode-segmentation = "1.10.1"
regex = "1"
unicode-segmentation = { workspace = true }
regex = { workspace = true }
# Async Language Features
futures = "0.3.19"
async-lock = "2.8.0"
async-trait = "0.1.51"
async-recursion = "1.0.4"
futures = { workspace = true }
async-lock = { workspace = true }
async-trait = { workspace = true }
async-recursion = { workspace = true }
# Async
async-std = { version = "1.8.0", features = ["attributes"], optional = true }
async-std = { workspace = true, features = ["attributes"], optional = true }
# Axum Impl
axum = { version = "0.7.5", optional = true }
axum = { workspace = true, optional = true }
# Rocket Impl
schemars = { version = "0.8.8", optional = true }
rocket = { version = "0.5.1", default-features = false, features = [
"json",
], optional = true }
revolt_okapi = { version = "0.9.1", optional = true }
revolt_rocket_okapi = { version = "0.10.0", optional = true }
schemars = { workspace = true, optional = true }
rocket = { workspace = true, features = ["json"], optional = true }
revolt_okapi = { workspace = true, optional = true }
revolt_rocket_okapi = { workspace = true, optional = true }
# Authifier
authifier = { version = "1.0.16" }
authifier = { workspace = true }
# RabbitMQ
amqprs = { version = "1.7.0" }
amqprs = { workspace = true }
# Voice
livekit-api = { version = "0.4.4", optional = true }
livekit-protocol = { version = "0.4.0", optional = true }
livekit-runtime = { version = "0.3.1", features = ["tokio"], optional = true }
livekit-api = { workspace = true, features = ["rustls-tls-native-roots"], optional = true }
livekit-protocol = { workspace = true, optional = true }
livekit-runtime = { workspace = true, features = ["tokio"], optional = true }

View File

@@ -2,7 +2,10 @@ use std::collections::HashSet;
use crate::events::rabbit::*;
use crate::User;
use amqprs::channel::{BasicPublishArguments, ExchangeDeclareArguments};
use amqprs::channel::{
BasicPublishArguments, ExchangeDeclareArguments, ExchangeType, QueueBindArguments,
QueueDeclareArguments,
};
use amqprs::connection::OpenConnectionArguments;
use amqprs::{channel::Channel, connection::Connection, error::Error as AMQPError};
use amqprs::{BasicProperties, FieldTable};
@@ -26,7 +29,7 @@ impl AMQP {
}
}
pub async fn new_auto() -> AMQP {
pub async fn new_auto() -> revolt_result::Result<AMQP> {
let config = revolt_config::config().await;
let connection = Connection::open(&OpenConnectionArguments::new(
@@ -43,16 +46,58 @@ impl AMQP {
.await
.expect("Failed to open RabbitMQ channel");
channel
let mut resp = AMQP::new(connection, channel);
//resp.configure_channels().await?;
Ok(resp)
}
pub async fn repoen_channel(&mut self) {
self.channel = self
.connection
.open_channel(None)
.await
.expect("Failed to open RabbitMQ channel");
}
pub async fn configure_channels(&mut self) -> revolt_result::Result<()> {
let config = revolt_config::config().await;
if !self.channel.is_open() {
self.repoen_channel().await;
}
self.channel
.exchange_declare(
ExchangeDeclareArguments::new(&config.pushd.exchange, "direct")
.durable(true)
.finish(),
ExchangeDeclareArguments::new(
&config.rabbit.default_exchange,
&ExchangeType::Topic.to_string(),
)
.durable(true)
.finish(),
)
.await
.expect("Failed to declare exchange");
AMQP::new(connection, channel)
// Configure acks channel & routing
self.channel
.queue_declare(
QueueDeclareArguments::new(&config.rabbit.queues.acks)
.durable(true)
.no_wait(true)
.finish(),
)
.await
.expect("Failed to bind queue");
self.channel
.queue_bind(QueueBindArguments::new(
&config.rabbit.queues.acks,
&config.rabbit.default_exchange,
&config.rabbit.queues.acks,
))
.await
.expect("Failed to bind channel");
Ok(())
}
pub async fn friend_request_accepted(
@@ -232,7 +277,9 @@ impl AMQP {
.await
}
pub async fn ack_message(
/// # Sends an ack to pushd to update badges on iPhones.
/// Not to be confused with the process_ack function, which handles sending all acks to crond for processing.
pub async fn ack_notification_message(
&self,
user_id: String,
channel_id: String,
@@ -316,4 +363,41 @@ impl AMQP {
)
.await
}
/// # Send an ack to crond for processing
pub async fn process_ack(
&self,
user_id: &str,
channel_id: Option<&str>,
server_id: Option<&str>,
) -> Result<(), AMQPError> {
let config = revolt_config::config().await;
let payload = AckEventPayload {
user_id: user_id.to_string(),
channel_id: channel_id.map(|value| value.to_string()),
server_id: server_id.map(|value| value.to_string()),
};
let payload = to_string(&payload).unwrap();
info!(
"Sending ack processor event on exchange {}, channel {}: {}",
config.rabbit.default_exchange, config.rabbit.queues.acks, payload
);
self.channel
.basic_publish(
BasicProperties::default()
.with_content_type("application/json")
.with_persistence(true)
//.with_headers(headers)
.finish(),
payload.into(),
BasicPublishArguments::new(
&config.rabbit.default_exchange,
&config.rabbit.queues.acks,
),
)
.await
}
}

View File

@@ -3,7 +3,7 @@ use revolt_result::Error;
use serde::{Deserialize, Serialize};
use revolt_models::v0::{
AppendMessage, Channel, ChannelUnread, ChannelVoiceState, Emoji, FieldsChannel, FieldsMember, FieldsMessage, FieldsRole, FieldsServer, FieldsUser, FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser, PartialUserVoiceState, PartialWebhook, PolicyChange, RemovalIntention, Report, Server, User, UserSettings, UserVoiceState, Webhook
AppendMessage, Channel, ChannelUnread, ChannelVoiceState, Emoji, FieldsChannel, FieldsMember, FieldsMessage, FieldsRole, FieldsServer, FieldsUser, FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialEmoji, PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser, PartialUserVoiceState, PartialWebhook, PolicyChange, RemovalIntention, Report, Server, User, UserSettings, UserVoiceState, Webhook
};
use crate::Database;
@@ -219,6 +219,12 @@ pub enum EventV1 {
/// New emoji
EmojiCreate(Emoji),
/// Update existing emoji
EmojiUpdate {
id: String,
data: PartialEmoji,
},
/// Delete emoji
EmojiDelete { id: String },

View File

@@ -78,3 +78,11 @@ pub struct AckPayload {
pub channel_id: String,
pub message_id: String,
}
/// This is not the same as the AckPayload above, as the state for this event is stored in redis to allow for state updates while the event is queued.
#[derive(Serialize, Deserialize, Debug)]
pub struct AckEventPayload {
pub user_id: String,
pub channel_id: Option<String>,
pub server_id: Option<String>,
}

View File

@@ -1,6 +1,7 @@
#![allow(deprecated)]
use std::{borrow::Cow, collections::HashMap};
use redis_kiss::get_connection;
use revolt_config::config;
use revolt_models::v0::{self, MessageAuthor};
use revolt_permissions::OverrideField;
@@ -110,6 +111,10 @@ auto_derived!(
/// Voice Information for when this channel is also a voice channel
#[serde(skip_serializing_if = "Option::is_none")]
voice: Option<VoiceInformation>,
/// The channel's slowmode delay in seconds
#[serde(skip_serializing_if = "Option::is_none")]
slowmode: Option<u64>,
},
}
@@ -146,6 +151,8 @@ auto_derived!(
pub last_message_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub voice: Option<VoiceInformation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub slowmode: Option<u64>,
}
/// Optional fields on channel object
@@ -206,6 +213,7 @@ impl Channel {
role_permissions: HashMap::new(),
nsfw: data.nsfw.unwrap_or(false),
voice: data.voice.map(|voice| voice.into()),
slowmode: None,
},
v0::LegacyServerChannelType::Voice => Channel::TextChannel {
id: id.clone(),
@@ -218,6 +226,7 @@ impl Channel {
role_permissions: HashMap::new(),
nsfw: data.nsfw.unwrap_or(false),
voice: Some(data.voice.unwrap_or_default().into()),
slowmode: None,
},
};
@@ -635,7 +644,7 @@ impl Channel {
}
/// Acknowledge a message
pub async fn ack(&self, user: &str, message: &str) -> Result<()> {
pub async fn ack(&self, user: &str, message: &str, amqp: &AMQP) -> Result<()> {
EventV1::ChannelAck {
id: self.id().to_string(),
user: user.to_string(),
@@ -644,17 +653,7 @@ impl Channel {
.private(user.to_string())
.await;
#[cfg(feature = "tasks")]
crate::tasks::ack::queue_ack(
self.id().to_string(),
user.to_string(),
crate::tasks::ack::AckEvent::AckMessage {
id: message.to_string(),
},
)
.await;
Ok(())
crate::util::acker::ack_channel(user, self.id(), message, amqp).await
}
/// Remove user from a group

View File

@@ -2,6 +2,7 @@ use std::collections::HashSet;
use std::str::FromStr;
use once_cell::sync::Lazy;
use revolt_models::v0;
use revolt_result::Result;
use ulid::Ulid;
@@ -41,6 +42,12 @@ auto_derived!(
Server { id: String },
Detached,
}
/// Partial representation of an emoji
pub struct PartialEmoji {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
);
#[allow(clippy::disallowed_methods)]
@@ -75,6 +82,26 @@ impl Emoji {
db.detach_emoji(&self).await
}
/// Update an emoji
pub async fn update(&mut self, db: &Database, partial: PartialEmoji) -> Result<()> {
if let Some(name) = partial.name.clone() {
self.name = name;
}
db.update_emoji(&self.id, &partial).await?;
EventV1::EmojiUpdate {
id: self.id.clone(),
data: v0::PartialEmoji {
name: partial.name.clone(),
},
}
.p(self.parent().to_string())
.await;
Ok(())
}
/// Check whether we can use a given emoji
pub async fn can_use(db: &Database, emoji: &str) -> Result<bool> {
if Ulid::from_str(emoji).is_ok() {

View File

@@ -1,6 +1,6 @@
use revolt_result::Result;
use crate::Emoji;
use crate::{Emoji, PartialEmoji};
#[cfg(feature = "mongodb")]
mod mongodb;
@@ -20,6 +20,9 @@ pub trait AbstractEmojis: Sync + Send {
/// Fetch emoji by their parent ids
async fn fetch_emoji_by_parent_ids(&self, parent_ids: &[String]) -> Result<Vec<Emoji>>;
/// Update emoji with new information
async fn update_emoji(&self, emoji_id: &str, partial: &PartialEmoji) -> Result<()>;
/// Detach an emoji by its id
async fn detach_emoji(&self, emoji: &Emoji) -> Result<()>;
}

View File

@@ -1,7 +1,7 @@
use bson::Document;
use revolt_result::Result;
use crate::Emoji;
use crate::{Emoji, PartialEmoji};
use crate::MongoDb;
use super::AbstractEmojis;
@@ -46,6 +46,11 @@ impl AbstractEmojis for MongoDb {
)
}
/// Update emoji with new information
async fn update_emoji(&self, emoji_id: &str, partial: &PartialEmoji) -> Result<()> {
query!(self, update_one_by_id, COL, emoji_id, partial, vec![], None).map(|_| ())
}
/// Detach an emoji by its id
async fn detach_emoji(&self, emoji: &Emoji) -> Result<()> {
self.col::<Document>(COL)

View File

@@ -1,6 +1,6 @@
use revolt_result::Result;
use crate::Emoji;
use crate::{Emoji, PartialEmoji};
use crate::EmojiParent;
use crate::ReferenceDb;
@@ -54,6 +54,19 @@ impl AbstractEmojis for ReferenceDb {
.collect())
}
/// Update emoji with new information
async fn update_emoji(&self, emoji_id: &str, partial: &PartialEmoji) -> Result<()> {
let mut emojis = self.emojis.lock().await;
if let Some(emoji) = emojis.get_mut(emoji_id) {
if let Some(name) = partial.name.clone() {
emoji.name = name;
}
Ok(())
} else {
Err(create_error!(NotFound))
}
}
/// Detach an emoji by its id
async fn detach_emoji(&self, emoji: &Emoji) -> Result<()> {
let mut emojis = self.emojis.lock().await;

File diff suppressed because it is too large Load Diff

View File

@@ -45,7 +45,8 @@ auto_derived!(
Image {
width: isize,
height: isize,
// animated: bool // TODO: https://docs.rs/image/latest/image/trait.AnimationDecoder.html for APNG support
thumbhash: Option<Vec<u8>>,
animated: Option<bool>,
},
/// File is a video with specific dimensions
Video { width: isize, height: isize },

View File

@@ -17,6 +17,12 @@ pub trait AbstractAttachmentHashes: Sync + Send {
/// Update an attachment hash nonce value.
async fn set_attachment_hash_nonce(&self, hash: &str, nonce: &str) -> Result<()>;
/// Updates the attachments animated metadata value.
///
/// The primary use for this is to update the metadata for existing uploaded files, this
/// can only be used for images.
async fn set_attachment_hash_animated(&self, hash: &str, animated: bool) -> Result<()>;
/// Delete attachment hash by id.
async fn delete_attachment_hash(&self, id: &str) -> Result<()>;
}

View File

@@ -48,6 +48,29 @@ impl AbstractAttachmentHashes for MongoDb {
.map_err(|_| create_database_error!("update_one", COL))
}
/// Updates the attachments animated metadata value.
///
/// The primary use for this is to update the metadata for existing uploaded files, this
/// can only be used for images.
async fn set_attachment_hash_animated(&self, hash: &str, animated: bool) -> Result<()> {
self.col::<FileHash>(COL)
.update_one(
doc! {
"_id": hash,
"metadata.type": "Image",
"metadata.animated": { "$exists": false },
},
doc! {
"$set": {
"metadata.animated": animated
}
},
)
.await
.map(|_| ())
.map_err(|_| create_database_error!("update_one", COL))
}
/// Delete attachment hash by id.
async fn delete_attachment_hash(&self, id: &str) -> Result<()> {
query!(self, delete_one_by_id, COL, id).map(|_| ())

View File

@@ -1,7 +1,6 @@
use revolt_result::Result;
use crate::FileHash;
use crate::ReferenceDb;
use crate::{FileHash, Metadata, ReferenceDb};
use super::AbstractAttachmentHashes;
@@ -39,6 +38,29 @@ impl AbstractAttachmentHashes for ReferenceDb {
}
}
/// Updates the attachments animated metadata value.
///
/// The primary use for this is to update the metadata for existing uploaded files, this
/// can only be used for images.
async fn set_attachment_hash_animated(&self, hash: &str, animated: bool) -> Result<()> {
let mut hashes = self.file_hashes.lock().await;
if let Some(FileHash {
metadata:
Metadata::Image {
animated: Some(animated_metadata),
..
},
..
}) = hashes.get_mut(hash)
{
*animated_metadata = animated;
Ok(())
} else {
Err(create_error!(NotFound))
}
}
/// Delete attachment hash by id.
async fn delete_attachment_hash(&self, id: &str) -> Result<()> {
let mut file_hashes = self.file_hashes.lock().await;

View File

@@ -70,6 +70,7 @@ auto_derived!(
LegacyGroupIcon,
ChannelIcon,
ServerIcon,
RoleIcon,
}
/// Information about what the file was used for
@@ -239,4 +240,23 @@ impl File {
)
.await
}
/// Use a file for a role icon
pub async fn use_role_icon(
db: &Database,
id: &str,
parent: &str,
uploader_id: &str,
) -> Result<File> {
db.find_and_use_attachment(
id,
"icons",
FileUsedFor {
id: parent.to_owned(),
object_type: FileUsedForType::RoleIcon,
},
uploader_id.to_owned(),
)
.await
}
}

View File

@@ -1,5 +1,3 @@
use std::{collections::HashSet, hash::RandomState};
use indexmap::{IndexMap, IndexSet};
use iso8601_timestamp::Timestamp;
use revolt_config::{config, FeaturesLimits};
@@ -9,6 +7,8 @@ use revolt_models::v0::{
};
use revolt_permissions::{calculate_channel_permissions, ChannelPermission, PermissionValue};
use revolt_result::{ErrorType, Result};
use std::time::SystemTime;
use std::{collections::HashSet, hash::RandomState};
use ulid::Ulid;
use validator::Validate;
@@ -388,6 +388,7 @@ impl Message {
mut role_mentions,
mut mentions_everyone,
mut mentions_online,
..
} = message_mentions;
if allow_mass_mentions && server_id.is_some() && !role_mentions.is_empty() {
@@ -497,7 +498,7 @@ impl Message {
user_mentions.retain(|m| recipients_hash.contains(m));
role_mentions.clear();
}
Channel::TextChannel { ref server, .. }=> {
Channel::TextChannel { ref server, .. } => {
let mentions_vec = Vec::from_iter(user_mentions.iter().cloned());
let valid_members = db.fetch_members(server.as_str(), &mentions_vec[..]).await;
@@ -687,8 +688,13 @@ impl Message {
)
.await?;
let is_dm_or_group = matches!(
channel,
Channel::DirectMessage { .. } | Channel::Group { .. }
);
if !self.has_suppressed_notifications()
&& (self.mentions.is_some() || self.contains_mass_push_mention())
&& (is_dm_or_group || self.mentions.is_some() || self.contains_mass_push_mention())
{
// send Push notifications
#[cfg(feature = "tasks")]
@@ -699,7 +705,7 @@ impl Message {
Some(
PushNotification::from(
self.clone().into_model(user, member),
Some(author),
Some(author.clone()),
channel.to_owned().into(),
)
.await,
@@ -707,7 +713,11 @@ impl Message {
self.clone(),
match channel {
Channel::DirectMessage { recipients, .. }
| Channel::Group { recipients, .. } => recipients.clone(),
| Channel::Group { recipients, .. } => recipients
.iter()
.filter(|uid| *uid != author.id())
.cloned()
.collect(),
Channel::TextChannel { .. } => {
self.mentions.clone().unwrap_or_default()
}
@@ -1029,6 +1039,31 @@ impl Message {
Ok(())
}
/// Bulk delete messages by an author since a given time
pub async fn bulk_delete_by_author_since(
db: &Database,
channels: &[String],
author: &str,
since: SystemTime,
) -> Result<()> {
let deleted_groups = db
.delete_messages_by_author_since(channels, author, since)
.await?;
for (channel_id, message_ids) in deleted_groups {
if !message_ids.is_empty() {
EventV1::BulkMessageDelete {
channel: channel_id.clone(),
ids: message_ids,
}
.p(channel_id)
.await;
}
}
Ok(())
}
/// Remove a reaction from a message
pub async fn remove_reaction(&self, db: &Database, user: &str, emoji: &str) -> Result<()> {
// Check if it actually exists

View File

@@ -1,3 +1,5 @@
use std::collections::HashMap;
use std::time::SystemTime;
use revolt_result::Result;
use crate::{AppendMessage, FieldsMessage, Message, MessageQuery, PartialMessage};
@@ -40,4 +42,12 @@ pub trait AbstractMessages: Sync + Send {
/// Delete messages from a channel by their ids and corresponding channel id
async fn delete_messages(&self, channel: &str, ids: &[String]) -> Result<()>;
/// Delete all messages from a specific author in a server from a certain ULID onwards
async fn delete_messages_by_author_since(
&self,
channels: &[String],
author: &str,
since: SystemTime
) -> Result<HashMap<String, Vec<String>>>;
}

View File

@@ -1,8 +1,12 @@
use bson::{to_bson, Document};
use futures::try_join;
use futures::StreamExt;
use mongodb::options::FindOptions;
use revolt_models::v0::MessageSort;
use revolt_result::Result;
use std::collections::{HashMap, HashSet};
use std::time::SystemTime;
use ulid::Ulid;
use crate::{
AppendMessage, DocumentId, FieldsMessage, IntoDocumentPath, Message, MessageQuery,
@@ -306,6 +310,112 @@ impl AbstractMessages for MongoDb {
.map(|_| ())
.map_err(|_| create_database_error!("delete_many", COL))
}
/// Delete all messages from a specific author in a server from a certain ULID onwards
async fn delete_messages_by_author_since(
&self,
channels: &[String],
author: &str,
since: SystemTime,
) -> Result<HashMap<String, Vec<String>>> {
let threshold_ulid = Ulid::from_datetime(since).to_string();
let filter = doc! {
"author": author,
"channel": { "$in": channels },
"_id": { "$gte": &threshold_ulid }
};
let pipeline = vec![
doc! { "$match": filter.clone() },
doc! {
"$project": {
"channel": 1_i32,
"message_id": "$_id",
"attachment_ids": {
"$map": {
"input": { "$ifNull": ["$attachments", Vec::<bson::Bson>::new()] },
"as": "a",
"in": "$$a._id"
}
}
}
},
doc! {
"$group": {
"_id": "$channel",
"message_ids": { "$push": "$message_id" },
"attachment_ids_nested": { "$push": "$attachment_ids" }
}
},
doc! {
"$project": {
"message_ids": 1_i32,
"attachment_ids": {
"$reduce": {
"input": "$attachment_ids_nested",
"initialValue": Vec::<bson::Bson>::new(),
"in": { "$setUnion": ["$$value", "$$this"] }
}
}
}
},
];
#[derive(serde::Deserialize)]
struct AggregatedChannel {
#[serde(rename = "_id")]
channel: String,
message_ids: Vec<String>,
#[serde(default)]
attachment_ids: Vec<String>,
}
let mut cursor = self
.col::<Document>(COL)
.aggregate(pipeline)
.await
.map_err(|_| create_database_error!("aggregate", COL))?
.with_type::<AggregatedChannel>();
let mut deleted_messages: HashMap<String, Vec<String>> = HashMap::new();
let mut attachment_ids: HashSet<String> = HashSet::new();
while let Some(result) = cursor.next().await {
if let Ok(item) = result {
for id in item.attachment_ids {
attachment_ids.insert(id);
}
deleted_messages.insert(item.channel, item.message_ids);
}
}
// Mark attachments as deleted before deleting messages
if !attachment_ids.is_empty() {
self.col::<Document>("attachments")
.update_many(
doc! {
"_id": {
"$in": attachment_ids.into_iter().collect::<Vec<String>>()
}
},
doc! {
"$set": {
"deleted": true
}
},
)
.await
.map_err(|_| create_database_error!("update_many", "attachments"))?;
}
self.col::<Document>(COL)
.delete_many(filter)
.await
.map_err(|_| create_database_error!("delete_many", COL))?;
Ok(deleted_messages)
}
}
impl IntoDocumentPath for FieldsMessage {

View File

@@ -1,7 +1,9 @@
use std::collections::HashMap;
use futures::future::try_join_all;
use indexmap::IndexSet;
use revolt_result::Result;
use std::time::SystemTime;
use ulid::Ulid;
use crate::{AppendMessage, FieldsMessage, Message, MessageQuery, PartialMessage, ReferenceDb};
use super::AbstractMessages;
@@ -247,7 +249,7 @@ impl AbstractMessages for ReferenceDb {
let mut messages = self.messages.lock().await;
if let Some(message) = messages.get_mut(id) {
if let Some(users) = message.reactions.get_mut(emoji) {
users.remove(&user.to_string());
users.swap_remove(&user.to_string());
}
Ok(())
@@ -260,7 +262,7 @@ impl AbstractMessages for ReferenceDb {
async fn clear_reaction(&self, id: &str, emoji: &str) -> Result<()> {
let mut messages = self.messages.lock().await;
if let Some(message) = messages.get_mut(id) {
message.reactions.remove(emoji);
message.reactions.swap_remove(emoji);
Ok(())
} else {
Err(create_error!(NotFound))
@@ -286,4 +288,63 @@ impl AbstractMessages for ReferenceDb {
Ok(())
}
/// Delete all messages from a specific author in a list of channels from a certain ULID onwards
async fn delete_messages_by_author_since(
&self,
channels: &[String],
author: &str,
since: SystemTime
) -> Result<HashMap<String, Vec<String>>> {
let threshold_ulid = Ulid::from_datetime(since).to_string();
let mut deleted_messages: HashMap<String, Vec<String>> = HashMap::new();
let mut attachment_ids: Vec<String> = Vec::new();
let messages = self.messages.lock().await;
// First pass: collect attachment IDs and message IDs to delete
for (id, message) in messages.iter() {
let should_delete = message.author == author
&& channels.contains(&message.channel)
&& id.as_str() >= threshold_ulid.as_str();
if should_delete {
// Collect attachment IDs
if let Some(attachments) = &message.attachments {
for attachment in attachments {
attachment_ids.push(attachment.id.clone());
}
}
deleted_messages
.entry(message.channel.clone())
.or_default()
.push(id.clone());
}
}
drop(messages);
// Mark attachments as deleted
if !attachment_ids.is_empty() {
let mut files = self.files.lock().await;
for attachment_id in attachment_ids {
if let Some(file) = files.get_mut(&attachment_id) {
file.deleted = Some(true);
}
}
}
// Delete the messages
self.messages
.lock()
.await
.retain(|id, message| {
let should_keep = !(message.author == author
&& channels.contains(&message.channel)
&& id.as_str() >= threshold_ulid.as_str());
should_keep
});
Ok(deleted_messages)
}
}

View File

@@ -3,8 +3,8 @@ use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
use revolt_result::{create_error, Result};
use crate::{
events::client::EventV1, util::permissions::DatabasePermissionQuery, Channel,
Database, File, Server, SystemMessage, User,
events::client::EventV1, util::permissions::DatabasePermissionQuery, Channel, Database, File,
Server, SystemMessage, User,
};
fn default_true() -> bool {
@@ -45,7 +45,6 @@ auto_derived_partial!(
/// Whether the member is server-wide voice deafened
#[serde(skip_serializing_if = "is_true", default = "default_true")]
pub can_receive: bool,
// This value only exists in the database, not the models.
// If it is not-None, the database layer should return None to member fetching queries.
// pub pending_deletion_at: Option<Timestamp>
@@ -153,7 +152,11 @@ impl Member {
#[cfg(feature = "voice")]
for channel in &channels {
if let Ok(Some(voice_state)) = crate::voice::get_channel_voice_state(channel).await {
if let Ok(Some(voice_state)) = crate::voice::get_channel_voice_state(
&crate::voice::UserVoiceChannel::from_channel(channel),
)
.await
{
voice_states.push(voice_state)
}
}
@@ -175,7 +178,7 @@ impl Member {
.map(|channel| channel.into())
.collect(),
emojis: emojis.into_iter().map(|emoji| emoji.into()).collect(),
voice_states
voice_states,
}
.private(user.id.clone())
.await;
@@ -225,14 +228,14 @@ impl Member {
pub fn remove_field(&mut self, field: &FieldsMember) {
match field {
FieldsMember::JoinedAt => {},
FieldsMember::JoinedAt => {}
FieldsMember::Avatar => self.avatar = None,
FieldsMember::Nickname => self.nickname = None,
FieldsMember::Roles => self.roles.clear(),
FieldsMember::Timeout => self.timeout = None,
FieldsMember::CanReceive => self.can_receive = true,
FieldsMember::CanPublish => self.can_publish = true,
FieldsMember::VoiceChannel => {},
FieldsMember::VoiceChannel => {}
}
}

View File

@@ -86,6 +86,9 @@ auto_derived_partial!(
/// Ranking of this role
#[serde(default)]
pub rank: i64,
/// Custom icon attachment
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<File>,
},
"PartialRole"
);
@@ -129,6 +132,7 @@ auto_derived!(
/// Optional fields on server object
pub enum FieldsRole {
Colour,
Icon,
}
);
@@ -305,6 +309,7 @@ impl Role {
colour: self.colour,
hoist: Some(self.hoist),
rank: Some(self.rank),
icon: self.icon,
}
}
@@ -318,6 +323,7 @@ impl Role {
colour: None,
hoist: false,
permissions: Default::default(),
icon: None,
};
db.insert_role(&server.id, &role).await?;
@@ -367,6 +373,7 @@ impl Role {
pub fn remove_field(&mut self, field: &FieldsRole) {
match field {
FieldsRole::Colour => self.colour = None,
FieldsRole::Icon => self.icon = None,
}
}

View File

@@ -77,7 +77,7 @@ impl AbstractServers for MongoDb {
},
doc! {
"$set": {
"roles.".to_owned() + &role.id: to_document(role)
"roles.".to_owned() + role.id.as_str(): to_document(role)
.map_err(|_| create_database_error!("to_document", "role"))?
}
},
@@ -172,6 +172,7 @@ impl IntoDocumentPath for FieldsRole {
fn as_path(&self) -> Option<&'static str> {
Some(match self {
FieldsRole::Colour => "colour",
FieldsRole::Icon => "icon",
})
}
}

View File

@@ -7,6 +7,7 @@ use futures::future::join_all;
use iso8601_timestamp::Timestamp;
use once_cell::sync::Lazy;
use rand::seq::SliceRandom;
use regex::{Regex, RegexBuilder};
use revolt_config::{config, FeaturesLimits};
use revolt_models::v0::{self, UserBadges, UserFlags};
use revolt_presence::filter_online;
@@ -163,6 +164,13 @@ pub static DISCRIMINATOR_SEARCH_SPACE: Lazy<HashSet<String>> = Lazy::new(|| {
set.into_iter().collect()
});
static BLOCKED_USERNAME_PATTERNS: Lazy<Regex> = Lazy::new(|| {
RegexBuilder::new("`{3}|(discord|rvlt|guilded|stt)\\.gg|(revolt|stoat)\\.chat|https?:\\/\\/")
.case_insensitive(true)
.build()
.unwrap()
});
#[allow(clippy::derivable_impls)]
impl Default for User {
fn default() -> Self {
@@ -198,11 +206,13 @@ impl User {
I: Into<Option<String>>,
D: Into<Option<PartialUser>>,
{
let username = User::validate_username(username)?;
let new_username = User::sanitise_username(&username).await?;
User::validate_username(&new_username)?;
let mut user = User {
id: account_id.into().unwrap_or_else(|| Ulid::new().to_string()),
discriminator: User::find_discriminator(db, &username, None).await?,
username,
discriminator: User::find_discriminator(db, &new_username, None).await?,
username: new_username.clone(),
last_acknowledged_policy_change: Timestamp::now_utc(),
..Default::default()
};
@@ -278,39 +288,40 @@ impl User {
}
}
/// Sanitise and validate a username can be used
pub fn validate_username(username: String) -> Result<String> {
// Copy the username for validation
/// Validate a username
///
/// This will check if the username is a blocked name or contains a blocked pattern.
fn validate_username(username: &str) -> Result<()> {
let username_lowercase = username.to_lowercase();
// Block homoglyphs
if decancer::cure(&username_lowercase).into_str() != username_lowercase {
const BLOCKED_USERNAMES: &[&str] = &["admin", "revolt", "stoat"];
if BLOCKED_USERNAMES.contains(&username_lowercase.as_str())
|| BLOCKED_USERNAME_PATTERNS.is_match(username)
{
return Err(create_error!(InvalidUsername));
}
// Ensure the username itself isn't blocked
const BLOCKED_USERNAMES: &[&str] = &["admin", "revolt"];
Ok(())
}
for username in BLOCKED_USERNAMES {
if username_lowercase == *username {
return Err(create_error!(InvalidUsername));
}
}
/// Sanitise a username
///
/// This will clean up Unicode homoglyphs and pad to the min username length with underscores.
async fn sanitise_username(username: &str) -> Result<String> {
let options = decancer::Options::default().retain_capitalization();
let mut username = decancer::cure(username, options)
.map_err(|_| create_error!(InvalidUsername))?
.to_string();
// Ensure none of the following substrings show up in the username
const BLOCKED_SUBSTRINGS: &[&str] = &[
"```",
"discord.gg",
"rvlt.gg",
"guilded.gg",
"https://",
"http://",
];
for substr in BLOCKED_SUBSTRINGS {
if username_lowercase.contains(substr) {
return Err(create_error!(InvalidUsername));
}
let config = revolt_config::config().await;
let username_length_diff = config
.api
.users
.min_username_length
.saturating_sub(username.len());
if username_length_diff > 0 {
username.push_str(&"_".repeat(username_length_diff))
}
Ok(username)
@@ -416,12 +427,14 @@ impl User {
/// Update a user's username
pub async fn update_username(&mut self, db: &Database, username: String) -> Result<()> {
let username = User::validate_username(username)?;
if self.username.to_lowercase() == username.to_lowercase() {
let new_username = User::sanitise_username(&username).await?;
User::validate_username(&new_username)?;
if self.username.to_lowercase() == new_username.to_lowercase() {
self.update(
db,
PartialUser {
username: Some(username),
username: Some(new_username),
..Default::default()
},
vec![],
@@ -434,12 +447,12 @@ impl User {
discriminator: Some(
User::find_discriminator(
db,
&username,
&new_username,
Some((self.discriminator.to_string(), self.id.clone())),
)
.await?,
),
username: Some(username),
username: Some(new_username),
..Default::default()
},
vec![],
@@ -825,3 +838,109 @@ impl User {
badges
}
}
#[cfg(test)]
mod tests {
use crate::User;
#[test]
fn username_validation_blocked_names() {
let username_admin = "Admin";
let username_revolt = "Revolt";
let username_stoat = "Stoat";
let username_allowed = "Allowed";
assert!(User::validate_username(username_admin).is_err());
assert!(User::validate_username(username_revolt).is_err());
assert!(User::validate_username(username_stoat).is_err());
assert!(User::validate_username(username_allowed).is_ok());
}
#[test]
fn username_validation_blocked_patterns() {
let username_grave = "```_test";
let username_discord = "discord.gg_test";
let username_rvlt = "rvlt.gg_test";
let username_guilded = "guilded.gg_test";
let username_stt = "stt.gg_test";
let username_revolt = "revolt.chat_test";
let username_stoat = "stoat.chat_test";
let username_http = "http://_test";
let username_https = "https://_test";
assert!(User::validate_username(username_grave).is_err());
assert!(User::validate_username(username_discord).is_err());
assert!(User::validate_username(username_rvlt).is_err());
assert!(User::validate_username(username_guilded).is_err());
assert!(User::validate_username(username_stt).is_err());
assert!(User::validate_username(username_revolt).is_err());
assert!(User::validate_username(username_stoat).is_err());
assert!(User::validate_username(username_http).is_err());
assert!(User::validate_username(username_https).is_err());
}
#[async_std::test]
async fn username_sanitisation_clean() {
let username_clean = "Test";
let username_clean_sanitised = User::sanitise_username(username_clean).await;
assert!(username_clean_sanitised.is_ok());
assert_eq!(username_clean, username_clean_sanitised.unwrap());
}
#[async_std::test]
async fn username_sanitisation_homoglyphs() {
let username_homoglyphs = "𝔽𝕌Ňℕy";
let username_homoglyphs_sanitised =
User::sanitise_username(username_homoglyphs).await.unwrap();
assert_ne!(username_homoglyphs, username_homoglyphs_sanitised);
assert_eq!("funny", username_homoglyphs_sanitised);
}
#[async_std::test]
async fn username_sanitisation_padding() {
let username_padding = "a";
let username = User::sanitise_username(username_padding).await.unwrap();
assert_eq!("a_", username);
}
#[async_std::test]
async fn create_user() {
use revolt_result::Result;
database_test!(|db| async move {
let mut created_clean = User::create(&db, "Test".to_string(), None, None)
.await
.unwrap();
assert_eq!("Test", created_clean.username);
created_clean
.update_username(&db, "Test2".to_string())
.await
.unwrap();
assert_eq!("Test2", created_clean.username);
let created_invalid_result: Result<_> =
User::create(&db, "stoat.chat".to_string(), None, None).await;
assert!(created_invalid_result.is_err());
let mut updated_invalid = User::create(&db, "Test".to_string(), None, None)
.await
.unwrap();
let updated_invalid_update_result = updated_invalid
.update_username(&db, "http://test".to_string())
.await;
assert!(updated_invalid_update_result.is_err());
});
}
}

View File

@@ -105,7 +105,11 @@ pub async fn handle_ack_event(
if mentions_acked > 0 {
if let Err(err) = amqp
.ack_message(user.to_string(), channel.to_string(), id.to_owned())
.ack_notification_message(
user.to_string(),
channel.to_string(),
id.to_owned(),
)
.await
{
revolt_config::capture_error(&err);
@@ -192,9 +196,7 @@ pub async fn handle_ack_event(
.expect("Failed to fetch channel from db");
if let TextChannel { server, .. } = channel {
if let Err(err) =
amqp.mass_mention_message_sent(server, mass_mentions).await
{
if let Err(err) = amqp.mass_mention_message_sent(server, mass_mentions).await {
revolt_config::capture_error(&err);
}
} else {

View File

@@ -0,0 +1,77 @@
use redis_kiss::{get_connection, AsyncCommands};
use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
use revolt_result::{Result, ToRevoltError};
use crate::{events::client::EventV1, Channel, Database, Server, User, AMQP};
pub async fn ack_channel(user: &str, channel: &str, message: &str, amqp: &AMQP) -> Result<()> {
let mut redis = get_connection()
.await
.map_err(|_| create_error!(InternalError))?;
let old: Option<String> = redis
.getset(format!("acker:{user}+{channel}"), message)
.await
.to_internal_error()?;
if old.is_none() || old.unwrap() == message {
amqp.process_ack(user, Some(channel), None)
.await
.to_internal_error()?;
}
Ok(())
}
pub async fn ack_server(user: &User, server: &Server, db: &Database, amqp: &AMQP) -> Result<()> {
let mut redis = get_connection()
.await
.map_err(|_| create_error!(InternalError))?;
let channels = db.fetch_channels(&server.channels).await?;
let query = crate::util::permissions::DatabasePermissionQuery::new(db, user).server(server);
for channel in channels {
let channel_id = channel.id();
let mut q = query.clone().channel(&channel);
if calculate_channel_permissions(&mut q)
.await
.has_channel_permission(ChannelPermission::ViewChannel)
{
let channel_last_msg = match &channel {
Channel::TextChannel {
last_message_id, ..
} => last_message_id,
_ => unreachable!(),
}
.clone();
if let Some(channel_last_msg) = channel_last_msg {
let old: Option<String> = redis
.getset(
format!("acker:{}+{}", user.id, channel_id),
&channel_last_msg,
)
.await
.to_internal_error()?;
if old.is_none() || old.unwrap() == channel_last_msg {
amqp.process_ack(&user.id, Some(channel_id), Some(&server.id))
.await
.to_internal_error()?;
EventV1::ChannelAck {
id: channel_id.to_string(),
user: user.id.clone(),
message_id: channel_last_msg,
}
.private(user.id.clone())
.await;
}
}
}
}
Ok(())
}

View File

@@ -190,6 +190,7 @@ impl From<crate::Channel> for Channel {
role_permissions,
nsfw,
voice,
slowmode,
} => Channel::TextChannel {
id,
server,
@@ -201,6 +202,7 @@ impl From<crate::Channel> for Channel {
role_permissions,
nsfw,
voice: voice.map(|voice| voice.into()),
slowmode,
},
}
}
@@ -254,6 +256,7 @@ impl From<Channel> for crate::Channel {
role_permissions,
nsfw,
voice,
slowmode,
} => crate::Channel::TextChannel {
id,
server,
@@ -265,6 +268,7 @@ impl From<Channel> for crate::Channel {
role_permissions,
nsfw,
voice: voice.map(|voice| voice.into()),
slowmode,
},
}
}
@@ -283,7 +287,8 @@ impl From<crate::PartialChannel> for PartialChannel {
role_permissions: value.role_permissions,
default_permissions: value.default_permissions,
last_message_id: value.last_message_id,
voice: value.voice.map(|voice| voice.into())
voice: value.voice.map(|voice| voice.into()),
slowmode: value.slowmode,
}
}
}
@@ -301,7 +306,8 @@ impl From<PartialChannel> for crate::PartialChannel {
role_permissions: value.role_permissions,
default_permissions: value.default_permissions,
last_message_id: value.last_message_id,
voice: value.voice.map(|voice| voice.into())
voice: value.voice.map(|voice| voice.into()),
slowmode: value.slowmode,
}
}
}
@@ -406,9 +412,16 @@ impl From<crate::Metadata> for Metadata {
match value {
crate::Metadata::File => Metadata::File,
crate::Metadata::Text => Metadata::Text,
crate::Metadata::Image { width, height } => Metadata::Image {
crate::Metadata::Image {
width,
height,
thumbhash,
animated,
} => Metadata::Image {
width: width as usize,
height: height as usize,
thumbhash,
animated,
},
crate::Metadata::Video { width, height } => Metadata::Video {
width: width as usize,
@@ -424,9 +437,16 @@ impl From<Metadata> for crate::Metadata {
match value {
Metadata::File => crate::Metadata::File,
Metadata::Text => crate::Metadata::Text,
Metadata::Image { width, height } => crate::Metadata::Image {
Metadata::Image {
width,
height,
thumbhash,
animated,
} => crate::Metadata::Image {
width: width as isize,
height: height as isize,
thumbhash,
animated,
},
Metadata::Video { width, height } => crate::Metadata::Video {
width: width as isize,
@@ -515,7 +535,9 @@ impl From<crate::SystemMessage> for SystemMessage {
crate::SystemMessage::UserRemove { id, by } => Self::UserRemove { id, by },
crate::SystemMessage::MessagePinned { id, by } => Self::MessagePinned { id, by },
crate::SystemMessage::MessageUnpinned { id, by } => Self::MessageUnpinned { id, by },
crate::SystemMessage::CallStarted { by, finished_at } => Self::CallStarted { by, finished_at }
crate::SystemMessage::CallStarted { by, finished_at } => {
Self::CallStarted { by, finished_at }
}
}
}
}
@@ -904,6 +926,7 @@ impl From<crate::Role> for Role {
colour: value.colour,
hoist: value.hoist,
rank: value.rank,
icon: value.icon.map(|f| f.into()),
}
}
}
@@ -917,6 +940,7 @@ impl From<Role> for crate::Role {
colour: value.colour,
hoist: value.hoist,
rank: value.rank,
icon: value.icon.map(|f| f.into()),
}
}
}
@@ -930,6 +954,7 @@ impl From<crate::PartialRole> for PartialRole {
colour: value.colour,
hoist: value.hoist,
rank: value.rank,
icon: value.icon.map(|f| f.into()),
}
}
}
@@ -943,6 +968,7 @@ impl From<PartialRole> for crate::PartialRole {
colour: value.colour,
hoist: value.hoist,
rank: value.rank,
icon: value.icon.map(|f| f.into()),
}
}
}
@@ -951,6 +977,7 @@ impl From<crate::FieldsRole> for FieldsRole {
fn from(value: crate::FieldsRole) -> Self {
match value {
crate::FieldsRole::Colour => FieldsRole::Colour,
crate::FieldsRole::Icon => FieldsRole::Icon,
}
}
}
@@ -959,6 +986,7 @@ impl From<FieldsRole> for crate::FieldsRole {
fn from(value: FieldsRole) -> Self {
match value {
FieldsRole::Colour => crate::FieldsRole::Colour,
FieldsRole::Icon => crate::FieldsRole::Icon,
}
}
}
@@ -1382,7 +1410,7 @@ impl From<FieldsMessage> for crate::FieldsMessage {
impl From<VoiceInformation> for crate::VoiceInformation {
fn from(value: VoiceInformation) -> Self {
crate::VoiceInformation {
max_users: value.max_users
max_users: value.max_users,
}
}
}
@@ -1390,7 +1418,7 @@ impl From<VoiceInformation> for crate::VoiceInformation {
impl From<crate::VoiceInformation> for VoiceInformation {
fn from(value: crate::VoiceInformation) -> Self {
VoiceInformation {
max_users: value.max_users
max_users: value.max_users,
}
}
}
}

View File

@@ -1,3 +1,4 @@
pub mod acker;
pub mod bridge;
pub mod bulk_permissions;
mod funcs;

View File

@@ -1,3 +1,5 @@
use std::fmt::{Display, Write};
use crate::{
events::client::EventV1,
models::{Channel, User},
@@ -6,7 +8,11 @@ use crate::{
};
use iso8601_timestamp::{Duration, Timestamp};
use livekit_protocol::ParticipantPermission;
use redis_kiss::{get_connection as _get_connection, redis::Pipeline, AsyncCommands, Conn};
use redis_kiss::{
get_connection as _get_connection,
redis::{FromRedisValue, Pipeline, RedisError, RedisWrite, ToRedisArgs, Value},
AsyncCommands, Conn,
};
use revolt_config::FeaturesLimits;
use revolt_models::v0::{self, PartialUserVoiceState, UserVoiceState};
use revolt_permissions::{calculate_channel_permissions, ChannelPermission, PermissionValue};
@@ -16,48 +22,60 @@ mod voice_client;
pub use voice_client::VoiceClient;
async fn get_connection() -> Result<Conn> {
_get_connection().await.map_err(|_| create_error!(InternalError))
_get_connection()
.await
.map_err(|_| create_error!(InternalError))
}
pub async fn raise_if_in_voice(user: &User, channel_id: &str) -> Result<()> {
pub async fn raise_if_in_voice(user: &User, channel: &UserVoiceChannel) -> Result<()> {
let mut conn = get_connection().await?;
if user.bot.is_some()
// bots can be in as many voice channels as it wants so we just check if its already connected to the one its trying to connect to
&& conn.sismember(format!("vc:{}", &user.id), channel_id)
if user.bot.is_some() {
// bots can be in as many voice channels as it wants so we just check if its already connected to the one its trying to connect to
if conn
.sismember(format!("vc:{}", &user.id), channel)
.await
.to_internal_error()?
{
Err(create_error!(AlreadyConnected))
{
return Err(create_error!(AlreadyConnected));
};
} else if conn
.scard::<_, u32>(format!("vc:{}", &user.id)) // check if the current vc set is empty
.await
.to_internal_error()?
> 0
{
Err(create_error!(AlreadyConnected))
} else {
Ok(())
}
return Err(create_error!(AlreadyConnected));
};
Ok(())
}
pub async fn set_channel_node(channel: &str, node: &str) -> Result<()> {
pub async fn set_channel_node(channel_id: &str, node: &str) -> Result<()> {
get_connection()
.await?
.set(format!("node:{channel}"), node)
.set(format!("node:{channel_id}"), node)
.await
.to_internal_error()
}
pub async fn get_channel_node(channel: &str) -> Result<Option<String>> {
pub async fn get_channel_node(channel_id: &str) -> Result<Option<String>> {
get_connection()
.await?
.get(format!("node:{channel}"))
.get(format!("node:{channel_id}"))
.await
.to_internal_error()
}
pub async fn get_user_voice_channels(user_id: &str) -> Result<Vec<String>> {
pub async fn delete_channel_node(channel_id: &str) -> Result<()> {
get_connection()
.await?
.del(format!("node:{channel_id}"))
.await
.to_internal_error()
}
pub async fn get_user_voice_channels(user_id: &str) -> Result<Vec<UserVoiceChannel>> {
get_connection()
.await?
.smembers(format!("vc:{user_id}"))
@@ -66,14 +84,14 @@ pub async fn get_user_voice_channels(user_id: &str) -> Result<Vec<String>> {
}
pub async fn set_user_moved_from_voice(
old_channel: &str,
new_channel: &str,
old_channel_id: &str,
new_channel: &UserVoiceChannel,
user_id: &str,
) -> Result<()> {
get_connection()
.await?
.set_ex(
format!("moved_from:{user_id}:{old_channel}"),
format!("moved_from:{user_id}:{old_channel_id}"),
new_channel,
10,
)
@@ -90,18 +108,25 @@ pub async fn get_user_moved_from_voice(channel_id: &str, user_id: &str) -> Resul
}
pub async fn set_user_moved_to_voice(
new_channel: &str,
old_channel: &str,
new_channel_id: &str,
old_channel: &UserVoiceChannel,
user_id: &str,
) -> Result<()> {
get_connection()
.await?
.set_ex(format!("moved_to:{user_id}:{new_channel}"), old_channel, 10)
.set_ex(
format!("moved_to:{user_id}:{new_channel_id}"),
old_channel,
10,
)
.await
.to_internal_error()
}
pub async fn get_user_moved_to_voice(channel_id: &str, user_id: &str) -> Result<Option<String>> {
pub async fn get_user_moved_to_voice(
channel_id: &str,
user_id: &str,
) -> Result<Option<UserVoiceChannel>> {
get_connection()
.await?
.get_del(format!("moved_to:{user_id}:{channel_id}"))
@@ -109,10 +134,10 @@ pub async fn get_user_moved_to_voice(channel_id: &str, user_id: &str) -> Result<
.to_internal_error()
}
pub async fn is_in_voice_channel(user_id: &str, channel_id: &str) -> Result<bool> {
pub async fn is_in_voice_channel(user_id: &str, channel: &UserVoiceChannel) -> Result<bool> {
get_connection()
.await?
.sismember(format!("vc:{user_id}"), channel_id)
.sismember(format!("vc:{user_id}"), channel)
.await
.to_internal_error()
}
@@ -146,12 +171,15 @@ pub fn get_allowed_sources(
}
pub async fn create_voice_state(
channel_id: &str,
server_id: Option<&str>,
channel: &UserVoiceChannel,
user_id: &str,
joined_at: Timestamp,
) -> Result<UserVoiceState> {
let unique_key = format!("{}:{}", &user_id, server_id.unwrap_or(channel_id));
let unique_key = format!(
"{}:{}",
&user_id,
channel.server_id.as_ref().unwrap_or(&channel.id)
);
let voice_state = UserVoiceState {
joined_at,
@@ -163,9 +191,9 @@ pub async fn create_voice_state(
};
Pipeline::new()
.sadd(format!("vc_members:{channel_id}"), user_id)
.sadd(format!("vc:{user_id}"), channel_id)
.set(&unique_key, channel_id)
.sadd(format!("vc_members:{}", &channel.id), user_id)
.sadd(format!("vc:{user_id}"), channel)
.set(&unique_key, &channel.id)
.set(
format!("joined_at:{unique_key}"),
joined_at
@@ -192,16 +220,16 @@ pub async fn create_voice_state(
Ok(voice_state)
}
pub async fn delete_voice_state(
channel_id: &str,
server_id: Option<&str>,
user_id: &str,
) -> Result<()> {
let unique_key = format!("{}:{}", &user_id, server_id.unwrap_or(channel_id));
pub async fn delete_voice_state(channel: &UserVoiceChannel, user_id: &str) -> Result<()> {
let unique_key = format!(
"{}:{}",
&user_id,
channel.server_id.as_ref().unwrap_or(&channel.id)
);
Pipeline::new()
.srem(format!("vc_members:{channel_id}"), user_id)
.srem(format!("vc:{user_id}"), channel_id)
.srem(format!("vc_members:{}", &channel.id), user_id)
.srem(format!("vc:{user_id}"), channel)
.del(&[
format!("joined_at:{unique_key}"),
format!("is_publishing:{unique_key}"),
@@ -216,19 +244,19 @@ pub async fn delete_voice_state(
}
pub async fn delete_channel_voice_state(
channel_id: &str,
server_id: Option<&str>,
channel: &UserVoiceChannel,
user_ids: &[String],
) -> Result<()> {
let parent_id = server_id.unwrap_or(channel_id);
let parent_id = channel.server_id.as_ref().unwrap_or(&channel.id);
let mut pipeline = Pipeline::new();
pipeline.del(format!("vc_members:{channel_id}"));
pipeline.del(format!("vc_members:{}", &channel.id));
pipeline.del(format!("node:{}", &channel.id));
for user_id in user_ids {
let unique_key = format!("{user_id}:{parent_id}");
pipeline.srem(format!("vc:{user_id}"), channel_id).del(&[
pipeline.srem(format!("vc:{user_id}"), channel).del(&[
format!("joined_at:{unique_key}"),
format!("is_publishing:{unique_key}"),
format!("is_receiving:{unique_key}"),
@@ -245,8 +273,7 @@ pub async fn delete_channel_voice_state(
}
pub async fn update_voice_state_tracks(
channel_id: &str,
server_id: Option<&str>,
channel: &UserVoiceChannel,
user_id: &str,
added: bool,
track: i32,
@@ -271,18 +298,21 @@ pub async fn update_voice_state_tracks(
_ => unreachable!(),
};
update_voice_state(channel_id, server_id, user_id, &partial).await?;
update_voice_state(channel, user_id, &partial).await?;
Ok(partial)
}
pub async fn update_voice_state(
channel_id: &str,
server_id: Option<&str>,
channel: &UserVoiceChannel,
user_id: &str,
partial: &PartialUserVoiceState,
) -> Result<()> {
let unique_key = format!("{}:{}", &user_id, server_id.unwrap_or(channel_id));
let unique_key = format!(
"{}:{}",
&user_id,
channel.server_id.as_ref().unwrap_or(&channel.id)
);
let mut pipeline = Pipeline::new();
@@ -308,21 +338,24 @@ pub async fn update_voice_state(
.to_internal_error()
}
pub async fn get_voice_channel_members(channel_id: &str) -> Result<Option<Vec<String>>> {
pub async fn get_voice_channel_members(channel: &UserVoiceChannel) -> Result<Option<Vec<String>>> {
get_connection()
.await?
.smembers::<_, Option<Vec<String>>>(format!("vc_members:{channel_id}"))
.smembers::<_, Option<Vec<String>>>(format!("vc_members:{}", &channel.id))
.await
.to_internal_error()
.map(|opt| opt.and_then(|v| if v.is_empty() { None } else { Some(v) }))
}
pub async fn get_voice_state(
channel_id: &str,
server_id: Option<&str>,
channel: &UserVoiceChannel,
user_id: &str,
) -> Result<Option<UserVoiceState>> {
let unique_key = format!("{}:{}", user_id, server_id.unwrap_or(channel_id));
let unique_key = format!(
"{}:{}",
&user_id,
channel.server_id.as_ref().unwrap_or(&channel.id)
);
let (joined_at, is_publishing, is_receiving, screensharing, camera) = get_connection()
.await?
@@ -363,21 +396,21 @@ pub async fn get_voice_state(
}
}
pub async fn get_channel_voice_state(channel: &Channel) -> Result<Option<v0::ChannelVoiceState>> {
let members = get_voice_channel_members(channel.id()).await?;
let server = channel.server();
pub async fn get_channel_voice_state(
channel: &UserVoiceChannel,
) -> Result<Option<v0::ChannelVoiceState>> {
let members = get_voice_channel_members(channel).await?;
if let Some(members) = members {
let mut participants = Vec::with_capacity(members.len());
for user_id in members {
if let Some(voice_state) = get_voice_state(channel.id(), server, &user_id).await? {
if let Some(voice_state) = get_voice_state(channel, &user_id).await? {
participants.push(voice_state);
} else {
log::info!("Voice state not found but member in voice channel members, removing.");
delete_voice_state(channel.id(), server, &user_id).await?;
delete_voice_state(channel, &user_id).await?;
}
}
@@ -385,7 +418,7 @@ pub async fn get_channel_voice_state(channel: &Channel) -> Result<Option<v0::Cha
participants.shrink_to_fit();
Ok(Some(v0::ChannelVoiceState {
id: channel.id().to_string(),
id: channel.id.clone(),
participants,
}))
} else {
@@ -393,12 +426,12 @@ pub async fn get_channel_voice_state(channel: &Channel) -> Result<Option<v0::Cha
}
}
pub async fn move_user(user: &str, from: &str, to: &str) -> Result<()> {
pub async fn move_user(user: &str, from_channel_id: &str, to_channel_id: &str) -> Result<()> {
get_connection()
.await?
.smove(
format!("vc-members-{from}"),
format!("vc-members-{to}"),
format!("vc_members:{from_channel_id}"),
format!("vc_members:{to_channel_id}"),
user,
)
.await
@@ -412,11 +445,13 @@ pub async fn sync_voice_permissions(
server: Option<&Server>,
role_id: Option<&str>,
) -> Result<()> {
let user_voice_channel = UserVoiceChannel::from_channel(channel);
let Some(node) = get_channel_node(channel.id()).await? else {
return Ok(());
};
for user_id in get_voice_channel_members(channel.id())
for user_id in get_voice_channel_members(&user_voice_channel)
.await?
.iter()
.flatten()
@@ -456,7 +491,9 @@ pub async fn sync_user_voice_permissions(
.as_ref()
.is_none_or(|member| member.roles.iter().any(|r| r == role_id))
}) {
let Some(voice_state) = get_voice_state(channel_id, server_id, &user.id).await? else {
let user_voice_channel = UserVoiceChannel::from_channel(channel);
let Some(voice_state) = get_voice_state(&user_voice_channel, &user.id).await? else {
return Ok(());
};
@@ -487,7 +524,7 @@ pub async fn sync_user_voice_permissions(
update_event.screensharing = voice_state.screensharing.then_some(can_video);
update_event.is_publishing = voice_state.is_publishing.then_some(can_speak);
update_voice_state(channel_id, server_id, &user.id, &update_event).await?;
update_voice_state(&user_voice_channel, &user.id, &update_event).await?;
voice_client
.update_permissions(
@@ -565,34 +602,95 @@ pub async fn get_call_notification_recipients(
.to_internal_error()
}
pub async fn remove_user_from_voice_channels(db: &Database, voice_client: &VoiceClient, user_id: &str) -> Result<()> {
for channel_id in get_user_voice_channels(user_id).await? {
remove_user_from_voice_channel(db, voice_client, &channel_id, user_id).await?;
};
Ok(())
}
pub async fn remove_user_from_voice_channel(db: &Database, voice_client: &VoiceClient, channel_id: &str, user_id: &str) -> Result<()> {
if let Some(node) = get_channel_node(channel_id).await? {
let _ = voice_client.remove_user(&node, user_id, channel_id).await;
pub async fn remove_user_from_voice_channels(
voice_client: &VoiceClient,
user_id: &str,
) -> Result<()> {
for channel in get_user_voice_channels(user_id).await? {
remove_user_from_voice_channel(voice_client, &channel, user_id).await?;
}
let channel = Reference::from_unchecked(channel_id).as_channel(db).await?;
Ok(())
}
delete_voice_state(channel_id, channel.server(), user_id).await?;
pub async fn remove_user_from_voice_channel(
voice_client: &VoiceClient,
channel: &UserVoiceChannel,
user_id: &str,
) -> Result<()> {
if let Some(node) = get_channel_node(&channel.id).await? {
let _ = voice_client.remove_user(&node, user_id, &channel.id).await;
}
delete_voice_state(channel, user_id).await?;
Ok(())
}
pub async fn delete_voice_channel(voice_client: &VoiceClient, channel_id: &str, server_id: Option<&str>) -> Result<()> {
if let Some(users) = get_voice_channel_members(channel_id).await? {
let node = get_channel_node(channel_id).await?.unwrap();
pub async fn delete_voice_channel(
voice_client: &VoiceClient,
channel: &UserVoiceChannel,
) -> Result<()> {
if let Some(users) = get_voice_channel_members(channel).await? {
let node = get_channel_node(&channel.id).await?.unwrap();
voice_client.delete_room(&node, &channel.id).await?;
voice_client.delete_room(&node, channel_id).await?;
delete_channel_voice_state(channel_id, server_id, &users).await?;
delete_channel_voice_state(channel, &users).await?;
};
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoomMetadata {
pub server: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserVoiceChannel {
pub id: String,
pub server_id: Option<String>,
}
impl UserVoiceChannel {
pub fn from_string(input: String) -> Self {
let mut parts = input.splitn(2, '-');
Self {
id: parts.next().unwrap().to_string(),
server_id: parts.next().map(ToString::to_string),
}
}
pub fn from_channel(channel: &Channel) -> Self {
Self {
id: channel.id().to_string(),
server_id: channel.server().map(ToString::to_string),
}
}
}
impl Display for UserVoiceChannel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.id)?;
if let Some(server_id) = &self.server_id {
f.write_char('-')?;
f.write_str(server_id)?
};
Ok(())
}
}
impl ToRedisArgs for UserVoiceChannel {
fn write_redis_args<W: ?Sized + RedisWrite>(&self, out: &mut W) {
out.write_arg_fmt(self);
}
}
impl FromRedisValue for UserVoiceChannel {
fn from_redis_value(v: &Value) -> Result<Self, RedisError> {
String::from_redis_value(v).map(UserVoiceChannel::from_string)
}
}

View File

@@ -1,5 +1,6 @@
use crate::{
models::{Channel, User},
voice::RoomMetadata,
Database,
};
use livekit_api::{
@@ -102,11 +103,16 @@ impl VoiceClient {
pub async fn create_room(&self, node: &str, channel: &Channel) -> Result<Room> {
let room = self.get_node(node)?;
let metadata = RoomMetadata {
server: channel.server().map(|id| id.to_string()),
};
room.client
.create_room(
channel.id(),
CreateRoomOptions {
empty_timeout: 5 * 60, // 5 minutes,
metadata: serde_json::to_string(&metadata).to_internal_error()?,
..Default::default()
},
)

View File

@@ -6,4 +6,4 @@ This email is intended for {{email}}
This email has no association with Stoat or Revolt Platforms Ltd.
Learn more about third party instances here:
https://developers.stoat.chat/faq.html
https://developers.stoat.chat/faq/

View File

@@ -6,4 +6,4 @@ This email is intended for {{email}}
This email has no association with Stoat or Revolt Platforms Ltd.
Learn more about third party instances here:
https://developers.revolt.chat/faq.html
https://developers.stoat.chat/faq/

View File

@@ -107,7 +107,7 @@
<p class="" style="line-height: 24px; font-size: 16px; width: 100%; margin: 0;" align="left">
Further violations may result in a permanent ban depending on
severity, please abide by the
<a href="https://revolt.chat/aup" style="color: #0d6efd;">Acceptable Usage Policy</a>.
<a href="https://stoat.chat/legal/community-guidelines" style="color: #0d6efd;">Community Guidelines</a>.
</p>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>

View File

@@ -3,7 +3,7 @@ Your account has been suspended, for one or more reasons:
You will be able to use your account again in {{duration}} days.
Further violations may result in a permanent ban depending on severity, please abide by the Acceptable Usage Policy (https://revolt.chat/aup).
Further violations may result in a permanent ban depending on severity, please abide by the Community Guidelines (https://stoat.chat/legal/community-guidelines).
Ban evasion is prohibited and will be dealt with accordingly.

View File

@@ -3,6 +3,6 @@ Your account has been suspended, for one or more reasons:
This email is intended for {{email}}
This email has no association with Revolt or Revolt Platforms Ltd.
This email has no association with Stoat or Revolt Platforms Ltd.
Learn more about third party instances here:
https://developers.revolt.chat/faq.html
https://developers.stoat.chat/faq/

View File

@@ -7,4 +7,4 @@ This email is intended for {{email}}
This email has no association with Stoat or Revolt Platforms Ltd.
Learn more about third party instances here:
https://developers.revolt.chat/faq.html
https://developers.stoat.chat/faq/

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-files"
version = "0.11.0"
version = "0.13.4"
edition = "2021"
license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"]
@@ -16,33 +16,33 @@ tracing = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
ffprobe = "0.4.0"
imagesize = "0.13.0"
tempfile = "3.12.0"
ffprobe = { workspace = true }
imagesize = { workspace = true }
tempfile = { workspace = true }
base64 = "0.22.1"
aes-gcm = "0.10.3"
typenum = "1.17.0"
base64 = { workspace = true }
aes-gcm = { workspace = true }
typenum = { workspace = true }
aws-config = "1.5.5"
aws-sdk-s3 = { version = "1.46.0", features = ["behavior-version-latest"] }
aws-config = { workspace = true }
aws-sdk-s3 = { workspace = true, features = ["behavior-version-latest"] }
revolt-config = { version = "0.11.0", path = "../config", features = [
revolt-config = { workspace = true, features = [
"report-macros",
] }
revolt-result = { version = "0.11.0", path = "../result" }
revolt-result = { workspace = true }
# image processing
jxl-oxide = { workspace = true }
jxl-oxide = { workspace = true, features = ["image"] }
image = { workspace = true }
# svg rendering
usvg = "0.44.0"
resvg = "0.44.0"
tiny-skia = "0.11.4"
usvg = { workspace = true }
resvg = { workspace = true }
tiny-skia = { workspace = true }
# encoding
webp = "0.3.0"
webp = { workspace = true }
[dev-dependencies]
uuid = { workspace = true }
uuid = { workspace = true, features = ["v4"] }

View File

@@ -1,5 +1,5 @@
use anyhow::Result;
use image::{DynamicImage, ImageBuffer, ImageReader};
use image::{AnimationDecoder, DynamicImage, ImageBuffer, ImageReader};
use jxl_oxide::integration::JxlDecoder;
use revolt_config::report_internal_error;
use std::io::{BufRead, Read, Seek};
@@ -35,6 +35,31 @@ impl MediaRepository for MediaImpl {
}
}
fn is_animated(&self, f: &NamedTempFile, mime: &str) -> Option<bool> {
match mime {
// Current behaviour is to assume GIFs are animated, this checks for at least 2 frames
"image/gif" => {
let file = std::fs::File::open(f.path()).ok()?;
let reader = std::io::BufReader::new(file);
let decoder = image::codecs::gif::GifDecoder::new(reader).ok()?;
Some(decoder.into_frames().take(2).count() > 1)
}
"image/png" => {
let file = std::fs::File::open(f.path()).ok()?;
let reader = std::io::BufReader::new(file);
let decoder = image::codecs::png::PngDecoder::new(reader).ok()?;
decoder.is_apng().ok()
}
"image/webp" => {
let file = std::fs::File::open(f.path()).ok()?;
let reader = std::io::BufReader::new(file);
let decoder = image::codecs::webp::WebPDecoder::new(reader).ok()?;
Some(decoder.has_animation())
}
_ => Some(false),
}
}
fn image_size_vec(&self, v: &[u8], mime: &str) -> Option<(usize, usize)> {
match mime {
"image/svg+xml" => {
@@ -171,9 +196,9 @@ impl MediaRepository for MediaImpl {
#[cfg(test)]
mod tests {
use std::io::Cursor;
use crate::{MediaImpl, MediaRepository};
use std::io::{Cursor, Write};
use tempfile::NamedTempFile;
#[tokio::test]
async fn asset_test_jpeg() {
@@ -186,6 +211,15 @@ mod tests {
media.create_thumbnail(image, "attachments");
}
#[tokio::test]
async fn asset_test_jpeg_is_not_animated() {
let media = MediaImpl::from_config().await;
let mut f = NamedTempFile::new().unwrap();
f.write_all(include_bytes!("../../tests/assets/test.jpeg"))
.unwrap();
assert_eq!(media.is_animated(&f, "image/jpeg"), Some(false));
}
#[tokio::test]
async fn asset_test_jpeg_extra_bytes() {
let media = MediaImpl::from_config().await;
@@ -212,6 +246,15 @@ mod tests {
media.create_thumbnail(image, "emojis");
}
#[tokio::test]
async fn asset_test_png_is_not_animated() {
let media = MediaImpl::from_config().await;
let mut f = NamedTempFile::new().unwrap();
f.write_all(include_bytes!("../../tests/assets/test.png"))
.unwrap();
assert_eq!(media.is_animated(&f, "image/png"), Some(false));
}
#[tokio::test]
async fn asset_test_png_extra_bytes() {
let media = MediaImpl::from_config().await;
@@ -259,6 +302,15 @@ mod tests {
media.create_thumbnail(image, "attachments");
}
#[tokio::test]
async fn asset_test_animated_png_is_animated() {
let media = MediaImpl::from_config().await;
let mut f = NamedTempFile::new().unwrap();
f.write_all(include_bytes!("../../tests/assets/anim-icos.apng"))
.unwrap();
assert_eq!(media.is_animated(&f, "image/png"), Some(true));
}
#[tokio::test]
async fn asset_test_jxl() {
let media = MediaImpl::from_config().await;
@@ -292,6 +344,15 @@ mod tests {
media.create_thumbnail(image, "attachments");
}
#[tokio::test]
async fn asset_test_webp_is_not_animated() {
let media = MediaImpl::from_config().await;
let mut f = NamedTempFile::new().unwrap();
f.write_all(include_bytes!("../../tests/assets/dice.webp"))
.unwrap();
assert_eq!(media.is_animated(&f, "image/webp"), Some(false));
}
#[tokio::test]
async fn asset_test_animated_webp() {
let media = MediaImpl::from_config().await;
@@ -303,6 +364,15 @@ mod tests {
media.create_thumbnail(image, "attachments");
}
#[tokio::test]
async fn asset_test_animated_webp_is_animated() {
let media = MediaImpl::from_config().await;
let mut f = NamedTempFile::new().unwrap();
f.write_all(include_bytes!("../../tests/assets/anim-icos.webp"))
.unwrap();
assert_eq!(media.is_animated(&f, "image/webp"), Some(true));
}
#[tokio::test]
async fn asset_test_animated_gif() {
let media = MediaImpl::from_config().await;
@@ -313,4 +383,13 @@ mod tests {
let image = media.decode_image(&mut reader, "image/gif").unwrap();
media.create_thumbnail(image, "attachments");
}
#[tokio::test]
async fn asset_test_animated_gif_is_animated() {
let media = MediaImpl::from_config().await;
let mut f = NamedTempFile::new().unwrap();
f.write_all(include_bytes!("../../tests/assets/anim-icos.gif"))
.unwrap();
assert_eq!(media.is_animated(&f, "image/gif"), Some(true));
}
}

View File

@@ -91,6 +91,34 @@ pub fn image_size_vec(v: &[u8], mime: &str) -> Option<(usize, usize)> {
media.image_size_vec(v, mime)
}
/// Check whether an image file contains animation data
pub fn is_animated(f: &NamedTempFile, mime: &str) -> Option<bool> {
let media = MediaImpl::new(Files {
blocked_mime_types: Default::default(),
clamd_host: Default::default(),
encryption_key: Default::default(),
limit: FilesLimit {
max_mega_pixels: 0,
max_pixel_side: 0,
min_file_size: 0,
min_resolution: [0, 0],
},
preview: Default::default(),
s3: FilesS3 {
access_key_id: Default::default(),
default_bucket: Default::default(),
endpoint: Default::default(),
path_style_buckets: Default::default(),
region: Default::default(),
secret_access_key: Default::default(),
},
scan_mime_types: Default::default(),
webp_quality: Default::default(),
});
media.is_animated(f, mime)
}
/// Determine size of video at temp file
pub fn video_size(f: &NamedTempFile) -> Option<(i64, i64)> {
let media = MediaImpl::new(Files {

View File

@@ -6,6 +6,9 @@ use thiserror::Error;
pub trait MediaRepository: Send + Sync + 'static {
fn image_size(&self, f: &NamedTempFile) -> Option<(usize, usize)>;
fn is_animated(&self, f: &NamedTempFile, mime: &str) -> Option<bool>;
fn image_size_vec(&self, v: &[u8], mime: &str) -> Option<(usize, usize)>;
fn decode_image<R: Read + BufRead + Seek>(

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-models"
version = "0.11.0"
version = "0.13.4"
edition = "2021"
license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"]
@@ -21,26 +21,26 @@ default = ["serde", "partials", "rocket"]
[dependencies]
# Core
revolt-config = { version = "0.11.0", path = "../config" }
revolt-permissions = { version = "0.11.0", path = "../permissions" }
revolt-config = { workspace = true }
revolt-permissions = { workspace = true }
# Utility
regex = "1.11"
indexmap = "1.9.3"
once_cell = "1.17.1"
num_enum = "0.6.1"
regex = { workspace = true }
indexmap = { workspace = true }
once_cell = { workspace = true }
num_enum = { workspace = true }
# Rocket
rocket = { optional = true, version = "0.5.0-rc.2", default-features = false }
rocket = { workspace = true, optional = true }
# Serialisation
revolt_optional_struct = { version = "0.2.0", optional = true }
serde = { version = "1", features = ["derive"], optional = true }
iso8601-timestamp = { version = "0.2.11", features = ["schema", "bson"] }
revolt_optional_struct = { workspace = true, optional = true }
serde = { workspace = true, optional = true }
iso8601-timestamp = { workspace = true, features = ["schema", "bson"] }
# Spec Generation
schemars = { version = "0.8.8", optional = true, features = ["indexmap1"] }
utoipa = { version = "4.2.3", optional = true }
schemars = { workspace = true, features = ["indexmap2"], optional = true }
utoipa = { workspace = true, optional = true }
# Validation
validator = { version = "0.16.0", optional = true, features = ["derive"] }
validator = { workspace = true, features = ["derive"], optional = true }

View File

@@ -112,6 +112,10 @@ auto_derived!(
/// Voice Information for when this channel is also a voice channel
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
voice: Option<VoiceInformation>,
/// The channel's slowmode delay in seconds
#[serde(skip_serializing_if = "Option::is_none")]
slowmode: Option<u64>,
},
}
@@ -150,6 +154,8 @@ auto_derived!(
pub last_message_id: Option<String>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub voice: Option<VoiceInformation>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub slowmode: Option<u64>,
}
/// Optional fields on channel object
@@ -189,6 +195,10 @@ auto_derived!(
/// Voice Information for voice channels
pub voice: Option<VoiceInformation>,
/// The channel's slow mode delay in seconds, up to 6 hours
#[cfg_attr(feature = "validator", validate(range(min = 0, max = 21600)))]
pub slowmode: Option<u64>,
/// Fields to remove from channel
#[cfg_attr(feature = "serde", serde(default))]
pub remove: Vec<FieldsChannel>,

View File

@@ -54,4 +54,22 @@ auto_derived!(
#[serde(default)]
pub nsfw: bool,
}
/// Partial emoji representation
#[derive(Default)]
pub struct PartialEmoji {
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub name: Option<String>,
}
/// Edit emoji information
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct DataEditEmoji {
/// Emoji name
#[cfg_attr(
feature = "validator",
validate(length(min = 1, max = 32), regex = "RE_EMOJI")
)]
pub name: Option<String>,
}
);

View File

@@ -46,8 +46,13 @@ auto_derived!(
File,
/// File contains textual data and should be displayed as such
Text,
/// File is an image with specific dimensions
Image { width: usize, height: usize },
/// File is an image with specific dimensions, and may be animated
Image {
width: usize,
height: usize,
thumbhash: Option<Vec<u8>>,
animated: Option<bool>,
},
/// File is a video with specific dimensions
Video { width: usize, height: usize },
/// File is audio

View File

@@ -133,7 +133,10 @@ auto_derived!(
#[serde(rename = "message_unpinned")]
MessageUnpinned { id: String, by: String },
#[serde(rename = "call_started")]
CallStarted { by: String, finished_at: Option<Timestamp> },
CallStarted {
by: String,
finished_at: Option<Timestamp>,
},
}
/// Name and / or avatar override information
@@ -201,6 +204,9 @@ auto_derived!(
pub image: Option<String>,
/// Message content or system message information
pub body: String,
/// The raw body, if the body has been rendered
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_body: Option<String>,
/// Unique tag, usually the channel ID
pub tag: String,
/// Timestamp at which this notification was created
@@ -255,7 +261,7 @@ auto_derived!(
pub nonce: Option<String>,
/// Message content to send
#[cfg_attr(feature = "validator", validate(length(min = 0, max = 2000)))]
#[cfg_attr(feature = "validator", validate(length(min = 0)))]
pub content: Option<String>,
/// Attachments to include in message
pub attachments: Option<Vec<String>>,
@@ -339,7 +345,7 @@ auto_derived!(
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct DataEditMessage {
/// New message content
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 2000)))]
#[cfg_attr(feature = "validator", validate(length(min = 1)))]
pub content: Option<String>,
/// Embeds to include in the message
#[cfg_attr(feature = "validator", validate(length(min = 0, max = 10)))]
@@ -385,6 +391,7 @@ auto_derived!(
);
/// Message Author Abstraction
#[derive(Clone)]
pub enum MessageAuthor<'a> {
User(&'a User),
Webhook(&'a Webhook),
@@ -508,6 +515,7 @@ impl PushNotification {
icon,
image,
body,
raw_body: None,
tag: channel.id().to_string(),
timestamp,
url: format!("{}/channel/{}/{}", config.hosts.app, channel.id(), msg.id),

View File

@@ -19,6 +19,9 @@ auto_derived!(
/// Ban reason
#[cfg_attr(feature = "validator", validate(length(min = 0, max = 1024)))]
pub reason: Option<String>,
/// Messages to delete in seconds
#[cfg_attr(feature = "validator", validate(range(min = 0, max = 604800)))]
pub delete_message_seconds: Option<i64>,
}
/// Just enough information to list a ban

View File

@@ -106,6 +106,9 @@ auto_derived_partial!(
/// Ranking of this role
#[cfg_attr(feature = "serde", serde(default))]
pub rank: i64,
/// Role icon
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub icon: Option<File>,
},
"PartialRole"
);
@@ -123,6 +126,7 @@ auto_derived!(
/// Optional fields on server object
pub enum FieldsRole {
Colour,
Icon,
}
/// Channel category
@@ -252,6 +256,9 @@ auto_derived!(
/// Must be enabled in order to show up on [Revolt Discover](https://rvlt.gg).
pub analytics: Option<bool>,
/// User id of the new owner
pub owner: Option<String>,
/// Fields to remove from server object
#[cfg_attr(feature = "serde", serde(default))]
pub remove: Vec<FieldsServer>,
@@ -275,6 +282,11 @@ auto_derived!(
///
/// **Removed** - no effect, use the edit server role positions route
pub rank: Option<i64>,
/// Role icon
///
/// Provide an Autumn attachment Id.
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 128)))]
pub icon: Option<String>,
/// Fields to remove from role object
#[cfg_attr(feature = "serde", serde(default))]
pub remove: Vec<FieldsRole>,

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-parser"
version = "0.11.0"
version = "0.13.4"
edition = "2021"
license = "MIT"
authors = ["Zomatree <me@zomatree.live>", "Paul Makles <me@insrt.uk>"]
@@ -8,4 +8,4 @@ description = "Revolt Backend: Message Parser"
repository = "https://github.com/stoatchat/stoatchat"
[dependencies]
logos = { version = "0.15" }
logos = { workspace = true }

View File

@@ -16,23 +16,29 @@ pub enum MessageToken<'a> {
UserMention(&'a str),
#[regex("<%(?&id)>", |lex| &lex.slice()[2..lex.slice().len() - 1],)]
RoleMention(&'a str),
#[regex("<#(?&id)>", |lex| &lex.slice()[2..lex.slice().len() - 1],)]
ChannelMention(&'a str),
#[regex(":(?&id):", |lex| &lex.slice()[1..lex.slice().len() - 1],)]
Emoji(&'a str),
#[token("@everyone")]
MentionEveryone,
#[token("@online")]
MentionOnline
MentionOnline,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct MessageResults {
pub user_mentions: HashSet<String>,
pub role_mentions: HashSet<String>,
pub channel_mentions: HashSet<String>,
pub emojis: HashSet<String>,
pub mentions_everyone: bool,
pub mentions_online: bool
pub mentions_online: bool,
}
struct MessageParserIterator<'a, I> {
inner: I,
temp: VecDeque<MessageToken<'a>>
temp: VecDeque<MessageToken<'a>>,
}
impl<'a, I: Iterator<Item = MessageToken<'a>>> Iterator for MessageParserIterator<'a, I> {
@@ -55,11 +61,11 @@ impl<'a, I: Iterator<Item = MessageToken<'a>>> Iterator for MessageParserIterato
if next_token == Some(MessageToken::CodeblockMarker(ty)) {
self.temp.clear();
self.temp.push_back(MessageToken::CodeblockMarker(ty));
break next_token
break next_token;
} else if let Some(token) = next_token {
self.temp.push_back(token);
} else {
break Some(MessageToken::CodeblockMarker(ty))
break Some(MessageToken::CodeblockMarker(ty));
}
}
} else {
@@ -69,10 +75,10 @@ impl<'a, I: Iterator<Item = MessageToken<'a>>> Iterator for MessageParserIterato
}
}
pub fn parse_message_iter(text: &str) -> impl Iterator<Item = MessageToken> + '_ {
pub fn parse_message_iter(text: &str) -> impl Iterator<Item = MessageToken<'_>> + '_ {
MessageParserIterator {
inner: MessageToken::lexer(text).flatten(),
temp: VecDeque::new()
temp: VecDeque::new(),
}
}
@@ -82,13 +88,23 @@ pub fn parse_message(text: &str) -> MessageResults {
for token in parse_message_iter(text) {
match token {
MessageToken::Escape => {}
MessageToken::CodeblockMarker(_) => {},
MessageToken::UserMention(id) => { results.user_mentions.insert(id.to_string()); },
MessageToken::RoleMention(id) => { results.role_mentions.insert(id.to_string()); },
MessageToken::CodeblockMarker(_) => {}
MessageToken::UserMention(id) => {
results.user_mentions.insert(id.to_string());
}
MessageToken::RoleMention(id) => {
results.role_mentions.insert(id.to_string());
}
MessageToken::ChannelMention(id) => {
results.channel_mentions.insert(id.to_string());
}
MessageToken::Emoji(id) => {
results.emojis.insert(id.to_string());
}
MessageToken::MentionEveryone => results.mentions_everyone = true,
MessageToken::MentionOnline => results.mentions_online = true,
};
};
}
results
}
@@ -109,7 +125,10 @@ mod tests {
let output = parse_message_iter("Hello <@01FD58YK5W7QRV5H3D64KTQYX3>.").collect::<Vec<_>>();
assert_eq!(output.len(), 1);
assert_eq!(output[0], MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3"));
assert_eq!(
output[0],
MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3")
);
}
#[test]
@@ -117,7 +136,10 @@ mod tests {
let output = parse_message_iter("Hello <%01FD58YK5W7QRV5H3D64KTQYX3>.").collect::<Vec<_>>();
assert_eq!(output.len(), 1);
assert_eq!(output[0], MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3"));
assert_eq!(
output[0],
MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3")
);
}
#[test]
@@ -138,29 +160,57 @@ mod tests {
#[test]
fn test_everything() {
let output = parse_message_iter("Hello <@01FD58YK5W7QRV5H3D64KTQYX3>, <%01FD58YK5W7QRV5H3D64KTQYX3>, @everyone and @online.").collect::<Vec<_>>();
let output = parse_message_iter("Hello <@01FD58YK5W7QRV5H3D64KTQYX3>, <%01FD58YK5W7QRV5H3D64KTQYX3>, <#01FD58YK5W7QRV5H3D64KTQYX3> @everyone and @online. :01FD58YK5W7QRV5H3D64KTQYX3:").collect::<Vec<_>>();
assert_eq!(output.len(), 4);
assert_eq!(output[0], MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3"));
assert_eq!(output[1], MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3"));
assert_eq!(output[2], MessageToken::MentionEveryone);
assert_eq!(output[3], MessageToken::MentionOnline);
assert_eq!(output.len(), 6);
assert_eq!(
output[0],
MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3")
);
assert_eq!(
output[1],
MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3")
);
assert_eq!(
output[2],
MessageToken::ChannelMention("01FD58YK5W7QRV5H3D64KTQYX3")
);
assert_eq!(output[3], MessageToken::MentionEveryone);
assert_eq!(output[4], MessageToken::MentionOnline);
assert_eq!(output[5], MessageToken::Emoji("01FD58YK5W7QRV5H3D64KTQYX3"));
}
#[test]
fn test_everything_no_spaces() {
let output = parse_message_iter("<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online").collect::<Vec<_>>();
let output = parse_message_iter(
"<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3><#01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online:01FD58YK5W7QRV5H3D64KTQYX3:",
)
.collect::<Vec<_>>();
assert_eq!(output.len(), 4);
assert_eq!(output[0], MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3"));
assert_eq!(output[1], MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3"));
assert_eq!(output[2], MessageToken::MentionEveryone);
assert_eq!(output[3], MessageToken::MentionOnline);
assert_eq!(output.len(), 6);
assert_eq!(
output[0],
MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3")
);
assert_eq!(
output[1],
MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3")
);
assert_eq!(
output[2],
MessageToken::ChannelMention("01FD58YK5W7QRV5H3D64KTQYX3")
);
assert_eq!(output[3], MessageToken::MentionEveryone);
assert_eq!(output[4], MessageToken::MentionOnline);
assert_eq!(output[5], MessageToken::Emoji("01FD58YK5W7QRV5H3D64KTQYX3"));
}
#[test]
fn test_codeblock_no_mentions() {
let output = parse_message_iter("```\n<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online\n```").collect::<Vec<_>>();
let output = parse_message_iter(
"```\n<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3><#01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online:01FD58YK5W7QRV5H3D64KTQYX3:\n```",
)
.collect::<Vec<_>>();
assert_eq!(output.len(), 2);
assert_eq!(output[0], MessageToken::CodeblockMarker(3));
@@ -169,19 +219,36 @@ mod tests {
#[test]
fn test_uncontained_codeblock_should_mention() {
let output = parse_message_iter("```\n<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online").collect::<Vec<_>>();
let output = parse_message_iter(
"```\n<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3><#01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online:01FD58YK5W7QRV5H3D64KTQYX3:",
)
.collect::<Vec<_>>();
assert_eq!(output.len(), 5);
assert_eq!(output.len(), 7);
assert_eq!(output[0], MessageToken::CodeblockMarker(3));
assert_eq!(output[1], MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3"));
assert_eq!(output[2], MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3"));
assert_eq!(output[3], MessageToken::MentionEveryone);
assert_eq!(output[4], MessageToken::MentionOnline);
assert_eq!(
output[1],
MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3")
);
assert_eq!(
output[2],
MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3")
);
assert_eq!(
output[3],
MessageToken::ChannelMention("01FD58YK5W7QRV5H3D64KTQYX3")
);
assert_eq!(output[4], MessageToken::MentionEveryone);
assert_eq!(output[5], MessageToken::MentionOnline);
assert_eq!(output[6], MessageToken::Emoji("01FD58YK5W7QRV5H3D64KTQYX3"));
}
#[test]
fn test_inline_codeblock_no_mentions() {
let output = parse_message_iter("`<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online`").collect::<Vec<_>>();
let output = parse_message_iter(
"`<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3><#01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online:01FD58YK5W7QRV5H3D64KTQYX3:`",
)
.collect::<Vec<_>>();
assert_eq!(output.len(), 2);
assert_eq!(output[0], MessageToken::CodeblockMarker(1));
@@ -190,19 +257,33 @@ mod tests {
#[test]
fn test_uncontained_inline_codeblock_should_mention() {
let output = parse_message_iter("`<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online").collect::<Vec<_>>();
let output = parse_message_iter(
"`<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3><#01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online:01FD58YK5W7QRV5H3D64KTQYX3:",
)
.collect::<Vec<_>>();
assert_eq!(output.len(), 5);
assert_eq!(output.len(), 7);
assert_eq!(output[0], MessageToken::CodeblockMarker(1));
assert_eq!(output[1], MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3"));
assert_eq!(output[2], MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3"));
assert_eq!(output[3], MessageToken::MentionEveryone);
assert_eq!(output[4], MessageToken::MentionOnline);
assert_eq!(
output[1],
MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3")
);
assert_eq!(
output[2],
MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3")
);
assert_eq!(
output[3],
MessageToken::ChannelMention("01FD58YK5W7QRV5H3D64KTQYX3")
);
assert_eq!(output[4], MessageToken::MentionEveryone);
assert_eq!(output[5], MessageToken::MentionOnline);
assert_eq!(output[6], MessageToken::Emoji("01FD58YK5W7QRV5H3D64KTQYX3"));
}
#[test]
fn test_codeblock_with_language_no_mentions() {
let output = parse_message_iter("```rust\n<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online```").collect::<Vec<_>>();
let output = parse_message_iter("```rust\n<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3><#01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online:01FD58YK5W7QRV5H3D64KTQYX3:```").collect::<Vec<_>>();
assert_eq!(output.len(), 2);
assert_eq!(output[0], MessageToken::CodeblockMarker(3));
@@ -220,7 +301,8 @@ mod tests {
#[test]
fn test_double_inline_codeblock_with_backticks_inside() {
let output = parse_message_iter("``this `should` not `ping` @everyone``").collect::<Vec<_>>();
let output =
parse_message_iter("``this `should` not `ping` @everyone``").collect::<Vec<_>>();
assert_eq!(output.len(), 2);
assert_eq!(output[0], MessageToken::CodeblockMarker(2));
@@ -238,7 +320,8 @@ mod tests {
#[test]
fn test_escaped_codeblock() {
let output = parse_message_iter("i am ~~not~~ pinging \\`@everyone` ok.").collect::<Vec<_>>();
let output =
parse_message_iter("i am ~~not~~ pinging \\`@everyone` ok.").collect::<Vec<_>>();
assert_eq!(output.len(), 3);
assert_eq!(output[0], MessageToken::Escape);
@@ -253,4 +336,4 @@ mod tests {
assert_eq!(output.len(), 1);
assert_eq!(output[0], MessageToken::Escape);
}
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-permissions"
version = "0.11.0"
version = "0.13.4"
edition = "2021"
license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"]
@@ -18,23 +18,23 @@ try-from-primitive = ["dep:num_enum"]
[dev-dependencies]
# Async
async-std = { version = "1.8.0", features = ["attributes"] }
async-std = { workspace = true, features = ["attributes"] }
[dependencies]
# Core
revolt-result = { version = "0.11.0", path = "../result" }
revolt-result = { workspace = true }
# Utility
auto_ops = "0.3.0"
once_cell = "1.17"
num_enum = { version = "0.6.1", optional = true }
auto_ops = { workspace = true }
once_cell = { workspace = true }
num_enum = { workspace = true, optional = true }
# Async
async-trait = "0.1.51"
async-trait = { workspace = true }
# Serialisation
serde = { version = "1", features = ["derive"], optional = true }
bson = { version = "2.1.0", optional = true }
serde = { workspace = true, optional = true }
bson = { workspace = true, optional = true }
# Spec Generation
schemars = { version = "0.8.8", optional = true }
schemars = { workspace = true, optional = true }

View File

@@ -75,6 +75,8 @@ pub enum ChannelPermission {
Masquerade = 1 << 28,
/// React to messages with emojis
React = 1 << 29,
/// Bypass slowmode
BypassSlowmode = 1 << 39,
// * Voice permissions
/// Connect to a voice channel
@@ -99,7 +101,7 @@ pub enum ChannelPermission {
MentionRoles = 1 << 38,
// * Misc. permissions
// % Bits 38 to 52: free area
// % Bits 39 to 52: free area
// % Bits 53 to 64: do not use
// * Grant all permissions
@@ -141,7 +143,7 @@ pub static DEFAULT_PERMISSION: Lazy<u64> = Lazy::new(|| {
pub static DEFAULT_PERMISSION_SAVED_MESSAGES: u64 = ChannelPermission::GrantAllSafe as u64;
pub static DEFAULT_PERMISSION_DIRECT_MESSAGE: Lazy<u64> = Lazy::new(|| {
DEFAULT_PERMISSION.add(ChannelPermission::ManageChannel + ChannelPermission::React)
DEFAULT_PERMISSION.add(ChannelPermission::ManageChannel + ChannelPermission::React + ChannelPermission::Masquerade)
});
pub static DEFAULT_PERMISSION_SERVER: Lazy<u64> = Lazy::new(|| {

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-presence"
version = "0.11.0"
version = "0.13.4"
edition = "2021"
license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"]
@@ -14,16 +14,16 @@ redis-is-patched = []
[dev-dependencies]
# Async
async-std = { version = "1.8.0", features = ["attributes"] }
async-std = { workspace = true, features = ["attributes"] }
# Config for loading Redis URI
revolt-config = { version = "0.11.0", path = "../config" }
revolt-config = { workspace = true }
[dependencies]
# Utility
log = "0.4.17"
rand = "0.8.5"
once_cell = "1.17.1"
log = { workspace = true }
rand = { workspace = true }
once_cell = { workspace = true }
# Redis
redis-kiss = "0.1.4"
redis-kiss = { workspace = true }

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-ratelimits"
version = "0.11.0"
version = "0.13.4"
edition = "2024"
license = "MIT"
authors = ["Zomatree <me@zomatree.live>", "Paul Makles <me@insrt.uk>"]
@@ -18,17 +18,17 @@ axum = ["dep:axum", "revolt-database/axum-impl"]
default = ["rocket", "axum"]
[dependencies]
revolt-database = { version = "0.11.0", path = "../database" }
revolt-result = { version = "0.11.0", path = "../result" }
revolt-config = { version = "0.11.0", path = "../config" }
revolt-database = { workspace = true }
revolt-result = { workspace = true }
revolt-config = { workspace = true }
rocket = { version = "0.5.1", optional = true }
revolt_rocket_okapi = { version = "0.10.0", optional = true }
rocket = { workspace = true, optional = true }
revolt_rocket_okapi = { workspace = true, optional = true }
axum = { version = "0.7.5", optional = true, features = ["macros"] }
axum = { workspace = true, optional = true, features = ["macros"] }
serde = { version = "1", features = ["derive"] }
authifier = { version = "1.0.16" }
dashmap = "5.2.0"
async-trait = "0.1.81"
log = "0.4"
serde = { workspace = true }
authifier = { workspace = true }
dashmap = { workspace = true }
async-trait = { workspace = true }
log = { workspace = true }

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-result"
version = "0.11.0"
version = "0.13.4"
edition = "2021"
license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"]
@@ -22,21 +22,21 @@ default = ["serde", "sentry"]
[dependencies]
# Serialisation
serde_json = { version = "1", optional = true }
serde = { version = "1", features = ["derive"], optional = true }
serde_json = { workspace = true, optional = true }
serde = { workspace = true, optional = true }
# Spec Generation
schemars = { version = "0.8.8", optional = true }
utoipa = { version = "4.2.3", optional = true }
schemars = { workspace = true, optional = true }
utoipa = { workspace = true, optional = true }
# Rocket
rocket = { optional = true, version = "0.5.0-rc.2", default-features = false }
revolt_rocket_okapi = { version = "0.10.0", optional = true }
revolt_okapi = { version = "0.9.1", optional = true }
rocket = { workspace = true, optional = true }
revolt_rocket_okapi = { workspace = true, optional = true }
revolt_okapi = { workspace = true, optional = true }
# utilities
log = "0.4"
log = { workspace = true }
# Axum
axum = { version = "0.7.5", optional = true }
axum = { workspace = true, optional = true }
sentry = { version = "0.31.5", optional = true }
sentry = { workspace = true, optional = true }

View File

@@ -24,6 +24,7 @@ impl IntoResponse for Error {
ErrorType::UnknownChannel => StatusCode::NOT_FOUND,
ErrorType::UnknownMessage => StatusCode::NOT_FOUND,
ErrorType::UnknownAttachment => StatusCode::BAD_REQUEST,
ErrorType::CannotDeleteMessage => StatusCode::FORBIDDEN,
ErrorType::CannotEditMessage => StatusCode::FORBIDDEN,
ErrorType::CannotJoinCall => StatusCode::BAD_REQUEST,
ErrorType::TooManyAttachments { .. } => StatusCode::BAD_REQUEST,
@@ -36,7 +37,9 @@ impl IntoResponse for Error {
ErrorType::NotInGroup => StatusCode::NOT_FOUND,
ErrorType::AlreadyPinned => StatusCode::BAD_REQUEST,
ErrorType::NotPinned => StatusCode::BAD_REQUEST,
ErrorType::InSlowmode { retry_after: _ } => StatusCode::TOO_MANY_REQUESTS,
ErrorType::CantCreateServers => StatusCode::FORBIDDEN,
ErrorType::UnknownServer => StatusCode::NOT_FOUND,
ErrorType::InvalidRole => StatusCode::NOT_FOUND,
ErrorType::Banned => StatusCode::FORBIDDEN,
@@ -74,7 +77,7 @@ impl IntoResponse for Error {
ErrorType::DuplicateNonce => StatusCode::CONFLICT,
ErrorType::VosoUnavailable => StatusCode::BAD_REQUEST,
ErrorType::NotFound => StatusCode::NOT_FOUND,
ErrorType::NoEffect => StatusCode::OK,
ErrorType::NoEffect => StatusCode::BAD_REQUEST,
ErrorType::FailedValidation { .. } => StatusCode::BAD_REQUEST,
ErrorType::LiveKitUnavailable => StatusCode::BAD_REQUEST,
ErrorType::NotConnected => StatusCode::BAD_REQUEST,

View File

@@ -1,5 +1,5 @@
use std::panic::Location;
use std::fmt::Display;
use std::panic::Location;
#[cfg(feature = "serde")]
#[macro_use]
@@ -78,6 +78,7 @@ pub enum ErrorType {
UnknownChannel,
UnknownAttachment,
UnknownMessage,
CannotDeleteMessage,
CannotEditMessage,
CannotJoinCall,
TooManyAttachments {
@@ -102,8 +103,12 @@ pub enum ErrorType {
NotInGroup,
AlreadyPinned,
NotPinned,
InSlowmode {
retry_after: u64,
},
// ? Server related errors
CantCreateServers,
UnknownServer,
InvalidRole,
Banned,
@@ -232,17 +237,16 @@ impl<T, E: std::fmt::Debug + std::error::Error> ToRevoltError<T> for Result<T, E
fn to_internal_error(self) -> Result<T, Error> {
let loc = Location::caller();
self
.map_err(|e| {
log::error!("{e:?}");
#[cfg(feature = "sentry")]
sentry::capture_error(&e);
self.map_err(|e| {
log::error!("{e:?}");
#[cfg(feature = "sentry")]
sentry::capture_error(&e);
Error {
error_type: ErrorType::InternalError,
location: format!("{}:{}:{}", loc.file(), loc.line(), loc.column())
}
})
Error {
error_type: ErrorType::InternalError,
location: format!("{}:{}:{}", loc.file(), loc.line(), loc.column()),
}
})
}
}
@@ -251,11 +255,9 @@ impl<T> ToRevoltError<T> for Option<T> {
fn to_internal_error(self) -> Result<T, Error> {
let loc = Location::caller();
self.ok_or_else(|| {
Error {
error_type: ErrorType::InternalError,
location: format!("{}:{}:{}", loc.file(), loc.line(), loc.column())
}
self.ok_or_else(|| Error {
error_type: ErrorType::InternalError,
location: format!("{}:{}:{}", loc.file(), loc.line(), loc.column()),
})
}
}

View File

@@ -30,6 +30,7 @@ impl<'r> Responder<'r, 'static> for Error {
ErrorType::UnknownChannel => Status::NotFound,
ErrorType::UnknownMessage => Status::NotFound,
ErrorType::UnknownAttachment => Status::BadRequest,
ErrorType::CannotDeleteMessage => Status::Forbidden,
ErrorType::CannotEditMessage => Status::Forbidden,
ErrorType::CannotJoinCall => Status::BadRequest,
ErrorType::TooManyAttachments { .. } => Status::BadRequest,
@@ -42,8 +43,10 @@ impl<'r> Responder<'r, 'static> for Error {
ErrorType::NotInGroup => Status::NotFound,
ErrorType::AlreadyPinned => Status::BadRequest,
ErrorType::NotPinned => Status::BadRequest,
ErrorType::InSlowmode { retry_after: _ } => Status::TooManyRequests,
ErrorType::InvalidFlagValue => Status::BadRequest,
ErrorType::CantCreateServers => Status::Forbidden,
ErrorType::UnknownServer => Status::NotFound,
ErrorType::InvalidRole => Status::NotFound,
ErrorType::Banned => Status::Forbidden,
@@ -80,7 +83,7 @@ impl<'r> Responder<'r, 'static> for Error {
ErrorType::NotAuthenticated => Status::Unauthorized,
ErrorType::DuplicateNonce => Status::Conflict,
ErrorType::NotFound => Status::NotFound,
ErrorType::NoEffect => Status::Ok,
ErrorType::NoEffect => Status::BadRequest,
ErrorType::FailedValidation { .. } => Status::BadRequest,
ErrorType::LiveKitUnavailable => Status::BadRequest,
ErrorType::NotAVoiceChannel => Status::BadRequest,

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-crond"
version = "0.11.0"
version = "0.13.4"
license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"]
edition = "2021"
@@ -11,13 +11,27 @@ publish = false
[dependencies]
# Utility
log = "0.4"
log = { workspace = true }
# Async
tokio = { version = "1" }
tokio = { workspace = true }
# Redis
redis-kiss = { workspace = true }
# RabbitMQ
lapin = { workspace = true }
futures-lite = { workspace = true }
# Processing
serde_json = { workspace = true }
revolt_optional_struct = { workspace = true }
serde = { workspace = true }
iso8601-timestamp = { workspace = true, features = ["serde", "bson"] }
# Core
revolt-database = { version = "0.11.0", path = "../../core/database" }
revolt-result = { version = "0.11.0", path = "../../core/result" }
revolt-config = { version = "0.11.0", path = "../../core/config" }
revolt-files = { version = "0.11.0", path = "../../core/files" }
revolt-database = { workspace = true }
revolt-result = { workspace = true }
revolt-config = { workspace = true }
revolt-files = { workspace = true }
revolt-permissions = { workspace = true }

View File

@@ -5,6 +5,7 @@ FROM debian:12 AS debian
# Bundle Stage
FROM gcr.io/distroless/cc-debian12:nonroot
COPY --from=builder /home/rust/src/target/release/revolt-crond ./
COPY --from=debian /usr/bin/uname /usr/bin/uname
USER nonroot
CMD ["./revolt-crond"]

View File

@@ -1,7 +1,7 @@
use revolt_config::configure;
use revolt_database::DatabaseInfo;
use revolt_result::Result;
use tasks::{file_deletion, prune_dangling_files, prune_members};
use tasks::{acks, file_deletion, prune_dangling_files, prune_members};
use tokio::try_join;
pub mod tasks;
@@ -14,7 +14,8 @@ async fn main() -> Result<()> {
try_join!(
file_deletion::task(db.clone()),
prune_dangling_files::task(db.clone()),
prune_members::task(db.clone())
prune_members::task(db.clone()),
acks::task(db.clone())
)
.map(|_| ())
}

View File

@@ -0,0 +1,129 @@
use futures_lite::stream::StreamExt;
use lapin::{
options::*,
types::FieldTable,
uri::{AMQPAuthority, AMQPQueryString, AMQPUri, AMQPUserInfo},
ConnectionBuilder, ConnectionProperties,
};
use log::info;
use redis_kiss::{get_connection, AsyncCommands, Conn as RedisConnection};
use revolt_config::config;
use revolt_database::{events::rabbit::AckEventPayload, Database};
use revolt_result::{Result, ToRevoltError};
use serde_json;
pub async fn task(db: Database) -> Result<()> {
let config = config().await;
let mut redis = get_connection()
.await
.expect("Failed to get redis connection");
let uri = AMQPUri {
scheme: lapin::uri::AMQPScheme::AMQP,
authority: AMQPAuthority {
userinfo: AMQPUserInfo {
username: config.rabbit.username,
password: config.rabbit.password,
},
host: config.rabbit.host,
port: config.rabbit.port,
},
vhost: "/".to_string(),
query: AMQPQueryString::default(),
};
let connection = ConnectionBuilder::new()
.expect("Builder")
.with_uri(uri)
.with_properties(ConnectionProperties::default())
.connect()
.await
.expect("Failed to connect to rabbitmq");
let reader_channel = connection
.create_channel()
.await
.expect("Failed to create channel");
let mut consumer = reader_channel
.basic_consume(
config.rabbit.queues.acks.into(),
"crond-ack-consumer".into(),
BasicConsumeOptions::default(),
FieldTable::default(),
)
.await
.expect("Failed to create consumer");
while let Some(delivery) = consumer.next().await {
if let Ok(delivery) = delivery {
let payload: std::result::Result<AckEventPayload, _> =
serde_json::from_slice(&delivery.data);
if let Ok(payload) = payload {
info!("{:?}", payload);
if let Err(e) = process_channel_ack(
&db,
payload.user_id,
payload.channel_id.unwrap(),
&mut redis,
)
.await
{
revolt_config::capture_error(&e);
_ = delivery.reject(BasicRejectOptions { requeue: false }).await;
} else {
_ = delivery.ack(BasicAckOptions { multiple: false }).await;
}
} else {
revolt_config::capture_message(
format!("Failed to decode ack data: {:?}", delivery.data).as_str(),
revolt_config::Level::Error,
);
}
}
}
Ok(())
}
#[allow(clippy::disallowed_methods)]
async fn process_channel_ack(
db: &Database,
user: String,
channel: String,
redis: &mut RedisConnection,
) -> Result<()> {
let message_id: Option<String> = redis
.get_del(format!("acker:{user}+{channel}"))
.await
.to_internal_error()?;
if let Some(message_id) = message_id {
// This will be uncommented eventually, but we need to sort out the transition to lapin first. For now we'll simply disable the badge update logic.
// We also drop a db request as a bonus.
//let unread = db.fetch_unread(&user, &channel).await?;
let _updated = db.acknowledge_message(&channel, &user, &message_id).await?;
info!("Set new state for ack: {}:{}:{}", channel, user, message_id);
// if let (Some(before), Some(after)) = (unread, updated) {
// let before_mentions = before.mentions.unwrap_or_default().len();
// let after_mentions = after.mentions.unwrap_or_default().len();
// let mentions_acked = before_mentions - after_mentions;
// if mentions_acked > 0 {
// if let Err(err) = amqp
// .ack_message(user.to_string(), channel.to_string(), payload.message_id)
// .await
// {
// revolt_config::capture_error(&err);
// }
// };
// }
Ok(())
} else {
Err(message_id.to_internal_error().expect_err("no err"))
}
}

View File

@@ -1,3 +1,4 @@
pub mod acks;
pub mod file_deletion;
pub mod prune_dangling_files;
pub mod prune_members;

View File

@@ -1,43 +1,40 @@
[package]
name = "revolt-pushd"
version = "0.11.0"
version = "0.13.4"
edition = "2021"
license = "AGPL-3.0-or-later"
publish = false
[dependencies]
revolt-result = { version = "0.11.0", path = "../../core/result" }
revolt-config = { version = "0.11.0", path = "../../core/config", features = [
"report-macros",
"anyhow",
] }
revolt-database = { version = "0.11.0", path = "../../core/database" }
revolt-models = { version = "0.11.0", path = "../../core/models", features = [
"validator",
] }
revolt-presence = { version = "0.11.0", path = "../../core/presence", features = [
"redis-is-patched",
] }
revolt-result = { workspace = true }
revolt-config = { workspace = true, features = ["report-macros", "anyhow"] }
revolt-database = { workspace = true }
revolt-models = { workspace = true, features = ["validator"] }
revolt-presence = { workspace = true, features = ["redis-is-patched"] }
revolt-parser = { workspace = true }
anyhow = { version = "1.0.98" }
anyhow = { workspace = true }
amqprs = { version = "1.7.0" }
fcm_v1 = "0.3.0"
web-push = "0.10.0"
isahc = { optional = true, version = "1.7", features = ["json"] }
revolt_a2 = { version = "0.10", default-features = false, features = ["ring"] }
tokio = "1.39.2"
async-trait = "0.1.81"
ulid = "1.0.0"
amqprs = { workspace = true }
fcm_v1 = { workspace = true }
web-push = { workspace = true }
isahc = { workspace = true, features = ["json"], optional = true }
revolt_a2 = { workspace = true, features = ["ring"] }
redis-kiss = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
ulid = { workspace = true }
authifier = "1.0.16"
authifier = { workspace = true }
log = "0.4.11"
pretty_env_logger = "0.4.0"
log = { workspace = true }
pretty_env_logger = { workspace = true }
regex = { workspace = true }
#serialization
serde_json = "1"
revolt_optional_struct = "0.2.0"
serde = { version = "1", features = ["derive"] }
iso8601-timestamp = { version = "0.2.10", features = ["serde", "bson"] }
base64 = "0.22.1"
serde_json = { workspace = true }
revolt_optional_struct = { workspace = true }
serde = { workspace = true }
iso8601-timestamp = { workspace = true, features = ["serde", "bson"] }
base64 = { workspace = true }

View File

@@ -128,7 +128,7 @@ impl DmCallConsumer {
config.pushd.vapid.queue.as_str(),
)
.finish();
sendable.extras.insert("p265dh".to_string(), sub.p256dh);
sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable
.extras
.insert("endpoint".to_string(), sub.endpoint.clone());

View File

@@ -101,7 +101,7 @@ impl FRAcceptedConsumer {
config.pushd.vapid.queue.as_str(),
)
.finish();
sendable.extras.insert("p265dh".to_string(), sub.p256dh);
sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable
.extras
.insert("endpoint".to_string(), sub.endpoint.clone());

View File

@@ -101,7 +101,7 @@ impl FRReceivedConsumer {
config.pushd.vapid.queue.as_str(),
)
.finish();
sendable.extras.insert("p265dh".to_string(), sub.p256dh);
sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable
.extras
.insert("endpoint".to_string(), sub.endpoint.clone());

View File

@@ -107,7 +107,7 @@ impl GenericConsumer {
config.pushd.vapid.queue.as_str(),
)
.finish();
sendable.extras.insert("p265dh".to_string(), sub.p256dh);
sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable
.extras
.insert("endpoint".to_string(), sub.endpoint.clone());

View File

@@ -3,7 +3,7 @@ use std::{
hash::RandomState,
};
use crate::consumers::inbound::internal::*;
use crate::{consumers::inbound::internal::*, utils};
use amqprs::{
channel::{BasicPublishArguments, Channel},
connection::Connection,
@@ -17,6 +17,7 @@ use revolt_database::{
MessageFlagsValue,
};
use revolt_models::v0::{MessageFlags, PushNotification};
use revolt_result::ToRevoltError;
pub struct MassMessageConsumer {
#[allow(dead_code)]
@@ -104,7 +105,7 @@ impl MassMessageConsumer {
config.pushd.vapid.queue.as_str(),
)
.finish();
sendable.extras.insert("p265dh".to_string(), sub.p256dh);
sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable
.extras
.insert("endpoint".to_string(), sub.endpoint.clone());
@@ -129,7 +130,17 @@ impl MassMessageConsumer {
) -> Result<()> {
let config = revolt_config::config().await;
let content = String::from_utf8(content)?;
let payload: MassMessageSentPayload = serde_json::from_str(content.as_str())?;
let mut payload: MassMessageSentPayload = serde_json::from_str(content.as_str())?;
for push in payload.notifications.iter_mut() {
if let Ok(body) = utils::render_notification_content(push, &self.db)
.await
.to_internal_error()
{
push.raw_body = Some(push.body.clone());
push.body = body;
}
}
debug!("Received mass message event");

View File

@@ -1,6 +1,6 @@
use std::collections::HashMap;
use crate::consumers::inbound::internal::*;
use crate::{consumers::inbound::internal::*, utils};
use amqprs::{
channel::{BasicPublishArguments, Channel},
connection::Connection,
@@ -11,6 +11,7 @@ use anyhow::Result;
use async_trait::async_trait;
use log::debug;
use revolt_database::{events::rabbit::*, Database};
use revolt_result::ToRevoltError;
pub struct MessageConsumer {
#[allow(dead_code)]
@@ -64,7 +65,15 @@ impl MessageConsumer {
content: Vec<u8>,
) -> Result<()> {
let content = String::from_utf8(content)?;
let payload: MessageSentPayload = serde_json::from_str(content.as_str())?;
let mut payload: MessageSentPayload = serde_json::from_str(content.as_str())?;
if let Ok(body) = utils::render_notification_content(&payload.notification, &self.db)
.await
.to_internal_error()
{
payload.notification.raw_body = Some(payload.notification.body);
payload.notification.body = body;
}
debug!("Received message event on origin");
@@ -107,7 +116,7 @@ impl MessageConsumer {
config.pushd.vapid.queue.as_str(),
)
.finish();
sendable.extras.insert("p265dh".to_string(), sub.p256dh);
sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable
.extras
.insert("endpoint".to_string(), sub.endpoint.clone());

View File

@@ -5,9 +5,8 @@ use amqprs::{channel::Channel as AmqpChannel, consumer::AsyncConsumer, BasicProp
use anyhow::{anyhow, bail, Result};
use async_trait::async_trait;
use fcm_v1::{
android::{AndroidConfig, AndroidMessagePriority},
auth::{Authenticator, ServiceAccountKey},
message::{Message, Notification},
message::Message,
Client, Error as FcmError,
};
use revolt_config::config;
@@ -15,6 +14,102 @@ use revolt_database::{events::rabbit::*, Database};
use revolt_models::v0::{Channel, PushNotification};
use serde_json::Value;
/// Custom notification data
#[derive(Debug, Clone, PartialEq)]
pub enum NotificationData {
FRReceived {
id: String,
username: String,
},
FRAccepted {
id: String,
username: String,
},
Generic {
title: String,
body: String,
image: Option<String>,
},
Message {
title: String,
body: String,
image: String,
tag: String,
},
DmCallStartEnd {
initiator_id: String,
channel_id: String,
started_at: String,
ended: bool,
duration: usize,
},
}
impl NotificationData {
pub fn get_type(&self) -> &str {
match self {
NotificationData::FRReceived { .. } => "push.fr.receive",
NotificationData::FRAccepted { .. } => "push.fr.accept",
NotificationData::Generic { .. } => "push.generic",
NotificationData::Message { .. } => "push.message",
NotificationData::DmCallStartEnd { .. } => "push.dm.call",
}
}
pub fn into_payload(self) -> HashMap<String, Value> {
let mut data = HashMap::new();
data.insert(
"type".to_string(),
Value::String(self.get_type().to_string()),
);
match self {
NotificationData::FRReceived { id, username } => {
data.insert("id".to_string(), Value::String(id));
data.insert("username".to_string(), Value::String(username));
}
NotificationData::FRAccepted { id, username } => {
data.insert("id".to_string(), Value::String(id));
data.insert("username".to_string(), Value::String(username));
}
NotificationData::Generic { title, body, image } => {
data.insert("title".to_string(), Value::String(title));
data.insert("body".to_string(), Value::String(body));
if let Some(image) = image {
data.insert("image".to_string(), Value::String(image));
}
}
NotificationData::Message {
title,
body,
image,
tag,
} => {
data.insert("title".to_string(), Value::String(title));
data.insert("body".to_string(), Value::String(body));
data.insert("image".to_string(), Value::String(image));
data.insert("tag".to_string(), Value::String(tag));
}
NotificationData::DmCallStartEnd {
initiator_id,
channel_id,
started_at,
ended,
duration,
} => {
data.insert("initiator_id".to_string(), Value::String(initiator_id));
data.insert("channel_id".to_string(), Value::String(channel_id));
data.insert("started_at".to_string(), Value::String(started_at));
data.insert("ended".to_string(), Value::Bool(ended));
data.insert("duration".to_string(), Value::Number(duration.into()));
}
}
data
}
}
pub struct FcmOutboundConsumer {
db: Database,
client: Client,
@@ -93,17 +188,14 @@ impl FcmOutboundConsumer {
.clone()
.ok_or_else(|| anyhow!("missing name"))?;
let mut data = HashMap::new();
data.insert(
"type".to_string(),
Value::String("push.fr.receive".to_string()),
);
data.insert("id".to_string(), Value::String(alert.from_user.id));
data.insert("username".to_string(), Value::String(name));
let data = NotificationData::FRReceived {
id: alert.from_user.id,
username: name,
};
let msg = Message {
token: Some(payload.token),
data: Some(data),
data: Some(data.into_payload()),
..Default::default()
};
@@ -121,30 +213,29 @@ impl FcmOutboundConsumer {
.clone()
.ok_or_else(|| anyhow!("missing name"))?;
let mut data: HashMap<String, Value> = HashMap::new();
data.insert(
"type".to_string(),
Value::String("push.fr.accept".to_string()),
);
data.insert("id".to_string(), Value::String(alert.accepted_user.id));
data.insert("username".to_string(), Value::String(name));
let data = NotificationData::FRAccepted {
id: alert.accepted_user.id,
username: name,
};
let msg = Message {
token: Some(payload.token),
data: Some(data),
data: Some(data.into_payload()),
..Default::default()
};
resp = self.client.send(&msg).await;
}
PayloadKind::Generic(alert) => {
let data = NotificationData::Generic {
title: alert.title,
body: alert.body,
image: alert.icon,
};
let msg = Message {
token: Some(payload.token),
notification: Some(Notification {
title: Some(alert.title),
body: Some(alert.body),
image: alert.icon,
}),
data: Some(data.into_payload()),
..Default::default()
};
@@ -152,19 +243,16 @@ impl FcmOutboundConsumer {
}
PayloadKind::MessageNotification(alert) => {
let title = self.format_title(&alert);
let data = NotificationData::Message {
title: self.format_title(&alert),
body: alert.body,
image: alert.icon,
tag: alert.tag,
};
let msg = Message {
token: Some(payload.token),
notification: Some(Notification {
title: Some(title),
body: Some(alert.body),
image: Some(alert.icon),
}),
android: Some(AndroidConfig {
collapse_key: Some(alert.tag),
..Default::default()
}),
data: Some(data.into_payload()),
..Default::default()
};
@@ -172,30 +260,17 @@ impl FcmOutboundConsumer {
}
PayloadKind::DmCallStartEnd(alert) => {
let mut data: HashMap<String, Value> = HashMap::new();
data.insert(
"initiator_id".to_string(),
Value::String(alert.initiator_id),
);
data.insert("channel_id".to_string(), Value::String(alert.channel_id));
data.insert(
"started_at".to_string(),
Value::String(alert.started_at.unwrap_or_else(|| "".to_string())),
);
data.insert("ended".to_string(), Value::Bool(alert.ended));
let data = NotificationData::DmCallStartEnd {
initiator_id: alert.initiator_id,
channel_id: alert.channel_id,
started_at: alert.started_at.unwrap_or_else(|| "".to_string()),
ended: alert.ended,
duration: config().await.api.livekit.call_ring_duration,
};
let msg = Message {
token: Some(payload.token),
notification: None,
data: Some(data),
android: Some(AndroidConfig {
priority: Some(AndroidMessagePriority::High),
ttl: Some(format!(
"{}s",
config().await.api.livekit.call_ring_duration
)),
..Default::default()
}),
data: Some(data.into_payload()),
..Default::default()
};

View File

@@ -14,6 +14,7 @@ use revolt_config::{config, Settings};
use tokio::sync::Notify;
mod consumers;
mod utils;
use consumers::{
inbound::{
ack::AckConsumer, dm_call::DmCallConsumer, fr_accepted::FRAcceptedConsumer,

View File

@@ -0,0 +1,2 @@
mod renderer;
pub use renderer::render_notification_content;

View File

@@ -0,0 +1,272 @@
use redis_kiss::{get_connection as _get_conn, AsyncCommands, Conn};
use regex::Regex;
use revolt_config::config;
use revolt_database::{Channel, Database};
use revolt_models::v0::PushNotification;
use revolt_parser::parse_message;
use revolt_result::{create_error, Result, ToRevoltError};
use std::{
borrow::Cow,
collections::{HashMap, HashSet},
};
use tokio::join;
async fn get_connection() -> Result<Conn> {
_get_conn().await.map_err(|_| create_error!(InternalError))
}
pub async fn render_notification_content(
notification: &PushNotification,
db: &Database,
) -> Result<String> {
let parsed = parse_message(&notification.body);
let user_mentions: HashMap<String, String>;
let channel_mentions: HashMap<String, String>;
let emojis: HashMap<String, String>;
let roles: HashMap<String, String>;
let server_id: Option<String> = get_channel_server_id(notification.channel.id(), db)
.await
.map(Some)
.or(Ok(None))?;
if server_id.is_some() {
(user_mentions, channel_mentions, emojis, roles) = join!(
get_items(
parsed.user_mentions,
server_id.as_deref(),
db,
get_user_display_name,
"Unknown User".to_string()
),
get_items(
parsed.channel_mentions,
server_id.as_deref(),
db,
get_channel_name,
"Unknown Channel".to_string()
),
get_items(
parsed.emojis,
server_id.as_deref(),
db,
get_emoji_name,
"Unknown Emoji".to_string()
),
get_items(
parsed.role_mentions,
server_id.as_deref(),
db,
get_role_name,
"Unknown Role".to_string()
),
);
} else {
roles = HashMap::default();
(user_mentions, channel_mentions, emojis) = join!(
get_items(
parsed.user_mentions,
server_id.as_deref(),
db,
get_user_display_name,
"Unknown User".to_string()
),
get_items(
parsed.channel_mentions,
server_id.as_deref(),
db,
get_channel_name,
"Unknown Channel".to_string()
),
get_items(
parsed.emojis,
server_id.as_deref(),
db,
get_emoji_name,
"Unknown Emoji".to_string()
),
);
}
let mut body = Cow::Borrowed(notification.body.as_str());
for (uid, name) in user_mentions {
replace_all_in_place(
Regex::new(format!("<@{uid}>").as_str()).unwrap(),
&mut body,
format!("@{name}").as_str(),
);
}
for (uid, name) in channel_mentions {
replace_all_in_place(
Regex::new(format!("<#{uid}>").as_str()).unwrap(),
&mut body,
format!("#{name}").as_str(),
);
}
for (uid, name) in roles {
replace_all_in_place(
Regex::new(format!("<%{uid}>").as_str()).unwrap(),
&mut body,
format!("%{name}").as_str(),
);
}
for (uid, name) in emojis {
replace_all_in_place(
Regex::new(format!(":{uid}:").as_str()).unwrap(),
&mut body,
format!(":{name}:").as_str(),
);
}
Ok(body.to_string())
}
async fn get_items<F>(
items: HashSet<String>,
server_id: Option<&str>,
db: &Database,
getter: F,
invalid_string: String,
) -> HashMap<String, String>
where
F: AsyncFn(&str, Option<&str>, &Database) -> Result<String>,
{
let mut resp = HashMap::default();
for obj_id in items {
resp.insert(
obj_id.clone(),
getter(&obj_id, server_id, db)
.await
.unwrap_or(invalid_string.clone()),
);
}
resp
}
// Getters
async fn get_user_display_name(id: &str, server: Option<&str>, db: &Database) -> Result<String> {
let config = config().await;
let mut conn = get_connection().await?;
let key = format!("pushd-user-cache:{}:{id}", server.unwrap_or("GLOBAL"));
if let Ok(name) = conn.get(key.clone()).await {
return Ok(name);
}
if let Some(server) = server {
let member = db.fetch_member(server, id).await?;
if let Some(nickname) = member.nickname {
conn.set_ex::<_, _, ()>(key, nickname.clone(), config.pushd.render_cache_time)
.await
.to_internal_error()?;
return Ok(nickname);
}
}
let user = db.fetch_user(id).await?;
let name = user.display_name.unwrap_or(user.username);
conn.set_ex::<_, _, ()>(key, name.clone(), config.pushd.render_cache_time)
.await
.to_internal_error()?;
Ok(name)
}
async fn get_channel_name(id: &str, _server: Option<&str>, db: &Database) -> Result<String> {
let config = config().await;
let mut conn = get_connection().await?;
let key = format!("pushd-channel-cache:{id}");
if let Ok(name) = conn.get(key.clone()).await {
return Ok(name);
}
let channel = db.fetch_channel(id).await?;
let name = match channel {
Channel::DirectMessage { .. } => "DM Channel".to_string(),
Channel::Group { name, .. } | Channel::TextChannel { name, .. } => name,
Channel::SavedMessages { .. } => "Err".to_string(),
};
conn.set_ex::<_, _, ()>(key, name.clone(), config.pushd.render_cache_time)
.await
.to_internal_error()?;
Ok(name)
}
async fn get_role_name(id: &str, server: Option<&str>, db: &Database) -> Result<String> {
let server = server.unwrap(); // Must be passed, but the interface must stay the same as the other getters
let config: revolt_config::Settings = config().await;
let mut conn = get_connection().await?;
let key = format!("pushd-role-cache:{server}:{id}");
if let Ok(name) = conn.get(key.clone()).await {
return Ok(name);
}
let server = db.fetch_server(server).await?;
let name = server
.roles
.get(id)
.ok_or_else(|| create_error!(NotFound))?
.name
.clone();
conn.set_ex::<_, _, ()>(key, name.clone(), config.pushd.render_cache_time)
.await
.to_internal_error()?;
Ok(name)
}
async fn get_emoji_name(id: &str, _server: Option<&str>, db: &Database) -> Result<String> {
let config: revolt_config::Settings = config().await;
let mut conn = get_connection().await?;
let key = format!("pushd-emoji-cache:{id}");
if let Ok(name) = conn.get(key.clone()).await {
return Ok(name);
}
let emoji = db.fetch_emoji(id).await?;
let name = emoji.name;
conn.set_ex::<_, _, ()>(key, name.clone(), config.pushd.render_cache_time)
.await
.to_internal_error()?;
Ok(name)
}
// utility
async fn get_channel_server_id(channel_id: &str, db: &Database) -> Result<String> {
match db.fetch_channel(channel_id).await? {
Channel::DirectMessage { .. } | Channel::Group { .. } | Channel::SavedMessages { .. } => {
Err(create_error!(NotFound))
}
Channel::TextChannel { server, .. } => Ok(server),
}
}
fn replace_all_in_place<R: regex::Replacer>(regex: Regex, s: &mut Cow<'_, str>, replacer: R) {
let new = regex.replace_all(s, replacer);
if let Cow::Owned(o) = new {
*s = Cow::Owned(o);
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-voice-ingress"
version = "0.11.0"
version = "0.13.4"
license = "AGPL-3.0-or-later"
edition = "2021"
publish = false
@@ -9,41 +9,41 @@ publish = false
[dependencies]
# util
log = "*"
sentry = "0.31.5"
lru = "0.7.6"
ulid = "0.5.0"
redis-kiss = "0.1.4"
chrono = "0.4.15"
log = { workspace = true }
sentry = { workspace = true }
lru = { workspace = true }
ulid = { workspace = true }
redis-kiss = { workspace = true }
chrono = { workspace = true }
# Serde
serde_json = "1.0.79"
rmp-serde = "1.0.0"
serde = "1.0.136"
serde_json = { workspace = true }
rmp-serde = { workspace = true }
serde = { workspace = true }
# Http
rocket = { version = "0.5.0-rc.2", features = ["json"] }
rocket_empty = "0.1.1"
rocket = { workspace = true, features = ["json"] }
rocket_empty = { workspace = true }
# Async
futures = "0.3.21"
async-std = { version = "1.8.0", features = [
futures = { workspace = true }
async-std = { workspace = true, features = [
"tokio1",
"tokio02",
"attributes",
] }
# Core
revolt-result = { path = "../../core/result" }
revolt-models = { path = "../../core/models" }
revolt-config = { path = "../../core/config" }
revolt-database = { path = "../../core/database", features = ["voice"] }
revolt-permissions = { path = "../../core/permissions" }
revolt-result = { workspace = true, features = ["rocket"] }
revolt-models = { workspace = true }
revolt-config = { workspace = true }
revolt-database = { workspace = true, features = ["voice"] }
revolt-permissions = { workspace = true }
# Voice
livekit-api = "0.4.4"
livekit-protocol = "0.4.0"
livekit-runtime = { version = "0.3.1", features = ["tokio"] }
livekit-api = { workspace = true }
livekit-protocol = { workspace = true }
livekit-runtime = { workspace = true, features = ["tokio"] }
# RabbitMQ
amqprs = { version = "1.7.0" }
amqprs = { workspace = true }

View File

@@ -5,9 +5,9 @@ use revolt_database::{
iso8601_timestamp::{Duration, Timestamp},
util::reference::Reference,
voice::{
create_voice_state, delete_voice_state,
get_user_moved_from_voice, get_user_moved_to_voice,
update_voice_state_tracks, VoiceClient,
create_voice_state, delete_channel_voice_state, delete_voice_state,
get_user_moved_from_voice, get_user_moved_to_voice, update_voice_state_tracks,
RoomMetadata, UserVoiceChannel, VoiceClient,
},
Database, AMQP,
};
@@ -51,27 +51,34 @@ pub async fn ingress(
let channel_id = event.room.as_ref().map(|r| &r.name);
let user_id = event.participant.as_ref().map(|r| &r.identity);
let room_metadata = if let Some(room) = event.room.as_ref() {
Some(serde_json::from_str::<RoomMetadata>(&room.metadata).to_internal_error()?)
} else {
None
};
match event.event.as_str() {
// User joined a channel
"participant_joined" => {
let channel_id = channel_id.to_internal_error()?;
let user_id = user_id.to_internal_error()?;
let channel = Reference::from_unchecked(channel_id).as_channel(db).await?;
let server_id = room_metadata.to_internal_error()?.server;
let channel = UserVoiceChannel {
id: channel_id.clone(),
server_id: server_id.clone(),
};
let joined_at = Timestamp::UNIX_EPOCH
.checked_add(Duration::seconds(event.created_at))
.unwrap();
let voice_state =
create_voice_state(channel_id, channel.server(), user_id, joined_at).await?;
let voice_state = create_voice_state(&channel, user_id, joined_at).await?;
// Only publish one event when a user is moved from one channel to another.
if let Some(moved_from) = get_user_moved_to_voice(channel_id, user_id).await? {
EventV1::VoiceChannelMove {
user: user_id.to_string(),
from: moved_from,
from: moved_from.id,
to: channel_id.to_string(),
state: voice_state,
}
@@ -136,10 +143,13 @@ pub async fn ingress(
"participant_left" => {
let channel_id = channel_id.to_internal_error()?;
let user_id = user_id.to_internal_error()?;
let server_id = room_metadata.to_internal_error()?.server;
let channel = UserVoiceChannel {
id: channel_id.clone(),
server_id: server_id.clone(),
};
let channel = Reference::from_unchecked(channel_id).as_channel(db).await?;
delete_voice_state(channel_id, channel.server(), user_id).await?;
delete_voice_state(&channel, user_id).await?;
// Dont send leave event when a user is moved
if get_user_moved_from_voice(channel_id, user_id)
@@ -203,8 +213,11 @@ pub async fn ingress(
let channel_id = channel_id.to_internal_error()?;
let user_id = user_id.to_internal_error()?;
let track = event.track.as_ref().to_internal_error()?;
let channel = Reference::from_unchecked(channel_id).as_channel(db).await?;
let server_id = room_metadata.to_internal_error()?.server;
let channel = UserVoiceChannel {
id: channel_id.clone(),
server_id: server_id.clone(),
};
let user = Reference::from_unchecked(user_id).as_user(db).await?;
@@ -244,15 +257,14 @@ pub async fn ingress(
log::debug!("Removing user {user_id} from channel {channel_id} {event:?} due to forbidden track.");
let _ = voice_client.remove_user(node, user_id, channel_id).await;
delete_voice_state(channel_id, channel.server(), user_id).await?;
delete_voice_state(&channel, user_id).await?;
return Ok(EmptyResponse);
};
};
let partial = update_voice_state_tracks(
channel_id,
channel.server(),
&channel,
user_id,
event.event == "track_published" || event.event == "track_unmuted", // to avoid duplicating this entire case twice
track.source,
@@ -267,6 +279,16 @@ pub async fn ingress(
.p(channel_id.clone())
.await;
}
"room_finished" => {
let channel_id = channel_id.to_internal_error()?;
let server_id = room_metadata.to_internal_error()?.server;
let channel = UserVoiceChannel {
id: channel_id.clone(),
server_id: server_id.clone(),
};
delete_channel_voice_state(&channel, &[]).await?;
}
_ => {}
};

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-delta"
version = "0.11.0"
version = "0.13.4"
license = "AGPL-3.0-or-later"
authors = ["Paul Makles <paulmakles@gmail.com>"]
edition = "2018"
@@ -10,84 +10,83 @@ publish = false
[dependencies]
# Test
rand = "0.8.5"
redis-kiss = "0.1.4"
rand = { workspace = true }
redis-kiss = { workspace = true }
# Utility
lru = "0.7.0"
url = "2.2.2"
log = "0.4.11"
dashmap = "5.2.0"
linkify = "0.6.0"
once_cell = "1.17.1"
env_logger = "0.7.1"
lru = { workspace = true }
url = { workspace = true }
log = { workspace = true }
dashmap = { workspace = true }
linkify = { workspace = true }
once_cell = { workspace = true }
# Lang. Utilities
regex = "1"
num_enum = "0.5.1"
impl_ops = "0.1.1"
bitfield = "0.13.2"
regex = { workspace = true }
num_enum = { workspace = true }
impl_ops = { workspace = true }
bitfield = { workspace = true }
# ID / key generation
ulid = "0.4.1"
nanoid = "0.4.0"
ulid = { workspace = true }
nanoid = { workspace = true }
# serde
serde_json = "1.0.57"
serde = { version = "1.0.115", features = ["derive"] }
validator = { version = "0.16", features = ["derive"] }
iso8601-timestamp = { version = "0.2.11", features = [] }
serde_json = { workspace = true }
serde = { workspace = true }
validator = { workspace = true, features = ["derive"] }
iso8601-timestamp = { workspace = true }
# 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 = [
futures = { workspace = true }
chrono = { workspace = true }
async-channel = { workspace = true }
reqwest = { workspace = true, features = ["json"] }
async-std = { workspace = true, features = [
"tokio1",
"tokio02",
"attributes",
] }
# internal util
lettre = "0.10.0-alpha.4"
lettre = { workspace = true }
# web
rocket = { version = "0.5.1", default-features = false, features = ["json"] }
rocket_cors = { git = "https://github.com/lawliet89/rocket_cors", rev = "072d90359b23e9b291df6b672c07c93de9c46011" }
rocket_empty = { version = "0.1.1", features = ["schema"] }
rocket_authifier = { version = "1.0.16" }
rocket_prometheus = "0.10.0-rc.3"
rocket = { workspace = true, features = ["json"] }
rocket_cors = { workspace = true }
rocket_empty = { workspace = true, features = ["schema"] }
rocket_authifier = { workspace = true }
rocket_prometheus = { workspace = true }
# spec generation
schemars = "0.8.8"
revolt_rocket_okapi = { version = "0.10.0", features = ["swagger"] }
schemars = { workspace = true }
revolt_rocket_okapi = { workspace = true, features = ["swagger"] }
# rabbit
amqprs = { version = "1.7.0" }
amqprs = { workspace = true }
# core
authifier = "1.0.16"
revolt-config = { path = "../core/config" }
revolt-database = { path = "../core/database", features = [
authifier = { workspace = true }
revolt-config = { workspace = true }
revolt-database = { workspace = true, features = [
"rocket-impl",
"redis-is-patched",
"voice",
] }
revolt-models = { path = "../core/models", features = [
revolt-models = { workspace = true, features = [
"schemas",
"validator",
"rocket",
] }
revolt-presence = { path = "../core/presence" }
revolt-result = { path = "../core/result", features = ["rocket", "okapi"] }
revolt-permissions = { path = "../core/permissions", features = ["schemas"] }
revolt-ratelimits = { path = "../core/ratelimits", features = ["rocket"] }
revolt-presence = { workspace = true }
revolt-result = { workspace = true, features = ["rocket", "okapi"] }
revolt-permissions = { workspace = true, features = ["schemas"] }
revolt-ratelimits = { workspace = true, features = ["rocket"] }
# voice
livekit-api = "0.4.4"
livekit-protocol = "0.4.0"
livekit-api = { workspace = true }
livekit-protocol = { workspace = true }
[build-dependencies]
vergen = "7.5.0"
vergen = { workspace = true }

View File

@@ -24,8 +24,8 @@ use amqprs::{
};
use async_std::channel::unbounded;
use authifier::AuthifierEvent;
use rocket::data::ToByteUnit;
use revolt_database::voice::VoiceClient;
use rocket::data::ToByteUnit;
pub async fn web() -> Rocket<Build> {
// Get settings
@@ -93,22 +93,6 @@ pub async fn web() -> Rocket<Build> {
)
.into();
let swagger_0_8 = revolt_rocket_okapi::swagger_ui::make_swagger_ui(
&revolt_rocket_okapi::swagger_ui::SwaggerUIConfig {
url: "/0.8/openapi.json".to_owned(),
..Default::default()
},
)
.into();
let swagger_0_8 = revolt_rocket_okapi::swagger_ui::make_swagger_ui(
&revolt_rocket_okapi::swagger_ui::SwaggerUIConfig {
url: "/0.8/openapi.json".to_owned(),
..Default::default()
},
)
.into();
// Voice handler
let voice_client = VoiceClient::new(config.api.livekit.nodes.clone());
// Configure Rabbit
@@ -135,7 +119,10 @@ pub async fn web() -> Rocket<Build> {
.await
.expect("Failed to declare exchange");
let amqp = AMQP::new(connection, channel);
let mut amqp = AMQP::new(connection, channel);
// amqp.configure_channels()
// .await
// .expect("Failed to configure channels");
// Launch background task workers
revolt_database::tasks::start_workers(db.clone(), amqp.clone());
@@ -153,7 +140,6 @@ pub async fn web() -> Rocket<Build> {
.mount("/", rocket_cors::catch_all_options_routes())
.mount("/", ratelimiter::routes())
.mount("/swagger/", swagger)
.mount("/0.8/swagger/", swagger_0_8)
.manage(authifier)
.manage(db)
.manage(amqp)

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